Skip to content

feat(app-shell,plugin-detail): derived related lists inherit the child object's default list view sort - #6088

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-5795-related-list-inherit-sort
Aug 24, 2026
Merged

feat(app-shell,plugin-detail): derived related lists inherit the child object's default list view sort#6088
yinlianghui merged 2 commits into
mainfrom
claude/issue-5795-related-list-inherit-sort

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5795

An auto-derived related list now orders its rows by the child object's default list view
sort
, instead of falling to the server's primary-key order. The reported case — a task
version's "check items" tab rendering 20/30/10/40 while the child object's own list page
obeyed sort: [{ field: 'seq_no' }] — renders 10/20/30/40.

The ruling this implements

objectstack#11345, maintainer 2026-08-23 15:02Z, direction 1: derived related lists
inherit the child object's default list view sort. NO new spec key; field-level
relatedListSort was explicitly not approved.

So nothing new is authorable. record:related_list.sort was already declared, parsed and
consumed, and RelatedList already lowers it to $orderby — the inheritance only fills it.
objectstack#11345 is pm:blocked on this card and closes when it lands.

What changed, and what deliberately did not

FileChange
app-shell/src/utils/deriveRelatedLists.tsemits sort, normalized from the child def's merged default list view
app-shell/src/views/RecordDetailView.tsxcarries it into the synthesizer's related shape (re-drop site 1)
plugin-detail/src/synth/buildDefaultPageSchema.tscarries it onto the record:related_list node (re-drop site 2)

RelatedList.tsx and renderers/record-related-list.tsx are unchanged — the relay said
the renderer needs none, and it does not: defaultSort={schema.sort} and the $orderby
assembly were already there and already correct.

The dialect trap, and the route taken

ListView.sort and record:related_list.sort declare the same union
(string | Array<{field, order}>) and mean different things by the string arm:

  • a ListView string is the legacy space-separated 'seq_no desc' (@objectstack/spec
    ui/view.zod.ts, annotated Legacy "field desc");
  • the related list's own normalizeSortSpec reads 'field' / '-field'.

