Skip to content

refactor(plugin-list,plugin-detail): converge the last sort-axis consumers on the served sortability signal - #6454

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-6108-sort-axis-convergence
Aug 26, 2026
Merged

refactor(plugin-list,plugin-detail): converge the last sort-axis consumers on the served sortability signal#6454
os-support-ai merged 2 commits into
mainfrom
claude/issue-6108-sort-axis-convergence

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes#6108

The last three sort-axis consumers now read the platform's per-column sortability
signal instead of re-deriving it from the field's type. Inherits objectstack#10235
maintainer ruling A through #5729's landed contract spelling — isPlatformSortableField
in @object-ui/core, which ObjectGrid's header already reads and which this PR does
not touch.

The census, before anything was written

The card named two files and two line numbers. Both were re-derived against origin/main
fdefd4b01 rather than trusted, and one of them had drifted:

Card saysMeasured
plugin-list/src/ListView.tsx:2409the picker reads at :2464line drift only
plugin-detail/src/RelatedList.tsx:1146,1391both confirmeda different spelling

RelatedList does not mention UNMATERIALIZED_FIELD_TYPES anywhere — a grep for that
symbol surfaces only ListView and core. It re-derives through
isUnmaterializedFieldType(fieldDef), which is the same judgement wearing another name.
Censusing the shape ("decides sortability from the field's type") rather than the
symbol is what found it; the card's claim was correct and this card covers three surfaces,
not one.

What changed

SurfaceWasNow
ListView.tsx — toolbar sort picker!UNMATERIALIZED_FIELD_TYPES.has(field.type)isPlatformSortableField(projection, field.value)
RelatedList.tsx — embedded table headersisUnmaterializedFieldType(fieldDef)one shared withheldFromServerSort
RelatedList.tsxdata-list sort-button rowisUnmaterializedFieldType(fieldDef)the same predicate

The two related-list entry points never shared a derivation, which is how the same refused
sort stayed reachable through whichever control the other one did not cover; they now go
through one React.useCallback.

