Skip to content

fix(plugin-list): separate what the sort picker lists from what it persists - #6583

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6455-listview-sort-persist-leak
Aug 26, 2026
Merged

fix(plugin-list): separate what the sort picker lists from what it persists#6583
os-support-ai merged 2 commits into
mainfrom
claude/issue-6455-listview-sort-persist-leak

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6455

ListView's toolbar sort picker keeps a platform-refused field listed while the current sort names it. That exception is deliberate and stays: it is the only way a user can REMOVE a sort the server answers 400 INVALID_SORT for. But the picker rendered and emitted from the same array, so any other edit in that popover — a second sort key, a reset to the view's default — re-emitted the refused entry, and the host's onSortChange turned it into persistViewPatch({ sort }): a personalization PUT storing a refused column, written by a user who never touched that row.

The seam, and why this one

Intercepted in packages/plugin-list/src/ListView.tsx, not in packages/app-shell/src/views/ObjectView.tsx. Three reasons, in order:

  1. It is the narrower of the two. The defect is the conflation of two legs inside one component; ObjectView is a correct consumer of a leaky emit.
  2. The verdict already lives here. The picker reads readObjectSortability(objectDef) for its render leg. ObjectView would have to re-derive the same projection to filter the write — a second copy of the judgement that isPlatformSortableField was consolidated to make singular.
  3. ObjectView has two persistViewPatch({ sort }) call sites, and the grid seam is already filtered. Filtering there would double-filter ObjectGrid's already-clean emit and still leave any future non-ObjectView host of ListView leaking.