Inheriting the string verbatim would therefore not produce "the same sort in another
notation" — it would put $orderby on a field whose name is the seven characters
seq_no desc. The route taken, and pinned in three places: normalize to the array arm
once, at the derivation
, through @object-ui/core's convertSortToQueryParams — the
repo's single definition of both authored dialects — so no second parser of the legacy
string exists to drift from it. deriveRelatedLists is the one place that knows it is
reading a ListView and writing a related list, which is why the translation belongs there
rather than as a tolerant reader downstream (AGENTS.md #0.1).

Key-order safety of the normalizer's map round-trip is not assumed: every ObjectStack field
name matches ^[a-z_][a-z0-9_]*$ (spec field.zod.ts), so none is an integer-like key JS
would hoist.

Precedence — no contest to resolve

The card asked me to stop and report if precedence turned out undefined. It is defined by
construction, in two independent ways, and both are recorded in the synth test:

  1. a hand-authored page carries its own record:related_list node with its own sort
    and never enters this synthesizer — RecordDetailView synthesizes only when no page is
    assigned;
  2. within the synthesized path, the one producer of related[].sort is the
    inheritance, and the synthesizer is a pure carrier that neither derives nor overrides.

An authored sort therefore behaves exactly as it did before. The ruling adds inheritance
and does not change precedence, which is what the code now says.

PM mechanism assumptions — measured, not inherited

1. All five line references re-derived on the merge-base (94e2fa725). Four moved, and
one was attributed to the wrong repo:

RelayActual on merge-baseDelta
deriveRelatedLists.ts:133-146 (emit):133-146unchanged
deriveRelatedLists.ts:118 (child in hand):118unchanged
RecordDetailView.tsx:1955-1978:1966-1990+11
buildDefaultPageSchema.ts:174-188 (related type):174-188unchanged
buildDefaultPageSchema.ts:683-690 (relatedNode):682-690-1
RelatedList.tsx:519-529 ($orderby):517-529-2
RelatedList.tsx:258-266 (normalizeSortSpec):255-267-3
record-related-list.tsx:211 (defaultSort):213+2
MetadataProvider.tsx:413-414"objectstack's half"objectui, packages/app-shell/src/providers/MetadataProvider.tsx; isDefault resolver at :378, the obj.list merge at :433-434wrong repo + wrong lines

The last row matters beyond bookkeeping: the merge that makes child.list exist is in
this repo, so it is inside this PR's blast radius rather than a fixed external given.

2. "Both re-drop sites" was a count to verify — and there is a THIRD site.
buildDefaultPageSchema.ts:940 re-drops the same related[] array into
record:reference_rail's entries. It is deliberately not given the key, and the
reason is a hard one rather than a judgement call: ReferenceRailEntrySchema is $strict
and declares only objectName / relationshipField / title / limit / displayField. Emitting
sort there would write a key the spec refuses at save with no render path reading it —
exactly the class objectui#5494 removed when it stopped emitting rel.icon onto rail
entries. The rail is a top-3 summary, not the list. Pinned as its own leg so a later
"symmetry" edit goes red.

3. child.list verified present at derivation time, and its absence shown harmless.
objects reaches RecordDetailView from MetadataContext's getter, which returns
mergeViewsIntoObjects(objs, views) — so the merged shape is what the derivation reads.
When view metadata arrives after the objects, the getter returns a new array, which is
a dependency of the memo over deriveRelatedLists, so the derivation recomputes and the
sort appears. Documented on the ObjectLike['list'] field, because "silently empty" and
"today's behaviour" are otherwise indistinguishable.

Verification

Direction predicted before running, then three ablation legs. Every leg asserted the
mutation on disk by exact-substring count before and after (never a bare git diff --stat,
never an editor's exit code), printed its landing site, asserted anchor uniqueness before
writing, and restored under trap … EXIT INT TERM with a cwd-independent git -C command.

vitest.config.mts aliases @object-ui/app-shell and @object-ui/plugin-detail to their
src/, so every subject resolves from source — no dist stands between an edit and a
run, and no rebuild leg is skippable-but-needed.

AblationderiveRelatedLists.inheritSort…InheritedSort (wire)…relatedSort (synth)…OutsideWindowed
remove the emit7 failed / 2 passed3 failed / 2 passed6 passed4 passed
remove re-drop site 19 passed3 failed / 2 passed6 passed
remove re-drop site 29 passed3 failed / 2 passed3 failed / 3 passed

Every cell matched the prediction, including which legs stayed green: the counter-probes
assert absence, so they must survive every ablation — a counter-probe that went red would
mean it was really a second subject. The asymmetry is the argument for asserting the
$orderby rather than the descriptor: only the wire test goes red on all three legs,
because the descriptor is three hops from the wire and two of those hops are re-drops.

Counter-probes (green on the fix, and green under every ablation, by design):

  • a child with no default list-view sort produces a working, scoped, windowed related
    list carrying no$orderby — so inheritance is not satisfiable by inventing an order.
    The key is absent, not present-and-empty;
  • an unusable sort (a number, a bare string array, an entry without field) is dropped
    rather than guessed at.

Gates, by name, with exit codes

Exit codes captured before any pipe; each result quotes the gate's own verdict line.

GateExitEvidence
type-check (@object-ui/app-shell)0packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.jsonDone
type-check (@object-ui/plugin-detail)0packages/plugin-detail type-check$ tsc --noEmit && tsc -p tsconfig.test.jsonDone
dependency-closure build0pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter '@object-ui/plugin-detail^...' build
vitest (path-filtered)0Test Files 12 passed (12) / Tests 172 passed (172)
eslint (merge-base delta, 7 files)00 error lines (pre-existing no-explicit-any warnings only)
check:esm-specifiers0run per the standing rule, though the one new import is a package specifier
check:phantom-deps0@object-ui/core is already an app-shell dependency
check:self-import0
check:control-bytes0plus a direct grep -naP control-byte sweep over the delta
check:spec-symbols0
check-changeset-presence.mjs03 source file(s) of 2 released package(s) changed … declares 1 changeset(s)
check-changeset-no-major.mjs0minor, per the fixed-group rule

The vitest union and eslint were both run after the final commit, at
5d599be9c — a clean tree at that sha.

Both type-check runs report Scope: 2 of 47 workspace projects and echo their tsc
command, so neither is the silent zero-match green a mistyped filter produces.

Declared narrowing:check:eager-closure was not run — it needs
apps/console/dist/eager-closure.json from a vite build of the console and refuses as a
broken gauge without it, which is CI's run to own. The reason it is safe to leave to CI
rather than merely unrun: the one new import edge is deriveRelatedLists.ts → @object-ui/core, and app-shell/src/utils/index.ts — the barrel that re-exports
deriveRelatedListsalready imports @object-ui/core, so the edge adds nothing to
that closure. CI runs the whole farm regardless.

Attached, not folded

  • The inherited sort inherits an existing hole.$orderby is assembled only inside
    RelatedList's windowed branch, and windowed goes false while the built-in client
    text filter is active — the client path then returns rows unsorted unless a column was
    clicked. A declared record:related_list.sort is dropped there today; the inherited
    sort is dropped identically, and now reaches users who authored nothing. Fixing it means
    changing RelatedList's fetch/sort split, which is outside this card's file surface — so
    it is recorded instead, in RelatedList.sortDroppedOutsideWindowed.test.tsx: typing
    in the filter drops $orderby from the query, the rows revert to server order, and
    clearing the filter restores both. Those assertions pin today's behaviour and should
    go red when someone closes the hole; the fix is to rewrite them, not delete them.
  • Auto-derived related lists consume the new field-level relatedListFilter declaration — list query AND-composed, tab badge honors the same filter #4664 (this same emit point ignores relatedListFilter) was re-confirmed by this
    measurement and is deliberately not folded in — separate card.
  • objectui#4286 (open) was re-confirmed incidentally: a synthesized Reference Rail page
    is unpersistable because the aside region carries a className that PageRegionSchema
    refuses. Measured on that branch with nosort anywhere, so it is not this change; it
    is why the rail leg asserts entry shape rather than parsing the whole page.

Not done here

Draft, per dispatch: not marked ready, not enqueued, no auto-merge — the PM
lands it. objectstack untouched.


Generated by Claude Code

claudeBot added 2 commits August 24, 2026 16:09
…d object's default list view sort
Ruled on objectstack#11345 (maintainer, 2026-08-23), direction 1: a derived
related list inherits the child object's default list view `sort`, with NO new
spec key (field-level `relatedListSort` was explicitly not approved).
`deriveRelatedLists` now emits `sort` from the child def's merged default list
view, and the two re-drop sites between it and the wire carry it forward:
`RecordDetailView`'s map into the synthesizer's `related` shape, and
`buildDefaultTabs`' `relatedNode`. `record:related_list.sort` was already
declared-parsed-consumed and `RelatedList` already lowers it to `$orderby`, so
neither the renderer nor the component changes.
`ListView.sort` and `record:related_list.sort` declare the same union and mean
different things by its string arm (legacy space-separated `'seq_no desc'` vs
`'field'`/`'-field'`), so the inherited value is normalized to the array arm at
the derivation, through the repo's single dialect definition
(`@object-ui/core`'s `convertSortToQueryParams`). Un-normalized it would have
ordered by a field literally named `seq_no desc`.
The reference rail is deliberately NOT given the key: `ReferenceRailEntrySchema`
is strict and declares no `sort`.
Part of #5795
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.0 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-BnT5A0di.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)10.38KB3.90KB
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.15KB114.53KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)166.86KB46.08KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.08KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.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)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
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)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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.49KB2.14KB
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

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.

Auto-derived related lists have no default-sort surface — derivation emits no sort, component skips $orderby, rows fall to PK order

1 participant

@yinlianghui