Skip to content

fix: stop hydrate trusting the snapshot filter and sort state came back in - #42

Merged
ndlabdev merged 2 commits into
devfrom
fix/41-hydrate-sanitize
Sep 3, 2026
Merged

ndlabdev merged 2 commits into
devfrom
fix/41-hydrate-sanitize

Conversation

@ndlabdev

Copy link
Copy Markdown
Owner

Summary

filtering's hydrate cast the state slice straight to FilterModel. The cast is a promise to the compiler, not a runtime check, and every path that reaches hydrate is untrusted: share links, localStorage, and anything handed back to setState. A condition whose operator belonged to another kind, a set whose values was not a list, or a condition missing the value its operator needs all reached the predicate builders as a shape they never tested, and threw while the pipeline's $derived was reading them.

That read happens inside the body's {#each grid.nodes}, so the throw took the whole render pass rather than one column. Six of the seven shapes measured against 1.3.0 brought the grid down.

sorting's hydrate had a thinner version of the same defect: it checked Array.isArray and then cast, so a null entry threw on columnId.

Closes #41

Changes

Boundary layer. sanitizeFilterModel rebuilds the model from the part that can be read and drops the rest: a kind outside the five, an op outside the list for that kind, a set whose values is not an array, an operator that needs a value and has none. A group keeps its readable conditions and is dropped once it has none. sanitizeSortState keeps only the entries naming a column and a direction.

Predicate layer. For a condition arriving some other way, applyFilterModel included: the number comparator is looked up through a widened alias and checked, the text and date switches take a default branch, setPredicate checks its array, and entryPredicate checks its condition list. All of them pass every row instead of throwing.

Either layer alone stops the crash. Both mean a future bug down a different path does not land in the pipeline either.

Behaviour changes

  • A filter that cannot be read is dropped, so the grid shows more rows than the snapshot asked for. Recorded as a deliberate call: for a column behind a value gate that is not strictly failing safe, and the comment in compileColumnFilters already says such a column needs its filter taken off by policy rather than by the gate. A grid that will not render is worse.
  • { kind: 'boolean', value: 'yes' } used to return zero rows and now returns every row, for the same reason.
  • No valid filter changes. What the editor builds passes through untouched, presence operators and their empty text value included.
  • The public API is unchanged: the two sanitizers stay inside their feature barrels and do not reach src/lib/features/index.ts.

Known remaining edge

describeFilter was left alone and still throws on a set whose values is not an array, returns undefined for an unknown kind, and writes Contains "undefined" for a missing value. The only way there now is an app calling applyFilterModel directly with broken data and the grid then drawing a chip for it; the setState path is sanitized before it gets that far. Out of scope for this fix, and worth its own issue.

Checklist

  • pnpm check: 1503 files, 0 errors, 0 warnings
  • pnpm lint: clean
  • pnpm test: 105 files, 1323 passed, 13 skipped
  • Regression tests: 21 in filter-sanitize.test.ts (the measured table through setState, the same table through applyFilterModel, and the sanitizer's own units), 4 for sanitizeSortState, 1 grid-level sort hydrate case
  • budgets.test.ts passes; the added work is all at predicate build time, not in the per-row loop, so no separate bench run

`hydrate` cast the state slice straight to `FilterModel`, which is a promise
to the compiler and not a check. A condition whose operator belonged to
another kind, a `set` whose `values` was not a list, or a condition missing
the value its operator needs all reached the predicate builders as a shape
they never tested, and threw while the pipeline's `$derived` was reading
them. That read happens inside the body's `{#each grid.nodes}`, so the throw
took the render pass rather than one column. Six of the seven shapes measured
against 1.3.0 brought the grid down, and every path that reaches `hydrate` is
untrusted: share links, `localStorage`, and anything handed back to
`setState`.

`sanitizeFilterModel` now rebuilds the model from the part that can be read
and drops the rest. A column left with no readable condition stops filtering,
which shows more rows rather than none. That is the deliberate call: for a
column behind a value gate it is not strictly failing safe, and a grid that
will not render is worse.

The predicates carry a second layer for a condition arriving some other way,
`applyFilterModel` included: an unknown operator, a missing value, a `set`
whose values are not a list and a kind nothing knows now pass every row
instead of throwing.

Closes #41
`hydrate` checked `Array.isArray` and then cast, which reads as a check and
is not one: a null entry in that array threw on `columnId` while the pipeline
was sorting. The same untrusted path as the filter model, one layer thinner.

`sanitizeSortState` keeps only the entries naming a column and a direction.
An unknown direction, a missing `columnId` and a plain string entry already
degraded quietly; they are now dropped rather than carried.
@ndlabdev ndlabdev added the bug Something isn't working label Aug 28, 2026
@ndlabdev ndlabdev self-assigned this Aug 28, 2026
@ndlabdev
ndlabdev merged commit d69c568 into dev Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant