Uh oh!
There was an error while loading. Please reload this page.
feat(app-shell,plugin-detail): derived related lists inherit the child object's default list view sort - #6088
Merged
Conversation
…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
Contributor
✅ 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
|
yinlianghui
marked this pull request as ready for review
August 24, 2026 16:36
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 taskversion'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-levelrelatedListSortwas explicitly not approved.So nothing new is authorable.
record:related_list.sortwas already declared, parsed andconsumed, and
RelatedListalready lowers it to$orderby— the inheritance only fills it.objectstack#11345 is
pm:blockedon this card and closes when it lands.What changed, and what deliberately did not
app-shell/src/utils/deriveRelatedLists.tssort, normalized from the child def's merged default list viewapp-shell/src/views/RecordDetailView.tsxrelatedshape (re-drop site 1)plugin-detail/src/synth/buildDefaultPageSchema.tsrecord:related_listnode (re-drop site 2)RelatedList.tsxandrenderers/record-related-list.tsxare unchanged — the relay saidthe renderer needs none, and it does not:
defaultSort={schema.sort}and the$orderbyassembly were already there and already correct.
The dialect trap, and the route taken
ListView.sortandrecord:related_list.sortdeclare the same union(
string | Array<{field, order}>) and mean different things by the string arm:'seq_no desc'(@objectstack/specui/view.zod.ts, annotatedLegacy "field desc");normalizeSortSpecreads'field'/'-field'.Inheriting the string verbatim would therefore not produce "the same sort in another
notation" — it would put
$orderbyon a field whose name is the seven charactersseq_no desc. The route taken, and pinned in three places: normalize to the array armonce, at the derivation, through
@object-ui/core'sconvertSortToQueryParams— therepo's single definition of both authored dialects — so no second parser of the legacy
string exists to drift from it.
deriveRelatedListsis the one place that knows it isreading 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_]*$(specfield.zod.ts), so none is an integer-like key JSwould 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:
record:related_listnode with its ownsortand never enters this synthesizer —
RecordDetailViewsynthesizes only when no page isassigned;
related[].sortis theinheritance, 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, andone was attributed to the wrong repo:
deriveRelatedLists.ts:133-146(emit):133-146deriveRelatedLists.ts:118(child in hand):118RecordDetailView.tsx:1955-1978:1966-1990buildDefaultPageSchema.ts:174-188(relatedtype):174-188buildDefaultPageSchema.ts:683-690(relatedNode):682-690RelatedList.tsx:519-529($orderby):517-529RelatedList.tsx:258-266(normalizeSortSpec):255-267record-related-list.tsx:211(defaultSort):213MetadataProvider.tsx:413-414— "objectstack's half"packages/app-shell/src/providers/MetadataProvider.tsx;isDefaultresolver at:378, theobj.listmerge at:433-434The last row matters beyond bookkeeping: the merge that makes
child.listexist is inthis 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:940re-drops the samerelated[]array intorecord:reference_rail'sentries. It is deliberately not given the key, and thereason is a hard one rather than a judgement call:
ReferenceRailEntrySchemais$strictand declares only
objectName / relationshipField / title / limit / displayField. Emittingsortthere 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.icononto railentries. The rail is a top-3 summary, not the list. Pinned as its own leg so a later
"symmetry" edit goes red.
3.
child.listverified present at derivation time, and its absence shown harmless.objectsreachesRecordDetailViewfromMetadataContext's getter, which returnsmergeViewsIntoObjects(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 thesort 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 TERMwith a cwd-independentgit -Ccommand.vitest.config.mtsaliases@object-ui/app-shelland@object-ui/plugin-detailto theirsrc/, so every subject resolves from source — nodiststands between an edit and arun, and no rebuild leg is skippable-but-needed.
deriveRelatedLists.inheritSort…InheritedSort(wire)…relatedSort(synth)…OutsideWindowedEvery 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
$orderbyrather 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):
list carrying no
$orderby— so inheritance is not satisfiable by inventing an order.The key is absent, not present-and-empty;
sort(a number, a bare string array, an entry withoutfield) is droppedrather than guessed at.
Gates, by name, with exit codes
Exit codes captured before any pipe; each result quotes the gate's own verdict line.
type-check(@object-ui/app-shell)packages/app-shell type-check$ tsc --noEmit && tsc -p tsconfig.test.json→Donetype-check(@object-ui/plugin-detail)packages/plugin-detail type-check$ tsc --noEmit && tsc -p tsconfig.test.json→Donepnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter '@object-ui/plugin-detail^...' buildTest Files 12 passed (12)/Tests 172 passed (172)eslint(merge-base delta, 7 files)no-explicit-anywarnings only)check:esm-specifierscheck:phantom-deps@object-ui/coreis already anapp-shelldependencycheck:self-importcheck:control-bytesgrep -naPcontrol-byte sweep over the deltacheck:spec-symbolscheck-changeset-presence.mjs3 source file(s) of 2 released package(s) changed … declares 1 changeset(s)check-changeset-no-major.mjsminor, per the fixed-group ruleThe vitest union and eslint were both run after the final commit, at
5d599be9c— a clean tree at that sha.Both
type-checkruns reportScope: 2 of 47 workspace projectsand echo theirtsccommand, so neither is the silent zero-match green a mistyped filter produces.
Declared narrowing:
check:eager-closurewas not run — it needsapps/console/dist/eager-closure.jsonfrom avite buildof the console and refuses as abroken 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, andapp-shell/src/utils/index.ts— the barrel that re-exportsderiveRelatedLists— already imports@object-ui/core, so the edge adds nothing tothat closure. CI runs the whole farm regardless.
Attached, not folded
$orderbyis assembled only insideRelatedList's windowed branch, andwindowedgoes false while the built-in clienttext filter is active — the client path then returns rows unsorted unless a column was
clicked. A declared
record:related_list.sortis dropped there today; the inheritedsort 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 — soit is recorded instead, in
RelatedList.sortDroppedOutsideWindowed.test.tsx: typingin the filter drops
$orderbyfrom the query, the rows revert to server order, andclearing 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.
relatedListFilterdeclaration — list query AND-composed, tab badge honors the same filter #4664 (this same emit point ignoresrelatedListFilter) was re-confirmed by thismeasurement and is deliberately not folded in — separate card.
is unpersistable because the
asideregion carries aclassNamethatPageRegionSchemarefuses. Measured on that branch with no
sortanywhere, so it is not this change; itis 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.
objectstackuntouched.Generated by Claude Code