feat(react): extract the settled-schema resolution hook - #6690
Merged
Conversation
Publish `useSettledSchema` from `@object-ui/react`: the settled-schema
RESOLUTION half shared by ObjectKanban / ObjectView / ObjectCalendar's
fetch-gate hand copies. One piece of state (`{ key, def } | null`) with
render-time `ready = resolution !== null && resolution.key === key`
means ready/def can never be observed inconsistently and a stale key
can never read as ready — the structural fix for objectui#6481's
ObjectTree defect (a separate, one-way-latched boolean that stayed
true across an object switch).
Gate placement stays per-component per the maintainer ruling (Option
A); existing hand copies migrate on their own subsequent cards, not
here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
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
|
os-sales
marked this pull request as ready for review
August 28, 2026 15:22
This was referenced Aug 28, 2026
os-sales
pushed a commit
that referenced
this pull request
Aug 28, 2026
…h effects onto dataConfig primitives
useMemo carries no semantic guarantee -- React may discard a memo cache and
recompute even when its deps compare equal, and getDataConfig(schema) builds
a fresh {provider, object}/{provider, items} wrapper object on every call.
So a fetch effect keyed on the whole `dataConfig` object was correct only
for as long as that identity happened to survive a discard: a recompute was
enough to re-run the effect and refetch, with schema itself unchanged.
Re-keys the load-bearing fetch effects onto the primitive fields they
actually read off dataConfig (provider / object / items) instead of the
container object, across every renderer the census found using the local
getDataConfig(schema)-into-useMemo pattern:
- plugin-map/ObjectMap.tsx -- both fetch effects (the known member, objectui#6270/#6591's deferred half)
- plugin-tree/ObjectTree.tsx -- both fetch effects
- plugin-calendar/ObjectCalendar.tsx -- the record-fetch effect (reusing the
existing schemaObjectName primitive; the schema-fetch effect was already
primitive-keyed)
- plugin-gantt/ObjectGantt.tsx -- reload()'s deps, and the fetch-object-schema
effect's dead (unused) dataConfig dependency dropped entirely. gantt's
effectiveDataSource memo deliberately keeps dataConfig as a dependency --
resolveDataSource needs the whole provider-shaped value, which cannot be
flattened to a fixed primitive list -- documented in-line as a scoped
exception; see the PR body's "known boundary" note.
packages/plugin-grid/src/ObjectGrid.tsx has the same dataConfig-in-deps
shape but sits inside objectui#6597's fence (plugin-grid/plugin-dashboard/
types) and is left untouched; packages/react is fenced in full (PR #6690).
Each touched renderer gets a new pinned test demonstrating the acceptance
direction: a dataConfig identity change carrying the SAME primitive fields
(the observable a discarded-and-recomputed memo produces) must not add an
extra dataSource.find/getObjectSchema call, alongside a counter-probe that a
genuinely different object name still does refetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
This was referenced Aug 28, 2026
Merged
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 free
to 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 #6482
What this lands
Extracts the settled-schema RESOLUTION half — shared today across three hand
copies (
ObjectKanban#6271,ObjectView#6419,ObjectCalendar#6453) — into anew, published hook:
useSettledSchema, exported from@object-ui/react(
packages/react/src/hooks/useSettledSchema.ts).Per the maintainer ruling of 2026-08-27 (Option A), this dispatch lands only
the hook. Migrating the three existing hand copies rides subsequent cards that
touch those components; none of
ObjectKanban.tsx/ObjectView.tsx/ObjectCalendar.tsx/ObjectTree.tsxis touched here. The four ungated views(
ObjectGantt,ObjectMap,ObjectTimeline,ObjectGallery) are untouched too— Option C (blanket gating) stays excluded on the card's own per-component
measurement.
Published-surface declaration (Clause-②)
@object-ui/react's public surface widens by two names, both re-exported fromthe package entry (
packages/react/src/index.ts→hooks/index.ts→useSettledSchema.ts):useSettledSchema(key: string, dataSource: DataSource | null | undefined): SettledSchema— the hook. Generic over the definition typeTDef(defaultunknown);DataSourcehere takes its own default type parameter.SettledSchema— the hook's return type for that sameTDef:{ ready: boolean; def: TDef | null }.An external consumer of
@object-ui/reactcan nowimport { useSettledSchema } from '@object-ui/react'and use it directly. Changeset scored
minor(objectui's major stays pinned to@objectstack's perscripts/check-changeset-no-major.mjs).Reachability verified through the barrel and the
exportsmap, not by anexportkeyword in a module file — same TypeScript-checker query(
getExportsOfModule)scripts/check-readme-exports.mjsitself uses, runagainst the built
packages/react/dist/index.d.ts, with a positive control(
useDataRefresh, already published) resolved through the identical query:AGENTS.md §3still bars React from@object-ui/core("No UI-lib deps. Logiconly.") — confirmed on this ref — which is why the hook lives in
@object-ui/react("The Runtime … Bridges Core and Components"), notcore.The shape, and why the resolution/gate split falls where it does
Internally: one piece of state —
useStateover{ key: string; def: TDef | null } | null, initialized tonull,settled by an effect keyed on
[key, dataSource]whose every exit settles(success, a thrown read, and "no source to read from" alike — a caller's gated
effect waits on
ready, so an exit that didn't settle would hold that queryopen forever).
readyanddefare derived at render time:Resolution is common; gate placement is not, and this PR keeps that split.
The three existing hand copies already agree on the resolution shape above
(state, key, settle-on-every-exit) — that's the part this hook lifts out. They
disagree on where the gate sits:
ObjectCalendarkeys ondataConfig.object ?? schema.objectName(notschema.objectName) and gates onlyits
object-provider branch, because an inlinevaluedata set issues nometadata read at all — a whole-effect gate would hold a query open on a
resolution nothing was ever going to produce. That's component-private truth
established by #6453's own measurement, and this hook does not take it over:
callers compute their own
keyand passdataSource: undefinedfor a renderthat shouldn't fetch, rather than the hook growing an
enabledflag. TheuseSettledSchemadoc comment and the newpackages/react/README.md"Hooks"entry both spell out this composition with a worked
ObjectCalendar-shapedexample.
Demonstrating #6481's defect is unwritable
#6481's root cause:
ObjectTreecarried the definition (objectSchema) and"has it settled" (
schemaSettled) as two separateuseStates.schemaSettledwas a one-way latch — settrueon first settle, never reset —so when the host swapped
objectNamemid-life, the fetch effect beganrefetching the new object's schema while
schemaSettledstayedtruefromthe old object's settle. A gated effect reading
schemaSettled === true+the still-old
objectSchemawould query with the wrong$expand: a stalekey reading as ready.
useSettledSchemamakes that shape unrepresentable rather than merelyavoiding it: there is no second, independently-settable boolean.
readyis arender-time comparison,
resolution.key === key, against a single statevalue — the instant
keychanges, that comparison is false in the samerender, before any effect runs. There is no window and nothing a caller can
observe out of sync, because there is nothing to observe besides the one
{ready, def}pair.packages/react/src/hooks/__tests__/useSettledSchema.test.tspins thisdirectly —
objectui#6481 unwritable: switching keys while a fetch is in flight reads NOT ready in the SAME render — never the old key's def: settle'accounts', switch to'contacts'before its fetch resolves, assertready === falseanddef === nullsynchronously, with nowaitFor. Asibling test additionally proves a late-arriving resolution for an
abandoned key can never clobber the current one (the abandoned effect's
isMountedclosure is false by the time it resolves). 9/9 tests pass; see theablation below for proof the pin actually bites.
Ablation — prediction vs observation
Mutation (on-disk edit to
packages/react/src/hooks/useSettledSchema.ts,committed baseline
877944237before mutating, restored fromHEADafter):This reproduces
ObjectTree's actual bug shape — a settle flag that doesn'tcare which key it settled for.
Predicted before running: the
objectui#6481 unwritabletest and theABANDONED keytest both go red — both assertready === falsein the renderright after a key switch, pre-settle.
Observed: only the
objectui#6481 unwritabletest went red(
expected true to be false); theABANDONED keytest stayed green. Thedifference, on inspection: the
ABANDONED keytest switches keys before anyresolution ever settles —
resolutionis stillnullat that point, soresolution !== nullreadsfalseunder both the mutated and theoriginal code, and the mutation only diverges from correct behavior once a
prior key has already settled successfully (exactly
ObjectTree's bugshape: an object whose schema had already resolved, then swapped for a new
one). Reporting the actual direction rather than forcing the initial
prediction — the pin that matches the real defect shape is the one that bit;
the other test simply doesn't exercise that window.
Mutation landing on disk was proven by an anchored
grep -cof the exact line(1 → 0 → mutated-marker present), not by any tool's exit code. Restoration was
proven by an empty
git diff HEADplus a byte-identicalgit hash-objectagainst theHEADblob (1d89c21a1f26108a715dbe4b36eef22cffe21c12both before and after). The mutate/restore script used a
trap ... EXIT INT TERMwith an absolute
$REPO_ROOT-anchored restore path. Full suite re-run green(9/9) after restoration.
Gate table
All commands run at repo root; exit captured by redirect-then-capture, never
after a pipe. Final union re-run after the commit this PR ships
(
877944237).@object-ui/reactvitest (full package scope)pnpm exec vitest run packages/react/Test Files 62 passed (62)/Tests 916 passed (916)@object-ui/reactvitest (new file only)pnpm exec vitest run packages/react/src/hooks/__tests__/useSettledSchema.test.tsTest Files 1 passed (1)/Tests 9 passed (9)@object-ui/reacttype-checkpnpm --filter @object-ui/react type-check(tsc --noEmit && tsc -p tsconfig.test.json)--listFilesthattsconfig.test.jsonincludes both the new hook and its test filecheck:control-bytesnode scripts/check-control-bytes.mjs✅ check-control-bytes: OK (scanned 5536 tracked text file(s); skipped 85 binary)check:readme-exports(scoped to the touched package, via the gate's ownscan()override params — the full-repo run in this fresh worktree isPRECONDITION NOT MET, see below)scan(root, { packageDirs: ['packages/react'], readmes: ['packages/react/README.md'], floors: {} })8 self-imports judged (8 real, 0 wrong-path, 0 fabricated);useSettledSchema:realchangeset:check(fixed group + no-major)node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs✅ All workspace packages are in the changeset fixed group./✅ No changeset declares a major bump.pnpm exec eslint packages/react/src/hooks/useSettledSchema.ts packages/react/src/hooks/__tests__/useSettledSchema.test.ts packages/react/src/hooks/index.ts --format json@typescript-eslint/no-explicit-anywarnings (same class already present throughout the package — e.g.DataSource's own default type parameter;warnseverity,lint.ymlsets no--max-warnings)pnpm --filter @object-ui/react lint380 problems (0 errors, 380 warnings)— all pre-existingLint narrowing justification: ESLint here (
eslint.config.js) usestseslint.configs.recommended, notrecommendedTypeChecked— noparserOptions.projectanywhere in the config — so no rule is type-aware andno rule reads cross-file type information. A diff confined to 3 files cannot
move any other file's verdict, so narrowing the run to those 3 files, with
their exact population read from eslint's own
--format jsonoutput(
len(data) == 3), is a real measurement, not a shrunk one. The whole-packagerun above additionally confirms nothing else in
@object-ui/reactregressed.check:readme-exportsfull-repo run — PRECONDITION NOT MET, reported inthose words: this is a fresh worktree with only
@object-ui/reactand itsthree workspace dependencies built (
types,core,data-objectstack,i18n); the gate's own population census reportspackagesRead: found 6, floor is 25and calls that a collapse, because mostpackages here are pre-existing-
unbuiltin this worktree, unrelated to thisdiff. Building all 40 workspace packages locally to clear that floor is the
repo-wide farm scan
AGENTS.mdreserves for CI. What is measured, andgreen, is the package this diff actually touches:
react's own self-imports(including the new one) judged via the gate's own
scan()function with itsdocumented
packageDirs/readmes/floorsoverrides (the same mechanism itsfixture suite uses) —
0 fabricated,0 wrong-path,useSettledSchema:real.CI's full run supplies the whole-workspace verdict.
Generated by Claude Code