The shape converges on the grid precedent (#5729): ObjectGrid's manualSort / manualOnSortChange pair separates render from emit. This picker was the second door onto the same stored view state.

What changed

  • One hoisted read of the served projection (platformSortability), shared by the render leg and the emit leg — read twice, the two copies could answer differently about the same field on the same render.
  • One emitSortChange boundary that every onSortChange in the component crosses: the builder's onChange, handleHeaderSort, and handleResetSort. A filter spelled three times is a filter that one new door can be added without.
  • currentSort still takes the array whole, so the refused entry stays listed, removable, and still the order this list asks the server for.
  • Only under a served projection. undefined is NO SIGNAL SERVED (an older deployment, an inline/mock data source), not "nothing is sortable" — that branch is unchanged.

Net: packages/plugin-list/src/ListView.tsx +58 −8, new test +258, changeset +32.

The leak, reproduced before it was fixed

packages/plugin-list/src/__tests__/ListView.sortPersistLeak.test.tsx models the host exactly as ObjectView writes it (onSortChange → a persistViewPatch({ sort }) spy), so what it reads is the payload that reaches stored view state.

Reverse-verification, direction predicted first (predicted: the two fix-direction tests go red, all four controls stay green). The fix was committed first, then the pre-fix expression was restored from the merge-base 12402a9b8, the mutation was confirmed on disk (emitSortChange count 0, pre-fix spelling count 1, blob hash differing from HEAD's), and the file was restored with git checkout HEAD -- and proven byte-identical (hash-object = HEAD blob f52456576e7b4be7589b388a380510683c99eb49, git diff HEAD empty in worktree and index).

PRE-FIX EXIT=1
× drops the platform-refused entry from the write an UNRELATED edit causes
expected [ …(2) ] to deeply equal [ { field: 'name', order: 'asc' } ]
× drops the refused entry from a "reset to default" write too
expected [ { field: 'remote_status', …(1) } ] to deeply equal []
Test Files 1 failed (1)
Tests 2 failed | 4 passed (6)

The two received values are the leak: [{remote_status,asc},{name,asc}] written by a user who only added a second sort key, and [{remote_status,asc}] written by a reset. The four controls that stayed green through the mutation are the ones the naive fix (filtering the array the picker renders from) would fail:

  • the refused entry is still listed while in use — ['Name','Amount','Remote Status'];
  • removing it persists the removal (sort: []);
  • a sort with nothing refused in it persists exactly as before ([amount, name], in order);
  • a deployment serving no projection is untouched (still [remote_status, name]).

RelatedList — measured, out of scope by construction

The card asked for this to be confirmed rather than assumed. packages/plugin-detail/src/RelatedList.tsx: 84 hits for sort (positive control — the sort surface is very much present), 1onSortChange, and it is an inbound prop handed to the embedded table (onSortChange: handleTableSort), whose handler only calls setSortField / setSortDirection / setCurrentPage. 0persistViewPatch, 0persistView, 0localStorage. RelatedListProps declares no sort callback at all (onRowClick, onRowAction, onToolbarAction — no sort), so no host can persist it: the sort is component state that dies with the component. Nothing to leak, and nothing to file.

Gates (exit code captured before any pipe; verdict lines are each gate's own)

Union re-run at final HEAD 2914bd516, clean tree, from the repo root per AGENTS.md §怎么跑测试:

gatecommandverdictexit
affected package testspnpm exec vitest run packages/plugin-list/Test Files 49 passed (49) / Tests 696 passed (696)0
type-checkturbo run type-check --filter=@object-ui/plugin-listTasks: 14 successful, 14 total0
lintturbo run lint --filter=@object-ui/plugin-list✖ 422 problems (0 errors, 422 warnings)0
changeset presencenode scripts/check-changeset-presence.mjs✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)0
changeset no-majornode scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.0
changeset overwritenode scripts/check-changeset-overwrite.mjs✅ No pre-existing changeset was modified or deleted.0
control bytesnode scripts/check-control-bytes.mjs✅ OK (scanned 5437 tracked text file(s))0
lint coveragenode scripts/check-lint-coverage.mjs✅ 46/46 packages linted, 0 with outstanding errors0
type-check coveragenode scripts/check-type-check-coverage.mjs✅ 41/41 packages compile their tests0
vi.mock specifiersnode scripts/check-vi-mock-specifiers.mjs✅ OK0

Two notes on what those readings do and do not cover:

  • The type-check really does cover the new test. The package's type-check is tsc --noEmit && tsc -p tsconfig.test.json; --listFiles on the second project names both ListView.tsx and ListView.sortPersistLeak.test.tsx, so "type-check clean" is a statement about the new file and not a true sentence about a set it excludes.
  • Lint was run package-scoped, not repo-wide — a declared narrowing. The universe comes from the package's own eslint config (eslint . inside packages/plugin-list, the exact task CI runs for it), --format json counts 60 files with 0 errors, and eslint.config.js enables no type-aware linting (no projectService, no parserOptions.project), so this diff cannot move the verdict of any file it did not touch. The new test's 9 warnings are all @typescript-eslint/no-explicit-any, the same class (and fewer) than its The other two sort-axis consumers still re-derive sortability from field type — ListView's sort picker and RelatedList #6108 sibling — no new warning family. The repo-wide farm is CI's run.

Out of scope, untouched

filterPlatformSortableSort's definition in packages/core and ObjectGrid's use of it (the precedent, not the subject); packages/plugin-view/src/ObjectView.tsx (a different file with the same component name, in flight elsewhere); anything server-side under objectstack-ai/objectstack#10235, which objectui consumes as a published package.

Generated by Claude Code


Generated by Claude Code

…rsists
The toolbar sort picker keeps a platform-refused field listed while the
current sort names it — the only way a user can remove a sort the server
refuses outright (#6108). But it rendered and emitted from the same array,
so any other edit in the popover re-emitted the refused entry and the host
turned it into `persistViewPatch({ sort })`: a personalization PUT storing
a column the platform answers `400 INVALID_SORT` for, written by a user who
never touched that row.
Route every `onSortChange` through one emit boundary that drops what the
served projection refuses, and leave `currentSort` whole so the entry stays
listed and removable. Same separation #5729 made at the grid seam.
A published behaviour fix in @object-ui/plugin-list, scored patch —
`scripts/check-changeset-presence.mjs` demanded it and now passes.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3235.1 KB3266.6 KB
Main entry chunk (gzip)157.0 KB350 KB
Entry fileindex-fIPfPsaQ.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.71KB4.46KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.85KB12.89KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.80KB43.15KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.58KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)112.86KB27.54KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT on the diff. ⏸ Landing held until every check is green — 17 success / 3 skipped / 9 still running, zero failures at 2914bd516732. Enqueue qualification is every check green on the reviewed head.

The seam, verified from the branch rather than the report

The narrower seam was chosen and app-shell was not touched. Three files: ListView.tsx (+58/−8), a new test (+258), a changeset (+32). packages/app-shell/src/views/ObjectView.tsx is absent from the file list, which is what the dispatch asked for when both would have worked.

One boundary, and the control proves it is the only one.emitSortChange is defined at :2531, and onSortChange is invoked at exactly one place in 3789 lines — :2533, inside it. Every emit path crosses it: :2563 (the builder's onChange), :2585 (reset), :3087 (the inline path), and :3604 hands handleHeaderSort down, which routes through it too. The remaining onSortChange occurrences are the prop declaration, the destructure, the dependency array and prose. No call site escapes the boundary — that is what makes "every write is filtered" a property rather than a hope.

The hard constraint survived, and is documented where it matters.setCurrentSort still takes the array whole at :2561, :2583 and :3086, with the reason written at the site:

:3083 // `setCurrentSort` takes the array WHOLE (the in-use
:3084 // exception depends on it); `emitSortChange` is what the
:3086 setCurrentSort(newSort);
:3087 emitSortChange(newSort);

Those two adjacent lines are the whole card: the same array, one to state unfiltered so the refused entry stays listed and removable, one through the filter so it never reaches a write. And the change is scoped to a served projection — undefined still means "no signal served" and that branch is untouched, so deployments where the platform serves nothing behave byte-identically.

Two corrections to the dispatch order, both from the dev

A third door existed and my order named only two.handleResetSort re-emits the view's declared sort array whole, so a declared refused sort was re-persisted on every reset to default — the same leak by a route the order did not list. It is covered by the same boundary and pinned by its own test. This is the "the card's list is not the population" failure with the list being mine this time, not the card's.

And a fix that was declined rather than claimed.handleHeaderSort's door was already clean in practice, because ObjectGrid filters its emit before the callback fires — so routing it through the boundary is defence in depth, not a second leak closed. Saying that, instead of counting it, is what makes the first correction credible.

RelatedList — measured, and correctly NOT filed

The order said confirm, do not assume, do not sweep. It confirmed the card's suspicion with evidence rather than adopting it: RelatedList.tsx has 84 hits for sort (the positive control — the surface is fully present, so the zeros below are real absences), its single onSortChange is an inbound prop handed to the embedded table, handleTableSort only calls setSortField / setSortDirection / setCurrentPage, and there are 0persistViewPatch, 0persistView, 0localStorage. RelatedListProps declares no sort callback at all, so no host can persist it. Component state that dies with the component: nothing to leak, and no sibling card filed.

Reverse verification

Direction predicted first, then observed exactly: two fix-direction assertions red — the leak itself ([{remote_status,asc},{name,asc}] persisted by a user who only added a second key) and the reset-door leak — with all four controls green through the mutation. Those four are precisely the ones a naive render-side filter would fail:

  1. the refused entry is still listed while in use;
  2. removing it persists the removal (sort: []);
  3. a sort with nothing refused persists byte-identically, order preserved;
  4. a deployment serving no projection is untouched.

The fourth was not asked for and is the one that keeps this from being a behaviour change for anyone the signal does not reach.

Mutation and restore both proven on disk by occurrence count and blob hash (a4ff10e1 → back to f5245657, git diff HEAD empty in worktree and index), restoring with git checkout HEAD -- <absolute path> rather than a bare git checkout --, which would have re-taken the mutation out of a poisoned index. Tests were run from the repo root throughout, avoiding objectui#3378's guard that silently runs another package's files and reports green.


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 26, 2026 15:05
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to no response for status checks Aug 26, 2026
@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Dequeued CI_TIMEOUT at ~16:08Z. Not this PR's, and deliberately not re-queued yet — stating the blocker once, per the drive-to-green rule.

This PR is untouched by it

On the reviewed head 2914bd516732: 26 success, 3 skipped, 0 failure, 0 cancelled, and mergeable_state: clean. Nothing about the diff or its checks changed. The queue gave up waiting for the merge-group validation to run — it never ran.

Why it timed out

The repository-wide Actions runner outage recorded in #6588. Measured just now across the 30 most recent workflow runs of any event type:

  • in_progress: 0 — nothing is executing anywhere in the repo;
  • 16 still queued;
  • the most recent successful run finished at 15:33Z, roughly 35 minutes ago.

This PR was enqueued first of three (15:05:11Z), so it aged out first. #6585 (15:07:41Z) and #6586 (15:11:46Z) are still enqueued and will presumably follow.

CI_TIMEOUT is a third distinct dequeue signature seen in this repo today, after #6571's CI_FAILURE — which itself turned out to be a cancelled run whose gate had already passed. Neither was a test result.

Why not re-queue immediately

Re-queueing is normally the right move for a transient dequeue, and this seat did exactly that for #6571 earlier today. It is the wrong move right now: with zero runner throughput, a re-queued PR would sit and time out again, spending the one-re-queue allowance on a certainty and adding load to the exact resource that is starved.

⛔ Not re-queued. ⛔ No push, no re-run, no change to the branch — there is nothing here to fix. ⛔ And explicitly not a case for weakening anything: a queue timeout caused by unavailable runners is not a gate to relax.

What this needs: runner throughput to return. The signal this seat is watching for is any run reaching in_progress. A check-in is scheduled; the moment execution resumes, this PR goes back in the queue unchanged and its 26 green checks stand.

If the outage turns out to be org-side runner concurrency rather than a GitHub incident, that is the lever — and whoever can reach githubstatus.com should look, because this environment cannot (proxy refuses the tunnel, CONNECT tunnel failed, response 403).


Generated by Claude Code

@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit bd2f56aAug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6455-listview-sort-persist-leak branch August 26, 2026 16:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ListView's sort picker persists a platform-refused sort — the in-use exception re-emits it, and filterPlatformSortableSort has only one consumer

2 participants

@os-support-ai@claude