Skip to content

fix(plugin-tree): key ObjectTree's schema-settled gate to the bound object - #6696

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6481-objecttree-keyed-schema-latch
Aug 28, 2026
Merged

fix(plugin-tree): key ObjectTree's schema-settled gate to the bound object#6696
os-sales merged 1 commit into
mainfrom
claude/issue-6481-objecttree-keyed-schema-latch

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6481

ObjectTree gated its record query on a schema-settled latch that was a bare
boolean — never keyed by, nor reset for, the bound object. This adopts the
shared useSettledSchema hook that landed for the #6482 ruling, replacing
both that boolean and the separate objectSchema state, and keeps
ObjectTree's own gate placement.

The defect, reproduced on this component

Triage set the evidence class here to read-from-source, so the object switch was
measured on ObjectTree itself before a shape was adopted. #6453's and #6419's
numbers do not transfer: this component's record effect has its own dependency
set, and its gate sits inside the object-provider branch rather than at the
top of the effect.

The reproduction binds two objects whose expandable field sets are disjoint
apart from the parent pointer — head only on business_unit, region only on
territory — with getObjectSchema held open by a hand-resolved promise, so
"not settled yet" is a real, observable state. Bind business_unit, let it
settle, then swap the bound object to territory. Measured on origin/main at
dba7d8443:

AssertionError: expected [ [ 'parent_id', 'head' ] ] to deeply equal []

That array is the $expand of a real query: find('territory', { $expand: ['parent_id', 'head'] }). head is not a field territory declares. One such
query per switch — rejected or silently ignored depending on the adapter, plus
the transient it paints — before the correct second query follows.

Before / after state shape

Before — two independent pieces of state:

const [objectSchema, setObjectSchema] = useState(null);        // the definition
const [schemaSettled, setSchemaSettled] = useState(false);     // a one-way latch

setSchemaSettled was only ever called with true, in a finally; nothing set
it back. Two independent values cannot express "settled, but for a
different object" — so after a switch the gate read true left over from the
previous object's settle while the definition still held the previous object's
fields.

After — one piece of state, readiness derived during render:

const schemaKey =
  (dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName) ?? '';
const { ready: schemaSettled, def: objectSchema } = useSettledSchema(
  schemaKey,
  dataSource,
);

(Explicit type arguments elided in both snippets.) The hook holds a single
{ key, def } | null and compares the settled key against the current key at
render time, so the gate closes in the same commit that changes the object
rather than one commit later. "Ready for the wrong object" is not merely
repaired but unwritable — there is no second piece of state left to disagree
with the first.

Deliberately unchanged: the gate did not move. It is still
if (!schemaSettled) return; inside the object-provider branch at
ObjectTree.tsx:422, and the record effect's dependency list is still at :468.
The hook's own effect re-keys on the derived schemaKey string rather than on
dataConfig, so a host that rebuilds its schema object each render no longer
re-reads metadata for an object that never changed.

The settle-on-every-exit guarantee, preserved and pinned

The finally existed so that the two early returns (no dataSource or no
getObjectSchema; no object name) and a rejected read all settled too —
otherwise the gated record query waits forever and a tree whose adapter serves no
schema never renders a row. An adoption that lost this would trade one hang for
another.

The hook makes the guarantee structural rather than incidental: each of those
exits settles explicitly with def: null, which the hook documents as an outcome
distinct from "not ready yet". Worth naming for review: after this change
ObjectTree contains no settle code of its own, so its pins are consumer-side
pins of a provider-side guarantee. Both halves are pinned here, and ablation
leg B below shows they actually discriminate:

  • still queries when the adapter exposes no getObjectSchema (every exit settles)
  • still queries when the schema read rejects (every exit settles)

Ablation — prediction stated before each leg ran

