Skip to content

fix(app-shell): build the filter[...] memo key with URLSearchParams - #9397

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9287-filter-params-key-encoding
Sep 13, 2026
Merged

fix(app-shell): build the filter[...] memo key with URLSearchParams#9397
os-tesla merged 1 commit into
mainfrom
claude/issue-9287-filter-params-key-encoding

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9287

ObjectView built the memo key over the filter[...] entries by hand and then
re-parsed that string. searchParams.entries() yields DECODED values, so the join
put back — unescaped — the two characters that are structural in a query string.

What changed

packages/app-shell/src/views/ObjectView.tsx

  • New exported helper selectFilterParams(searchParams) appends the filter[...]
    entries onto a URLSearchParams instead of joining key=value strings. It is
    exported for the same reason buildViewTabs above it is: so the shape is assertable
    without mounting the view.
  • The memo reads that params object directly. filterParams.toString()
    percent-encodes and is used only as the memo's identity, so the
    serialize-then-re-parse round trip that lost the character is gone, not patched.
  • The key still absorbs unrelated uf_* params — the only reason it exists.

The measurement

The card's table, re-measured on origin/main @ ac05d4f by lifting the live
construction out of ObjectView.tsx (not by copying it), and again after the repair.
Acme is the lit control: it reads the same on both sides, so neither the two
failures nor the six clean rows are unmeasured cells.

value in the URL before after
Acme (LIT CONTROL) Acme Acme
Smith & Sons Smith + a stray empty-valued param Smith & Sons
A+B A B A+B
a=b a=b a=b
100% 100% 100%
a b a b a b
Latin letters with diacritics unchanged unchanged
a#b a#b a#b

Reverse verification — measured, not predicted

Direction stated before running: the two discriminating rows and both named-defect
tests RED, the lit control and the five other clean rows GREEN.

The ablation restores the hand-join inside selectFilterParams (the live code),
so the pin exercises the real construction rather than a copy of it. The mutation was
proved on disk before the run (join('&') occurrences 0 to 1, filterParams.append
occurrences 1 to 0; blob 28c951c9 to 7dc1ad28) and the restore was proved after it
(git checkout HEAD -- PATH; blob back to 28c951c9, git diff HEAD empty).
Nothing is resolved through dist/ here — both the helper and
parseUrlEqualityFilterTriples are imported by relative path, so vitest transforms the
edited source directly.

Tests  4 failed | 39 passed (43)
AssertionError: expected [ [ 'account_name', '=', 'Smith ' ] ] to deeply equal [ [ 'account_name', '=', 'Smith & Sons' ] ]
AssertionError: expected [ [ 'account_name', '=', 'A B' ] ] to deeply equal [ [ 'account_name', '=', 'A+B' ] ]

The four reds are exactly the predicted four. All 29 tests of
ObjectView.urlFilterSuffix-9196.test.ts stayed GREEN under the ablation — which is
the point: that file's plain-equality control cannot tell this defect apart, which is
why the finding was filed separately rather than folded in.

objectui#9196's control was not moved

ObjectView.urlFilterSuffix-9196.test.ts is untouched by this PR. Its three
source-scan invariants still hold on the edited file (const urlFilters present,
parseUrlEqualityFilterTriples + the drillUrlFilters.js import present, no
regex-escaped filter bracket introduced), and its plain-equality row reads
identically before and after. Plain equality was not moved, quietly or otherwise.

Route

The route named in the claim, taken as written: build the key with URLSearchParams
and read the params object directly — not "keep the hand-joined string and encode it".
The falsification condition the claim set (a URLSearchParams-built key moving a
landed pin) did not occur: the 9196 pin, drillUrlFilters.test.ts and the whole
packages/app-shell suite are green. One consequence is declared rather than hidden:
the memo reads a params object rebuilt every render while keying on its serialized
form, so the dependency array carries one react-hooks/exhaustive-deps disable with
the reason next to it. Listing the object instead would invalidate the memo on every
unrelated uf_* write — the churn the key exists to prevent. Equal keys imply equal
contents, so the captured object is never stale.

Gates run locally

gate command result
pin pnpm exec vitest run packages/app-shell/src/views/ObjectView.filterParamsKeyEncoding-9287.test.ts exit 0 — 14 passed
family pins pnpm exec vitest run ...ObjectView.urlFilterSuffix-9196.test.ts ...drillUrlFilters.test.ts exit 0 — 45 passed
affected package pnpm exec vitest run packages/app-shell/ exit 0 — 694 files, 6777 passed, 1 skipped
type-check pnpm --filter @object-ui/app-shell run type-check exit 0 (after building the dependency closure)
lint pnpm exec eslint on both changed files exit 0, 0 errors
changeset node scripts/check-changeset-presence.mjs exit 0 — 1 changeset declared

Lint warning delta on ObjectView.tsx, measured base vs head with
--format json: 169 to 170 warnings, 0 errors on both sides. The one added warning is
react-refresh/only-export-components on the new exported helper — the same warning
each of the file's 15 existing exported helpers already carries.

The repo-wide gates (Lint, Type Check, the four Test shards, Build & E2E,
Build Docs, Changeset Declaration) are left to CI; this is a declared narrowing,
not a skipped run.

Acceptance notes

  • Noted, not filed: the two live producers of this route's URLs
    (NavigationRenderer's nav filters links and RelatedList's parent scope) both
    build with URLSearchParams and toString(), so the emit side was already correct —
    the defect was read-side only. No separate card: nothing to repair there.
  • Noted, not filed: filter[FIELD] keys are selected by the plain-string prefix
    key.startsWith('filter['), which admits a malformed key such as filter[x into the
    memo key. It changes nothing — drillUrlFilters' one key grammar rejects it at the
    read — so it is an observation, not a defect. Successor: none.

🤖 Generated with Claude Code

https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa


Generated by Claude Code

`ObjectView` built its memo key by joining `key=value` pairs from
`searchParams.entries()` and then re-parsed that string. `entries()` yields
DECODED values, so the join put back, unescaped, the two characters that are
structural in a query string: `&` truncated the value at its first occurrence
(`Smith & Sons` reached the reader as `Smith `, leaving a stray empty-valued
param) and `+` arrived as a space (`A+B` as `A B`). The destination list
rendered, scoped by a silently wrong value.

The selection now appends onto a `URLSearchParams` (`selectFilterParams`, an
exported helper so the shape is assertable without mounting the view) which the
memo reads DIRECTLY; `toString()` percent-encodes and only keys the memo, so
the serialize-then-re-parse round trip is gone rather than patched. The key
still absorbs unrelated `uf_*` params, which is the only reason it exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.4 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 502.02KB 115.16KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 211.58KB 58.68KB
fields (index.js) 247.89KB 62.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.25KB 13.99KB
plugin-charts (index.js) 71.34KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.59KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.46KB 65.85KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.77KB 34.17KB
plugin-gantt (index.js) 166.95KB 41.04KB
plugin-grid (index.js) 211.66KB 57.50KB
plugin-kanban (index.js) 46.00KB 14.30KB
plugin-list (index.js) 112.58KB 27.65KB
plugin-map (index.js) 20.64KB 6.86KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.79KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 96.00KB 31.71KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Bundle Analysis red here is the same standing ui-components debt — ⛔ not this PR's

domain:ui PM seat, session_011QreXiyMEqKLN4U5daMPVa (os-tesla). Recording the gate and the cause, as queueing with a red non-required check requires.

Identical verdict to the one recorded on PR objectui#9396 this round: every budget passes, and the single ❌ is the checker's gauge verdict (exit 2) that the ui-components row's headroom has fallen under its 0.10x floor. The gate's own output states it is "a standing debt that predates this change", that the verdict is "NOT an accusation that your diff spent the bytes", and that "there is therefore nothing here for this pull request to 'fix'" — with both greening edits (raise the ceiling, raise the allowance) ⛔ forbidden.

Taken where the gate says to take it: the open decision on the chunk is objectui#9251 with objectui#9204 as the implementation row; both already exist and are held by the seat working that chunk, so ⛔ no duplicate card was filed. This PR's diff is packages/app-shell/src/views/ObjectView.tsx + a pin + a changeset — it touches no bundled component surface, and ⛔ neither ceiling nor allowance is edited here.

performance-budget.yml declares only push and pull_request — ⛔ no merge_group — and the workflow's own comment records that it "cannot hold the merge queue". It is outside the 9-check required set.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 13, 2026 14:09
@os-tesla
os-tesla added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 6be9733 Sep 13, 2026
37 of 38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9287-filter-params-key-encoding branch September 13, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(app-shell): ObjectView's filter[...] memo key re-serializes params by hand, so a value containing & or + reaches the reader mangled

2 participants