Three things deliberately preserved

  • The relational carve-out stays separate from the signal. The projection answers
    sortable: true for a lookup — the platform's question is whether it can order by the
    stored foreign key, which it can. Ours is whether that order means anything beside a
    column of names, and it does not (objectstack#4256). Folding this into the signal would
    hand every relational control its sort back. This is the same split ObjectGrid makes.
  • The picker's keep-current-sort exception. A field the CURRENT sort already names
    stays listed — the only way to remove a sort the server refuses outright, and the reason
    a saved-before-the-signal view neither renders a blank row nor re-emits the refused entry
    into the next persistViewPatch({ sort }). It now covers platform-refused fields, not
    just formulas.
  • The no-signal floor.readObjectSortability returning undefined means "no signal
    was served" — an older backend, an inline/mock data source, a schema fetch not yet landed
    — not "nothing is sortable". That branch keeps the type read, so behaviour on a
    pre-objectstack#10235 deployment is unchanged.

The pins, and why they are not vacuous

isPlatformSortableField and the deleted re-derivation agree on most inputs — the
platform computes its projection from the same @objectstack/spec storage fact, which is
exactly why the drift went unnoticed across two cards. A pin over formula would pass
against the re-derivation too and prove nothing. Every load-bearing cell is an input where
the two disagree:

FamilyContractDeleted type read
Absence — an unprovisioned audit column (audited_at)withholdoffer (not formula)
Absence — a dotted path a caller can declare (account.name)withholdoffer (no field def resolves)
A refusal with no reason: virtual-type (remote_status)withholdoffer (text)
The platform moving — a formula it DOES order by (rolled_total)offerwithhold, forever

The last row is the direction no type read can ever follow. Controls sit in the same render
throughout — a stored column that stays sortable (which also proves the surface rendered at
all), a formula both readings withhold, and the relational carve-out.

Everything that is not a drift cell is generated by the platform's own
resolveObjectSortability (@objectstack/spec/api), the resolver the REST layer serves the
projection from — agreement over a copied table.

Reverse verification

Each surface's pins were run with the re-derivation put back. Predicted direction, stated
before each run: RED on the drift cells, GREEN on the agreement/floor cells.
Mutations were
proven on disk by grepping the injected and the deleted text (never a diffstat, never an
editor's exit code); restores were proven by comparing the worktree blob hash to the HEAD
blob hash plus an empty git diff HEAD, with an EXIT/INT/TERM trap holding absolute
paths.

LegResultThe cells that stayed green
ListView predicate reverted7 failed / 2 passedthe no-projection floor, the relational carve-out
RelatedList predicate reverted8 failed / 5 passedboth carve-outs, both floors, client mode

No dist is in the path: the tests import ../ListView / ../RelatedList directly, and the
root vitest config aliases @object-ui/core to packages/core/src, so the ablation reads the
mutated source. The dependency closure (pnpm --filter '<pkg>^...' build) was built first
regardless.

Verification

All runs below are at 27f9d10f6 (the final commit) unless noted.

  • pnpm exec vitest run packages/plugin-list packages/plugin-detail155 files, 1710
    tests, all passing
    (Test Files 48 passed (48) / Tests 690 passed (690) and
    Test Files 107 passed (107) / Tests 1020 passed (1020)).
  • pnpm --filter @object-ui/plugin-list --filter @object-ui/plugin-detail run type-check
    Done for both; each runs tsc --noEmit && tsc -p tsconfig.test.json, so the new test
    files are inside the checked set rather than excluded from it.
  • Repo gates implicated by this diff, each printing its own verdict line: check:control-bytes
    (✅ OK (scanned 5327 tracked text file(s))), check:vi-mock-specifiers (✅ OK),
    check:spec-symbols (✅ spec symbol derivation), check:phantom-deps
    (✅ Every in-scope import is declared by the package that publishes it), check:i18n-keys
    ().
  • Lint, narrowed and the narrowing measured.eslint . was run complete for both affected
    packages — 219 files (59 + 160, counted from --format json, so the population comes
    from eslint's own config resolution and not from a guess about which files count),
    0 errors, 1268 pre-existing warnings. The other 45 workspace projects were excluded, and
    that exclusion is provable rather than assumed: eslint.config.js extends
    tseslint.configs.recommended with languageOptions: { ecmaVersion, globals } and declares
    no parserOptions.project / projectService, so type-aware linting is not enabled and
    this diff cannot move the verdict on any file it does not touch. CI runs the full farm
    regardless.

Out of scope

packages/plugin-grid/** is untouched — ObjectGrid.tsx is this card's precedent consumer
(read, never edited) and #6004 is in flight holding it. The convergence needed nothing there.

Generated by Claude Code


Generated by Claude Code

…umers on the served signal
ListView's toolbar sort picker and both of RelatedList's sort entry points
decided "can the platform order by this?" by re-deriving it from the field's
type — `UNMATERIALIZED_FIELD_TYPES` in the picker, `isUnmaterializedFieldType`
in the related list. objectstack#10235 ruling A serves that verdict per column
on the metadata envelope, and objectui#5729 landed its consumer spelling in
`@object-ui/core`; the grid header already reads it. These three now do too.
The re-derivation agreed with the projection about `formula`, which is why the
drift went unnoticed. It disagrees on everything the projection encodes as
absence (an unknown name, a dotted path, an unprovisioned audit column), on any
refusal carrying no `reason: virtual-type`, and in the other direction on a
field the platform has since started ordering by.
Kept deliberately separate: the relational carve-out (the projection says a
`lookup` IS sortable; the UI withholds for a different reason), and the
picker's in-use exception, which is the only way to remove a sort the server
refuses. A deployment that served no projection keeps the type read as a
compatibility floor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
…drift family
The two combined cells named nothing when they failed: "expected true to be
false" over six assertions. Each family the served projection and the deleted
type read disagree on — an absent name, a dotted path, a refusal carrying no
`virtual-type` reason, and a formula the platform DOES order by — now has its
own test on each of the two surfaces, so a regression says which one moved.
Measured against the re-derivation put back: 8 of 13 red, and the 5 that stay
green are exactly the agreement and floor cells (the relational carve-out on
both surfaces, the no-projection fallback on both, and client mode, which
never consults the signal).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.8 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-apHV6YL6.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.30KB4.28KB
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)505.99KB114.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.66KB12.84KB
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)211.90KB42.74KB
plugin-detail (index.js)245.26KB62.38KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.79KB54.60KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
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.55KB20.74KB
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)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
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-ai
os-support-ai marked this pull request as ready for review August 26, 2026 01:36
@os-support-ai
os-support-ai added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit b4393e5Aug 26, 2026
30 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6108-sort-axis-convergence branch August 26, 2026 01:48
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.

The other two sort-axis consumers still re-derive sortability from field type — ListView's sort picker and RelatedList

2 participants

@os-support-ai@claude