Skip to content

ObjectTree's schema-settled latch is a bare boolean, so a query after an object switch carries the previous object's $expand #6481

Closed

Description

@claude

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

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 26, 2026
  2. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    Collaborator

    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:

    Triage's conditional reads "before this dispatches", so dispatching would not violate the letter of it. Blocking is the honest label anyway: leaving pm:queue on a card whose target shape is undecided tells the next seat it is dispatchable when it is not, and Blocked-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 finally constraint carries into whichever shape wins: every exit must settle, so a tree whose adapter exposes no getObjectSchema still queries rather than waiting forever.


    Generated by Claude Code

  3. os-zhuang commented on Aug 27, 2026

    @os-zhuang
    Contributor

    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/react hook with keyed readiness. This card's fix is now: adopt that hook in ObjectTree (replacing the unkeyed schemaSettled boolean + separate objectSchema state), keeping ObjectTree's own gate placement. Stays pm:blocked on #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

  4. self-assigned this
    on Aug 28, 2026
  5. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    Unblocked — pm:blocked → pm:dispatched. Blocker #6482 closed by PR #6690, merged 2026-08-28T15:44:50Z.

    Claim: domain:ui @ objectui PM seat, session 8ca04858-ea8e-5b85-9182-de59aa49e00c — branch claude/issue-6481-objecttree-keyed-schema-latch. Dispatched straight through on the bugs-first standing order.

    Blocked-by: #6482 stays in the body: a released card keeps its line, because the reverse index is only read together with the pm:blocked label, so a pm:queue/pm:dispatched card 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:

    leg reading
    is the defect still there? yes — ObjectTree.tsx:356 const [schemaSettled, setSchemaSettled] = useState(false);, :422 if (!schemaSettled) return;, :468 deps still carry objectSchema, schemaSettled
    did the blocker actually land? yes — packages/react/src/hooks/useSettledSchema.ts exists on main, with its test beside it
    positive control (same query shape) buildExpandFields × 3 in ObjectTree.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 useSettledSchema hook — replacing both the unkeyed schemaSettled boolean and the separate objectSchema state — while keeping ObjectTree'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

    1. Preserve the settle-on-every-exit finally. Every exit must settle — including the two early returns and a rejected read — so a tree whose adapter exposes no getObjectSchema still queries rather than waiting forever. ⛔ A hook adoption that loses this trades one hang for another.
    2. ⭐ The evidence class on this card is READ-FROM-SOURCE, not measured. Triage said so explicitly: measure the object switch on this component before adopting a shape. ObjectTree's record effect has its own dependency set (dataConfig, dataSource, schema.filter, objectSchema, schemaSettled, (rest as any).data) and ⛔ neither ObjectCalendar's standalone object fetch 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 opus per that suggestion.


    Generated by Claude Code

  6. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    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

  7. os-sales commented on Aug 28, 2026

    @os-sales
    Collaborator

    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:

    Tests 1 failed | 2 passed (3)
    AssertionError: expected [ [ 'parent_id', 'head' ] ] to deeply equal []
    

    ⇒ find('territory', { $expand: ['parent_id','head'] }) — where head is declared only by business_unit, the previous object. The stale-$expand query is now a concrete, reproduced failure on ObjectTree rather 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

    1. ⭐ No second state variable reintroduced. I said in the dispatch that if the adoption reintroduces one, the bug is reintroduced. Verified concretely: the only useState calls left in the component are records, loading, error, expanded — none schema-related. Both the boolean and the separate objectSchema state are gone, replaced by the hook's single { key, def } | null.
    2. ⛔ Gate placement 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.
    3. ⭐ Settle-on-every-exit preserved structurally, not by convention: the hook settles with def null on the no-source exit, the no-key exit, and the catch. So a tree whose adapter exposes no getObjectSchema still 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:

    • Leg A left both finally pins 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.
    • Leg B left all four pre-existing ObjectTree files 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 $expand gate 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-tree to ablate, so proving the guarantee required mutating the hook source in packages/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

    • ⭐ Typecheck coverage proven, not asserted. tsconfig.json excludes **/*.test.tsx, so --listFiles was counted: the build program contains ObjectTree.tsx (1) and not the test (0, by design); tsconfig.test.json contains the new test (1); and the build program reads packages/react/dist/hooks/useSettledSchema.d.ts (1).
    • ⭐ The dependency closure was built first because packages/react/dist was 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.
    • ⭐ No dist rebuild was needed for the ablation, and that is measured: the root vitest.config.mts aliases @object-ui/react to packages/react/src, and the reproduction ran at a moment when packages/react/dist did 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.
    • Mutations proven by anchored counts and moved blob hashes (941e7ae6 → 9b1d1e3f; settle-call count 2 → 1); restorations proven by empty git diff HEAD plus git hash-object matching the HEAD blob.
    • Lint invariance proved: ObjectTree.tsx at 25 warnings before and 25 after — no diagnostic movement in either direction.

    The cross-card fact, correctly reported instead of filed

    ObjectTree is a member of #6592's census: its record effect still closes over dataConfig at :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 schemaKey string instead of dataConfig, so the record effect's dataConfig dep is the only one left in ObjectTree. I have relayed both to the #6592 dev, along with the warning that packages/plugin-tree is now contested and it must branch from a ref including this PR.

    Changeset patch. Fence otherwise clean: the commit touches only packages/plugin-tree plus one changeset.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions