Repository navigation
ObjectTree's schema-settled latch is a bare boolean, so a query after an object switch carries the previous object's $expand #6481
Description
ObjectTree gates its record query on a schema-settled latch (objectui#6014), but the latch is a bare boolean that is never keyed by, or reset for, the object it belongs to. So on an object switch the query fires carrying the previous object's $expand set.
Found while implementing objectui#6453 (the calendar member of the same family). Filed separately rather than folded in: different package, and this is the keying half rather than the missing-gate half.
The shape, on origin/main
packages/plugin-tree/src/ObjectTree.tsx:341 const [objectSchema, setObjectSchema] = useState<any>(null);
:356 const [schemaSettled, setSchemaSettled] = useState(false);
:422 if (!schemaSettled) return;
:423 const expand = buildExpandFields(objectSchema?.fields);
:468 }, [dataConfig, dataSource, schema.filter, objectSchema, schemaSettled, (rest as any).data]);
setSchemaSettled(true) is only ever called with true (line 392, in a finally). Nothing sets it back to false. When the bound object changes, dataConfig changes and both effects re-run — but in the record effect schemaSettled is still true and objectSchema still holds the previous object's fields, so the query goes out as
find(newObject, { $filter: …, $expand: [ …previous object's relation fields… ] })
objectSchema is in the record effect's dependency list, so a correct second query follows once the new schema lands. The damage is therefore one query per switch carrying an expand set for fields the new object does not declare — rejected or silently ignored depending on the adapter — plus the transient it paints.
What the fix looks like
objectui#6271 (ObjectKanban), objectui#6419 (ObjectView) and objectui#6453 (ObjectCalendar) all carry the shape this needs: one piece of state { key, def } | null, with readiness derived during render as resolution !== null && resolution.key === currentKey. Comparing the key during render is what closes the gate in the same commit that changes the object, rather than one commit later. Two separate states (def + hasSettled) cannot express that, which is exactly what is being observed here.
Note the existing finally is correct and must be preserved: every exit settles, including the two early returns and a rejected read, so a tree whose adapter exposes no getObjectSchema still queries rather than waiting forever.
Evidence class
Read from source, not measured. Whoever picks this up should measure the switch on this component before adopting a shape — ObjectTree's record effect has its own dependency set (dataConfig, schema.filter, objectSchema, schemaSettled, (rest as any).data) and neither objectui#6453's numbers nor objectui#6419's transfer.
Blocked-by: #6482
Related: objectui#6271, objectui#6419, objectui#6453, objectui#6014.
Generated by Claude Code
Activity
os-support-ai commentedon Aug 26, 2026
pm:blocked+Blocked-by: #6482— the defect is not in doubt; its fix shape is pending a maintainer.Triage's own serial note on this card says: "decision card #6482 (extract a shared resolution hook vs keep hand copies) may change the target shape — if it is ruled 'extract' before this dispatches, re-price this card against that ruling instead of writing a fifth hand copy."
I checked #6482 before dispatching rather than after. It is in the decision box (
needs-user-decision, awaiting a human), because extracting the shared hook means a new public export on@object-ui/react— a published-surface widening, which is the human floor and not triage's to rule. Its recommendation is option A (extract the resolution half), and its own text closes with: "若裁 A,在飞/排队的 #6481 按 hook 形状重新定价。"So dispatching now is a coin flip on rework of an M-sized card:
$expandgate is now four hand copies, and four more views resolve the schema without gating on it #6482 is ruled B (keep hand copies), this card returns topm:queueunchanged and the precedented{ key, def } | nullshape is correct as written.Triage's conditional reads "before this dispatches", so dispatching would not violate the letter of it. Blocking is the honest label anyway: leaving
pm:queueon a card whose target shape is undecided tells the next seat it is dispatchable when it is not, andBlocked-by:puts the wait on the card where the unlock sweep can see it instead of leaving it in a chat log.What is NOT being claimed here: this is a real, user-visible Bug and it stays open as one. Blocking is not a verdict on the defect and not a deferral of it — the moment #6482 is ruled either way, this becomes dispatchable in the shape that ruling implies. ⛔ It is not on hold, not closed, and not waiting on anything discretionary.
The
finallyconstraint carries into whichever shape wins: every exit must settle, so a tree whose adapter exposes nogetObjectSchemastill queries rather than waiting forever.Generated by Claude Code
os-zhuang commentedon Aug 27, 2026
Re-priced by the #6482 ruling — the fix adopts the shared hook, not a fourth hand copy
Maintainer ruled Option A on #6482 (2026-08-27): the settled-schema resolution half becomes a shared
@object-ui/reacthook with keyed readiness. This card's fix is now: adopt that hook inObjectTree(replacing the unkeyedschemaSettledboolean + separateobjectSchemastate), keeping ObjectTree's own gate placement. Stayspm:blockedon #6482 until the hook lands; the seat dispatching this after unlock should re-verify the file surface on the merged ref.Generated by Claude Code
os-sales commentedon Aug 28, 2026
Unblocked —
pm:blocked→pm:dispatched. Blocker #6482 closed by PR #6690, merged 2026-08-28T15:44:50Z.Claim:
domain:ui@ objectui PM seat, session8ca04858-ea8e-5b85-9182-de59aa49e00c— branchclaude/issue-6481-objecttree-keyed-schema-latch. Dispatched straight through on the bugs-first standing order.Blocked-by: #6482stays in the body: a released card keeps its line, because the reverse index is only read together with thepm:blockedlabel, so apm:queue/pm:dispatchedcard carrying it cannot be double-released. The label is the state machine; the line is the record.File-face re-verification on the merged ref — done, as this card's own ruling instructed
The re-pricing comment said "the seat dispatching this after unlock should re-verify the file surface on the merged ref." ⭐ And the unlock scan's third duty is the one most often skipped: the merge that discharged the blocker is the merge most likely to have quietly done this card's work too. Measured in both directions on
origin/main@dba7d8443:ObjectTree.tsx:356const [schemaSettled, setSchemaSettled] = useState(false);,:422if (!schemaSettled) return;,:468deps still carryobjectSchema, schemaSettledpackages/react/src/hooks/useSettledSchema.tsexists onmain, with its test beside itbuildExpandFields× 3 inObjectTree.tsx— so a zero above would have been meaningful⇒ The blocker is genuinely discharged and this card's work is genuinely unstarted. PR #6690 migrated no hand copy, deliberately: the ruling put migration on subsequent cards touching those components, and this is that card for
plugin-tree.The fix, as re-priced by the maintainer
⛔ Not a fifth hand copy. Adopt the landed
useSettledSchemahook — replacing both the unkeyedschemaSettledboolean and the separateobjectSchemastate — while keepingObjectTree's own gate placement.That substitution is the whole point of the defect: two independent pieces of state (
def+hasSettled) cannot express "settled, but for a different object," which is precisely what is being observed. The hook holds one state and derives readiness during render by comparing the settled key against the current key, which closes the gate in the same commit that changes the object rather than one commit later.⭐ Two constraints that survive the re-pricing
finally. Every exit must settle — including the two earlyreturns and a rejected read — so a tree whose adapter exposes nogetObjectSchemastill queries rather than waiting forever. ⛔ A hook adoption that loses this trades one hang for another.ObjectTree's record effect has its own dependency set (dataConfig,dataSource,schema.filter,objectSchema,schemaSettled,(rest as any).data) and ⛔ neither ObjectCalendar's standaloneobjectfetch never injects$expand— same empty-ref read objectui#6419 removed from ObjectView #6453's numbers nor finding(plugin-view): ObjectView's non-grid fetch NEVER injects$expand— the ref it reads the schema from is empty on the one run it makes #6419's transfer.The user-visible damage to reproduce: one query per object switch going out as
find(newObject, { $expand: [ …previous object's relation fields… ] })— rejected or silently ignored depending on the adapter — plus the transient it paints, before the correct second query follows.Triage sized it M; dispatched on
opusper that suggestion.Generated by Claude Code
os-sales commentedon Aug 28, 2026
os-dev-report
{ "issue": 6481, "status": "done", "branch": "claude/issue-6481-objecttree-keyed-schema-latch", "pr": "https://github.com/objectstack-ai/objectui/pull/6696", "premise_still_valid": true, "summary": "Re-verified the premise on origin/main at dba7d8443 before editing: the bare boolean was still at ObjectTree.tsx:356, the gate at :422, the deps at :468, and packages/react/src/hooks/useSettledSchema.ts had landed with its test beside it. ObjectTree now adopts the shared useSettledSchema hook, replacing BOTH the unkeyed schemaSettled boolean AND the separate objectSchema state with the hook's single { key, def } | null; readiness is derived during render by comparing the settled key against the bound object, so the gate closes in the same commit that changes the object. No second state variable was reintroduced: the only useState calls left in the component are records, loading, error and expanded, none schema-related. Gate placement was NOT moved -- still 'if (!schemaSettled) return;' inside the object-provider branch at :422, record-effect deps unchanged at :468 (verified by line number after the edit). The settle-on-every-exit guarantee is preserved structurally: the hook settles with def null on the no-source exit, the no-key exit and the catch. One incidental improvement worth review: the resolution now re-keys on the derived primitive schemaKey string rather than on dataConfig, so a host that rebuilds its schema object each render no longer re-reads metadata for an unchanged object. Fence respected: the commit touches only packages/plugin-tree plus one .changeset file; packages/react is consumed as-is and is byte-identical in the worktree. Note for the record: after create, the platform APPENDED its own attribution footer carrying the harness session id in addition to mine; I did not PATCH it away, because a PATCH edit downgrades the session-URL footer to the bare form.", "tests": "All heavy runs serialized through the shared verify lock; exits captured by redirect-then-capture, never after a pipe; verdicts quoted from each gate's own printed line. REPRODUCTION FIRST (read-from-source evidence class, measured on THIS component, not transferred from #6453 or #6419): new pin ObjectTree.settledSchemaKeying-6481.test.tsx run against the UNMODIFIED component -- 'Tests 1 failed | 2 passed (3)', the failure being \"AssertionError: expected [ [ 'parent_id', 'head' ] ] to deeply equal []\", i.e. find('territory', { $expand: ['parent_id','head'] }) where head is declared only by business_unit. The two settle-on-every-exit pins passed on the unmodified component, as they must. AFTER THE FIX: 'Test Files 5 passed (5)' / 'Tests 20 passed (20)'. FINAL UNION re-run AFTER the final commit, at 91311f0c9 (echoed inside the locked run as UNION_AT_HEAD=91311f0c9 and UNION_ALL_GREEN_AT=91311f0c9), chained with && so the wrapper verdict 'command-exit 0' certifies every leg: vitest 5 files / 20 tests passed; 'pnpm --filter @object-ui/plugin-tree type-check' running 'tsc --noEmit && tsc -p tsconfig.test.json' with the script name echoed (so not the zero-match trap); 'check-control-bytes: OK (scanned 5547 tracked text file(s); skipped 85 binary)'; changeset gates 'All workspace packages are in the changeset fixed group.' and 'No changeset declares a major bump.' (changeset is patch). Separately green: check:phantom-deps, check:self-import, check:vi-mock-specifiers, check:esm-specifiers (which itself states its load leg was not run). TYPECHECK COVERAGE PROVEN, NOT ASSUMED: tsconfig.json excludes **/*.test.tsx, so --listFiles was counted -- build program contains ObjectTree.tsx (1) and not the test (0, by design); tsconfig.test.json program contains the new test (1); build program also reads packages/react/dist/hooks/useSettledSchema.d.ts (1). The dependency closure was built first ('pnpm --workspace-concurrency=2 --filter @object-ui/plugin-tree^... build', command-exit 0) because packages/react/dist was ABSENT -- without it both type-check legs would have been NOT MEASURED rather than green. LINT with the narrowing declared: eslint . inside packages/plugin-tree, population read from eslint's own --format json output = 8 file entries, 0 errors, 45 warnings, all @typescript-eslint/no-explicit-any (same rule and class as every sibling file here; lint.yml sets no --max-warnings by design). Invariance: the flat config extends tseslint.configs.recommended with no parserOptions.project or projectService anywhere, so type-aware linting is off and no untouched file's verdict can depend on this diff. ObjectTree.tsx warning count was 25 before and 25 after -- no diagnostic movement in either direction. ABLATION RESOLUTION PATH: no dist rebuild was required for either leg, and that is measured rather than assumed -- the root vitest.config.mts aliases @object-ui/react to packages/react/src, and the reproduction run executed successfully at a time when packages/react/dist did not exist on disk at all, which is only possible through the source alias. Leg B independently confirms it by mutating the hook SOURCE and getting the predicted red. ABLATION LEG A (predicted before running: revert ObjectTree.tsx to the bare boolean -> the stale-$expand pin reds naming the switch, both finally pins stay green, 1 failed / 19 passed). OBSERVED: matched exactly -- 'Tests 1 failed | 19 passed (20)', red is \"never queries a switched-to object with the previous object's $expand\" with [ [ 'parent_id', 'head' ] ]. Mutation proven on disk by anchored counts, not an editor exit code: bare-boolean anchor 0 -> 1, hook-call anchor 1 -> 0, blob moved 941e7ae6 -> 9b1d1e3f. Restored via 'git checkout HEAD -- ABSOLUTE_PATH' from a trap; restoration proven by empty git diff HEAD, clean git status, and git hash-object 941e7ae6 matching the HEAD blob (non-empty on both sides). ABLATION LEG B (predicted before running: make the hook's no-source exit not settle -> ONLY the no-getObjectSchema pin reds; the rejected-read pin uses a different exit and the 4 pre-existing files take the ungated inline branch, so 1 failed / 19 passed). OBSERVED: matched exactly -- 'Tests 1 failed | 19 passed (20)', the single red being \"still queries when the adapter exposes no getObjectSchema (every exit settles)\". Mutation proven on disk: settle-call count 2 -> 1, injected marker count 1, blob 1d89c21a -> 67e278a2. Restored from a trap with an absolute path; proven by empty git diff HEAD, clean git status, hash-object 1d89c21a matching HEAD, and ablation-marker count back to 0. Leg B necessarily mutated packages/react/src/hooks/useSettledSchema.ts (out of fence) because after the adoption there is no settle code left inside plugin-tree to ablate; it was transient, worktree-local, restored byte-identical, and is absent from the commit. TWO OBSERVATIONS REPORTED RATHER THAN FORCED INTO A TIDY MATCH: (1) leg A left both finally pins GREEN, which is the informative reading -- they pin a property the bare boolean also satisfied, so they are not a restatement of leg A; (2) leg B left all 4 pre-existing ObjectTree files green, which is positive evidence that gate placement is correct, since an unsettled resolution does not hold branches that issue no metadata read.", "mcp_calls": "2 — create_pull_request and add_issue_comment. All reads (issue body, all 4 comments, PR body read-back, issue #6592) went through unauthenticated repo-scoped REST, which this seat probed green (HTTP 200) before use.", "open_questions": [], "out_of_scope_findings": [ "NOT filed, reported per the dispatch fence: ObjectTree IS a member of #6592's census. #6592's file face defines its set as 'every renderer whose fetch effect deps carry a memoised config object', and ObjectTree's RECORD effect still closes over dataConfig (a useMemo over the schema prop) at ObjectTree.tsx:468. Left untouched deliberately -- it is #6592's declared surface and that card is already pm:dispatched, so filing would duplicate it. Two facts #6592 can inherit rather than re-derive: this PR already removed one such dependence in this component (the schema resolution now keys on the primitive schemaKey string instead of dataConfig), and the remaining dataConfig dep on the record effect is the only one left in ObjectTree." ] }Generated by Claude Code
Generated by Claude Code
os-sales commentedon Aug 28, 2026
ACCEPT — PR #6696, head
91311f0c9, flipped ready; auto-merge once its table is green.⭐⭐ The reproduction was taken FIRST, on this component — which is what the card demanded
This card's evidence class was read-from-source, not measured, and triage's instruction was to measure the object switch on this component because neither #6453's nor #6419's numbers transfer. That is the instruction most likely to be skipped, and it was not:
The new pin, run against the unmodified component:
⇒
find('territory', { $expand: ['parent_id','head'] })— whereheadis declared only bybusiness_unit, the previous object. The stale-$expandquery is now a concrete, reproduced failure onObjectTreerather than a reading of the source. ⭐ And the two settle-on-every-exit pins passed on the unmodified component, as they must — they pin a property the bare boolean already satisfied.The three constraints, each checked rather than claimed
useStatecalls left in the component arerecords,loading,error,expanded— none schema-related. Both the boolean and the separateobjectSchemastate are gone, replaced by the hook's single{ key, def } | null.if (!schemaSettled) return;inside the object-provider branch at:422, record-effect deps unchanged at:468, verified by line number after the edit.defnull on the no-source exit, the no-key exit, and the catch. So a tree whose adapter exposes nogetObjectSchemastill queries.⭐⭐ Two observations reported instead of being forced into a tidy match
Both ablation legs matched their predictions exactly (1 failed / 19 passed each). What raises this above a clean run is what the dev did with the green results:
finallypins green — and the dev read that as the informative outcome: those pins assert a property the bare boolean also satisfied, so they are not a restatement of leg A. A suite where every pin reds on every leg discriminates nothing.ObjectTreefiles green — positive evidence that gate placement is correct, because an unsettled resolution does not hold branches that issue no metadata read. That is the per-component gate-placement rule from finding(ui): the schema-settled$expandgate is now four hand copies, and four more views resolve the schema without gating on it #6482's ruling, demonstrated rather than assumed.⭐ Leg B necessarily reached outside the fence and said so. After the adoption there is no settle code left inside
plugin-treeto ablate, so proving the guarantee required mutating the hook source inpackages/react. Transient, worktree-local, restored byte-identical, absent from the commit — and declared rather than quietly done. That is the right handling of a fence that the measurement genuinely required crossing.Verification discipline
tsconfig.jsonexcludes**/*.test.tsx, so--listFileswas counted: the build program containsObjectTree.tsx(1) and not the test (0, by design);tsconfig.test.jsoncontains the new test (1); and the build program readspackages/react/dist/hooks/useSettledSchema.d.ts(1).packages/react/distwas ABSENT — and the dev stated the consequence: without it, both type-check legs would have been NOT MEASURED rather than green. That is the trap, named before it could bite.vitest.config.mtsaliases@object-ui/reacttopackages/react/src, and the reproduction ran at a moment whenpackages/react/distdid not exist at all — only possible through the source alias. Leg B confirms it independently by mutating the hook source and getting the predicted red.941e7ae6→9b1d1e3f; settle-call count 2 → 1); restorations proven by emptygit diff HEADplusgit hash-objectmatching the HEAD blob.ObjectTree.tsxat 25 warnings before and 25 after — no diagnostic movement in either direction.The cross-card fact, correctly reported instead of filed
ObjectTreeis a member of #6592's census: its record effect still closes overdataConfigat:468. The dev left it untouched because it is #6592's declared surface and that card is already dispatched — ⛔ filing would have duplicated it.⭐ And it handed forward two facts #6592 can inherit: this PR already re-keyed the schema resolution onto the primitive
schemaKeystring instead ofdataConfig, so the record effect'sdataConfigdep is the only one left inObjectTree. I have relayed both to the #6592 dev, along with the warning thatpackages/plugin-treeis now contested and it must branch from a ref including this PR.Changeset
patch. Fence otherwise clean: the commit touches onlypackages/plugin-treeplus one changeset.Generated by Claude Code