Both legs mutate, prove the mutation landed on disk by anchored counts plus a
moved blob hash (never an editor's exit code), restore via git checkout HEAD --
against an absolute path from a trap, and prove restoration by an empty
git diff HEAD plus a matching non-empty git hash-object.

leg mutation predicted observed
A revert ObjectTree.tsx to the bare boolean (dba7d8443) the stale-$expand pin reds naming the switch; both finally pins stay green -> 1 failed / 19 passed matched. 1 failed / 19 passed; expected [ [ 'parent_id', 'head' ] ] to deeply equal []
B make the hook's no-source exit not settle only the no-getObjectSchema pin reds; the rejected-read pin (a different exit) and the 4 pre-existing files stay green -> 1 failed / 19 passed matched. 1 failed / 19 passed; the single red is still queries when the adapter exposes no getObjectSchema

Leg A anchors: bare-boolean anchor 0 -> 1, hook-call anchor 1 -> 0, blob
941e7ae6 -> 9b1d1e3f. Leg B anchors: settle-call count 2 -> 1, injected
marker 1, blob 1d89c21a -> 67e278a2.

Two notes rather than a tidy match:

  • Leg A left the two finally pins green, which is the useful reading: they
    pin a property the bare boolean also satisfied, so they are not a restatement
    of leg A and would have caught a regression leg A cannot.
  • Leg B left all 4 pre-existing ObjectTree files green. That is positive
    evidence for gate placement: those files render inline/static data, and an
    unsettled resolution does not hold branches that issue no metadata read.

Leg B necessarily mutated packages/react/src/hooks/useSettledSchema.ts, which is
outside this PR's surface — after the adoption there is no settle code left in
plugin-tree to ablate. It was transient, inside this worktree only, and restored
byte-identical (1d89c21a both sides, ablation marker count back to 0). This PR
touches no file under packages/react.

Gate table

Verdicts quoted from each gate's own printed line; exit captured by
redirect-then-capture, never after a pipe. The union below was re-run after
the final commit, at 91311f0c9.

gate invocation verdict
plugin-tree vitest pnpm exec vitest run packages/plugin-tree/src/ from repo root (objectui#3378) Test Files 5 passed (5) / Tests 20 passed (20)
plugin-tree type-check pnpm --filter @object-ui/plugin-tree type-check tsc --noEmit && tsc -p tsconfig.test.json, command-exit 0
control bytes node scripts/check-control-bytes.mjs (new file git add-ed first) check-control-bytes: OK (scanned 5547 tracked text file(s); skipped 85 binary)
changesets pnpm run changeset:check All workspace packages are in the changeset fixed group. / No changeset declares a major bump.
phantom deps pnpm run check:phantom-deps Every in-scope import is declared by the package that publishes it.
self-import pnpm run check:self-import No package names itself inside its own src/.
vi.mock specifiers pnpm run check:vi-mock-specifiers check-vi-mock-specifiers: OK (3907 tracked source file(s) …)
esm specifiers pnpm run check:esm-specifiers no un-ledgered package emits an extensionless relative specifier (this script states its load leg was not run)
lint eslint . in packages/plugin-tree, --format json 8 files, 0 errors, 45 warnings — all @typescript-eslint/no-explicit-any, the same rule and class as every sibling file here; lint.yml sets no --max-warnings by design

Typecheck coverage, not assumed. tsconfig.json excludes **/*.test.tsx, so
"type-check is clean" would say nothing about the new test on its own. Counted via
--listFiles: the build program contains ObjectTree.tsx (1) and not the test
(0, by design); the tsconfig.test.json program contains the new test (1). The
build program also reads packages/react/dist/hooks/useSettledSchema.d.ts (1),
so the adoption is checked against freshly rebuilt dependency typings — the
closure was built first (pnpm --filter '@object-ui/plugin-tree^...' build,
command-exit 0) because packages/react/dist was absent and both type-check
legs resolve @object-ui/react through it.

Lint narrowing, declared. CI runs the whole farm via turbo run lint; this run
was narrowed to packages/plugin-tree, the only package the diff touches
(git diff --cached --name-only: two files there plus a .changeset markdown,
which eslint does not lint). Population read from eslint's own --format json
output — 8 file entries — not from a guess about which files count. 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.

Heavy runs were serialized through the shared verify lock; every wrapper verdict
quoted above read command-exit 0.

Generated by Claude Code


Generated by Claude Code

…bject

Adopt the shared `useSettledSchema` hook from `@object-ui/react` in
`ObjectTree`, replacing BOTH the unkeyed `schemaSettled` boolean and the
separate `objectSchema` state with the hook's single `{ key, def } | null`.

Two independent pieces of state cannot express "settled, but for a DIFFERENT
object". `schemaSettled` was a one-way latch set `true` in a `finally` and
never reset, so on an object switch the gate read `true` from the previous
object's settle while `objectSchema` still held the previous object's fields,
and the record query went out as

    find(newObject, { $filter: …, $expand: [ …previous object's fields… ] })

Readiness is now derived during render by comparing the settled key against
the currently bound object, so the gate closes in the same commit that changes
the object rather than one commit later.

Deliberately unchanged: gate PLACEMENT stays inside the object-provider branch
of the record effect, and every exit still settles (no `getObjectSchema`, no
object name, or a rejected read each settle with no definition) so a tree whose
adapter serves no schema still queries instead of waiting forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3231.9 KB 3266.6 KB
Main entry chunk (gzip) 157.2 KB 350 KB
Entry file index-MVGDiF11.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 509.24KB 115.61KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 239.05KB 60.06KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.85KB 12.89KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.43KB 34.48KB
plugin-designer (index.js) 212.80KB 43.15KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.16KB 40.33KB
plugin-grid (index.js) 201.51KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.01KB 27.57KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 8.91KB 3.05KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 65.97KB 21.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 28, 2026 16:09
os-sales pushed a commit that referenced this pull request Aug 28, 2026
…#6696

PM coordinator flagged (after this branch was already cut, and after
ObjectTree.tsx had already been edited on this branch) that PR #6696
(objectui#6481, "key ObjectTree's schema-settled gate to the bound object")
is open, ready, and rewrites this exact file: it replaces the
objectSchema/schemaSettled state pair this branch also touched with a
shared `useSettledSchema` hook, re-keyed onto a derived `schemaKey`
primitive as an incidental improvement -- already satisfying this card's
acceptance direction for that one effect. ObjectTree's SECOND effect (the
record fetch, ObjectTree.tsx:468 on current main) still closes over bare
`dataConfig` and is untouched by #6696 -- a real, confirmed remaining
census member -- but #6696 is "ready and heading for the queue" per the
coordinator, so this file is contested until it lands.

Reverts ObjectTree.tsx to its origin/main content (verified byte-identical
to origin/main @ e0d83da, which does not yet contain #6696) and drops
the new ObjectTree.discardedConfigMemo.test.tsx pin along with it, so this
branch edits a file #6696 is mid-flight on nowhere. The changeset drops the
@object-ui/plugin-tree entry to match.

plugin-map / plugin-calendar / plugin-gantt are unaffected and ship as
originally verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
@os-sales
os-sales added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 9abc02a Aug 28, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6481-objecttree-keyed-schema-latch branch August 28, 2026 16:33
os-sales pushed a commit that referenced this pull request Aug 28, 2026
…nfig primitives (#6700)

The record-fetch effect closed over `dataConfig`, a `useMemo` over the
`schema` prop, whose identity `useMemo` carries no semantic guarantee for --
React may discard the cache and recompute even when `schema` is unchanged,
and `getDataConfig(schema)` builds a fresh `{ provider, object }` /
`{ provider, items }` wrapper object on every call. A discard alone was
therefore enough to re-run the effect and issue an extra `dataSource.find`
call, with nothing about the bound object actually different.

Re-keys the effect onto the three primitive fields it actually reads off
`dataConfig` (`provider`, and conditionally `object` / `items`) instead of
the container object, mirroring the pattern already shipped for
`ObjectMap`/`ObjectCalendar`/`ObjectGantt` (objectui#6592). This is the last
`dataConfig`-identity dependence in the component: the schema-resolution
effect was already re-keyed onto `useSettledSchema`'s primitive `schemaKey`
by #6696.

New pin (`ObjectTree.discardedConfigMemo.test.tsx`) simulates a discarded
memo via a schema reference swap with identical primitive content (verified
against this component's own `dataConfig = useMemo(..., [schema])`, which
reliably recomputes on that swap) and asserts no extra `dataSource.find`
call, alongside a counter-probe that a genuinely different `objectName`
still refetches. Reverse-verified RED against the pre-fix source (2 calls
observed vs. 1 expected) and GREEN after restoring the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants