Repository navigation
fix(app-shell): drop the filter[...] operator suffix instead of swallowing it into the field name - #9285
Conversation
…llowing it into the field name `ObjectView` was a THIRD reader of the `filter[...]` URL family, with its own greedy field capture. An operator-suffixed key therefore did not fail to match — it matched with the suffix INSIDE the field name, so `?filter[amount][gte]=100` on `/apps/:app/:object` emitted a condition against a field literally named `amount][gte` that no object declares: a silently wrong query, not an ignored parameter and not a refusal. The route now reads through `drillUrlFilters`, the one module that owns this family, via a new equality-only arm. Both arms share ONE key grammar whose field slot excludes brackets, so the swallowing is structurally impossible rather than fixed at one call site. An operator suffix this route cannot execute is dropped — the posture the `/data` surface already declares: ignored, never silently downgraded to equality. Range operators are deliberately NOT added to this route; that would widen the accepted set of an addressable public surface and belongs to the maintainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review — PR objectui#9285 @ head
|
| check | reading |
|---|---|
CI at c9e370c4d |
36/36 settled — 0 failing, 0 pending |
--pair 9285 |
✓ PR #9285 / card #9196 — both carriers agree |
| ruling compliance | ✓ candidate 2 only. Read in the diff, not taken from the report: parseUrlFilterTriples keeps its operator arm and parseUrlEqualityFilterTriples returns early on a suffix. No range operator reaches /apps/:app/:object |
| the swallowing is structural now | ✓ one FILTER_KEY grammar, field slot [^[\]]+ — excludes both brackets, so a suffix cannot enter a field name on either arm |
drillUrlFilters is internal |
✓ verified independently: absent from packages/app-shell/src/index.ts, and the package's only entry is "." → dist/index.js. ⇒ the new export adds no public signature, which is what the patch level rests on |
| No-Touch zone | ✓ not touched |
⭐ The distinction this card turns on, and the diff makes it in the code rather than only in prose
An operator suffix this route cannot execute is DROPPED … answering
amount = 100when the URL asked foramount >= 100would be a wrong answer wearing a right answer's shape.
⇒ and the same sentence is written at the call site, in the module, and in the changeset — three readers, one statement, no room for the next seat to "helpfully" downgrade it. ⭐ That posture was copied from /data's existing declaration rather than invented, which is the whole reason there is no fourth posture in this repo now.
⭐ One second-order delta I measured myself, because the single grammar is not a pure narrowing
The operator slot widened: [a-z]+ → [^[\]]+. So ?filter[amount][GTE]=100 now matches FILTER_KEY where before it matched neither old regex.
The outcome is unchanged — URL_FILTER_OPS['GTE'] is undefined, so the triple is dropped, exactly as the non-matching key was dropped before. ⇒ same answer by a different route. I record it because it is the one place the new grammar accepts more keys than the old pair, and "accepts more keys" is the shape that usually hides a widening. It does not turn into more triples here, and the module's docblock already declares "an unknown operator suffix is ignored", so the behaviour is the declared one.
⭐ The differential is the right instrument, and it read the real bytes
254 key/value pairs, 244 identical, 10 changed, every one a key whose FIELD NAME contains a [ — each a narrowing that deletes a wrong answer, none a widening. ⭐ And the probe lifted the regex literal out of ObjectView.tsx on disk instead of a hand-copied pattern, so the before-side is the shipped code and not a transcription of it. The differ carried its own control (two deliberately unequal readings compared unequal), so "no difference" would have been a reading.
⭐ Two ablation legs whose green halves are each other's controls
Leg A (greedy capture back in the shared grammar): predicted red set written before the run, observed 17 failed | 12 passed = exactly that set, with the lit control and the whole source-scan block staying green.
Leg B (ObjectView.tsx reverted to base): 2 failed | 27 passed, exactly the two source-scan pins, anchor positive control green.
Mutations proved on disk (marker counts and blob hashes both ways) before either run was believed; restores proved by state, ⛔ never by an exit code.
⭐ The in-flight check had a control that lit
All 14 open PRs enumerated (including the 1147-file Changesets release PR, fully paginated): zero touch views/ObjectView.tsx or views/drillUrlFilters*. ⭐ Control leg: the same channel and matcher run against PR #9236, known to touch ObjectView.tsx, returned the hit. ⇒ the empty result is a reading. Same for the 177-branch remote glob with its own lit control.
The three out-of-scope findings are all correctly placed
- objectui#9287 filed, not folded in. ⭐ The reason is the sharp one: repairing the
&/+memo-key truncation would move the plain-equality control this card's acceptance depends on. Fixing it here would have quietly invalidated the instrument. NavigationRenderer.tsx:719, a fourth copy, noted and not filed — measured to fail safe (no failing probe with a wrong user-visible outcome can be named), and the bounded in-place exemption correctly failed on condition 4, new verification surface. A named successor rather than a dangling observation.- The silent drop with no chip, inherited from the
/dataposture this card was told to copy. ⛔ Inventing a refusal UI here would have been the fourth posture. Right call.
Changeset
patch, argued on the three axes that decide it and with the internal-module claim I re-measured above. ⛔ major unavailable by construction. pnpm changeset:check exit 0 — the real script name, and the dispatch's warning about no-major held.
Carriers
Cleared in one stroke on both limbs — PR objectui#9285 and card objectui#9196 — each read back.
Generated by Claude Code
Fixes #9196
The defect, stated sharply
ObjectViewwas a THIRD reader of thefilter[...]URL family, carrying its ownfield capture. The capture was GREEDY, so an operator-suffixed key did not fail to
match — it matched with the suffix swallowed INTO THE FIELD NAME. On
/apps/:app/:object,?filter[amount][gte]=100emitted a condition against a fieldliterally named
amount][gte, which no object declares.The user therefore got a silently wrong query — not an ignored parameter, and not
a refusal.
The ruling this PR implements, and the one it does not
The triage comment on the card split the two candidate repairs across the human floor.
/datasurface already declares in as many words: an unknown operator suffix is ignored, never silently downgraded to equality.Explicit confirmation: candidate one was not implemented. The evidence is in the
pin, not in this sentence — the
/datacolumn is asserted on the same inputs in thesame run, and
filter[amount][gte]still reads['amount', '>=', '100']there andONLY there. If this change had taught the object route the operator arm, that row
would be indistinguishable from the object-route row; it is not.
What changed
The route now reads through
drillUrlFilters, the one module that owns this family,via a new equality-only arm. Both arms share ONE key grammar whose field slot excludes
brackets, so the swallowing is structurally impossible rather than patched at one call
site. Inventing a fourth posture here is what the card identified as recurrence in
place, so the posture is copied from
/datarather than designed anew.Measurements
Before and after, on the REAL bytes
Neither column is a hand copy of the shipped function: the probe lifts the regex
literal out of
ObjectView.tsxon disk and runs the card's three inputs through it.a9d97be63)filter[owners][contains]=u1[["owners][contains","=","u1"]][]filter[owners]=u1[["owners","=","u1"]][["owners","=","u1"]]filter[amount][gte]=100[["amount][gte","=","100"]][]After the change the probe reports
no filter[ regex literal in ObjectView.tsx— theprivate reader is gone, which is why the third column is read from the shared module.
The lit control fires. Plain equality is byte-identical on both sides and on both
readers; it is the only evidence that ordinary equality was not broken while the suffix
was being dropped, and it would have gone red had the repair over-reached.
IGNORED is pinned as DISTINCT from DOWNGRADED TO EQUALITY
Two different outcomes, only one correct.
toEqual([])alone would also pass for areader that refused the whole URL, so the pin names which one is meant:
expect(triples).toEqual([])— the suffixed key contributes nothing;expect(triples).not.toContainEqual(['amount', '=', '100'])— and in particular itdid not quietly answer the narrower question;
expect(fields).not.toContain('amount')— no condition on that field survives at all;expect(objectRoute('filter[amount][gte]=100&filter[status]=open'))keeps only['status', '=', 'open']— the drop is scoped to the key it cannot execute.A bounded in-place fix on the sibling arm, declared
Sharing one grammar also tightens the field slot of the
/datareader. That arm wasdifferenced against its previous implementation over a 254-case key corpus, both sides
lifted from real file bytes:
All ten deltas are the same defect class as this card — a bracket swallowed into the
field name — and every one is a narrowing that removes a wrong answer. Nothing the
surface accepts was widened. The bracket battery in the pin asserts this on both
readers.
The pin can fire — two ablation legs, prediction stated first
Predicted before either leg ran, then observed:
17 failed / 12 passed— exactly that setObjectView.tsxreverted to the base commit2 failed / 27 passed— exactly those twoEach leg's GREEN half is the other's control: leg A leaves the source scan green
(it does not touch
ObjectView.tsx), leg B leaves every behavioural case green. Thelit control and the
ANCHORpositive control stayed green in both.Mutations were proven ON DISK before the runs were believed (marker counts flipped
1 -> 0/0 -> 1, blob hash4810e6e0 -> ef88069b; leg B marker2 -> 0, hash9fe318c0 -> 6e5d06f3). Both restores were proven BY STATE, never by an exit code:blob hash back to the HEAD blob and
git diff HEADempty, under a trap using absolutepaths. Final tree:
git diff HEADempty,git status --shortempty.Verification
All readings below are from
c9e370c4d, the final commit on this branch; the workingtree was byte-identical to it for every run (
git diff HEADempty).pnpm exec vitest run packages/app-shell/ examples/schema-catalog/Test Files 720 passed (720),Tests 8861 passed / 1 skipped, lockVERDICT command-exit 0pnpm --filter @object-ui/app-shell run type-checktsc --noEmitandtsc -p tsconfig.test.json, so the new pin is type-checked tooTest Files 1 passed,Tests 29 passedpnpm changeset:check254is NOT MEASURED)node scripts/check-changeset-presence.mjscheck:control-bytes,check:test-path-roots,check:new-line-citations,check:unreferenced-sources,check:self-import,check:phantom-deps,check:shell-escape-residueeslint --no-inline-configon the three changed filesObjectView.tsxwarnings170 -> 169(the change removes oneno-explicit-anyand adds none, measured against the base file through--stdin)node scripts/check-governed-queue-guard.mjs --testexamples/schema-catalog/was run because a package-scoped run is not the blast radius(objectui#9273). Grep for count-shaped and README prose pins naming either reader found
none, and
drillUrlFiltersis not re-exported from this package's only entry, so thenew export adds no public signature.
NOT MEASURED:
check:readme-exportsexits 1 in this container withtype entry ./dist/index.d.ts is not on disk -- run pnpm build firstfor 8 unbuiltpackages. Only this package's dependency closure was built, so that is a prerequisite
miss, not a red gate; it is unrelated to this diff, which touches no README and no
exportsfield.Changeset
patchon@object-ui/app-shell, justified by measurement rather than intuition: noauthored key changes; no public signature is added or removed (internal module, not
re-exported); and the only behaviour deltas are the ten narrowings above plus the object
route dropping a key that previously produced a condition against a field no object
declares. Nothing that worked stops working.
Acceptance notes
Out-of-scope observations found while measuring this reader. Neither is fixed here.
(
map(([k, v]) => k + '=' + v).join('&')) and re-parses the result, so a valuecontaining an ampersand or a plus is mangled before it ever reaches the reader.
Measured with a lit control:
Acmesurvives,Smith & SonsbecomesSmith,A+Bbecomes
A B. A different mechanism from this card's, and fixing it would move theplain-equality control this PR depends on, so it is a separate card.
packages/layout/src/NavigationRenderer.tsxcarries a FOURTHcopy of the same greedy capture, for nav-item highlight matching. Measured, it fails
safe: the authored side of the comparison never contains a suffix, the size-and-exact
-key guard admits only false negatives, and "no highlight" is the semantically right
answer for a URL asking a different question. Successor named: whoever takes the
maintainer's candidate-one card lands in this file, because a route that really
executes range operators would need the nav side to agree on operator spelling. It
also sits in another package, so joining it to the shared module is a dependency
-direction question, not a mechanical edit.
Neither of these is addressed here; objectui#9159 remains open and is a different gap
on the same family.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
Generated by Claude Code