Skip to content

feat(react): extract the settled-schema resolution hook - #6690

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6482-settled-schema-hook
Aug 28, 2026
Merged

feat(react): extract the settled-schema resolution hook#6690
os-sales merged 1 commit into
mainfrom
claude/issue-6482-settled-schema-hook

Conversation

@os-sales

@os-sales os-sales commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6482

What this lands

Extracts the settled-schema RESOLUTION half — shared today across three hand
copies (ObjectKanban #6271, ObjectView #6419, ObjectCalendar #6453) — into a
new, 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.tsx is 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 from
the package entry (packages/react/src/index.tshooks/index.ts
useSettledSchema.ts):

  • useSettledSchema(key: string, dataSource: DataSource | null | undefined): SettledSchema — the hook. Generic over the definition type TDef (default unknown); DataSource here takes its own default type parameter.
  • SettledSchema — the hook's return type for that same TDef: { ready: boolean; def: TDef | null }.

An external consumer of @object-ui/react can now import { useSettledSchema } from '@object-ui/react'
and use it directly. Changeset scored minor (objectui's major stays pinned to
@objectstack's per scripts/check-changeset-no-major.mjs).

Reachability verified through the barrel and the exports map, not by an
export keyword in a module file — same TypeScript-checker query
(getExportsOfModule) scripts/check-readme-exports.mjs itself uses, run
against the built packages/react/dist/index.d.ts, with a positive control
(useDataRefresh, already published) resolved through the identical query:

control  present: true useDataRefresh
new hook present: true useSettledSchema
new type present: true SettledSchema
total export symbols at package entry: 299

AGENTS.md §3 still bars React from @object-ui/core ("No UI-lib deps. Logic
only.") — confirmed on this ref — which is why the hook lives in
@object-ui/react ("The Runtime … Bridges Core and Components"), not core.

The shape, and why the resolution/gate split falls where it does

// generic over TDef, default: unknown
function useSettledSchema(
  key: string,
  dataSource: DataSource | null | undefined,
): { ready: boolean; def: TDef | null }

Internally: one piece of state — useState over { key: string; def: TDef | null } | null, initialized to null,
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 query
open forever). ready and def are derived at render time:

const ready = resolution !== null && resolution.key === key;
const def = ready ? resolution.def : null;

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: ObjectCalendar keys on
dataConfig.object ?? schema.objectName (not schema.objectName) and gates only
its object-provider branch, because an inline value data set issues no
metadata 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 key and pass dataSource: undefined for a render
that shouldn't fetch, rather than the hook growing an enabled flag. The
useSettledSchema doc comment and the new packages/react/README.md "Hooks"
entry both spell out this composition with a worked ObjectCalendar-shaped
example.

Demonstrating #6481's defect is unwritable

#6481's root cause: ObjectTree carried the definition (objectSchema) and
"has it settled" (schemaSettled) as two separate useStates.
schemaSettled was a one-way latch — set true on first settle, never reset —
so when the host swapped objectName mid-life, the fetch effect began
refetching the new object's schema while schemaSettled stayed true from
the old object's settle. A gated effect reading schemaSettled === true +
the still-old objectSchema would query with the wrong $expand: a stale
key reading as ready.

useSettledSchema makes that shape unrepresentable rather than merely
avoiding it: there is no second, independently-settable boolean. ready is a
render-time comparison, resolution.key === key, against a single state
value — the instant key changes, that comparison is false in the same
render, 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.ts pins this
directly — 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, assert
ready === false and def === null synchronously, with no waitFor. A
sibling test additionally proves a late-arriving resolution for an
abandoned key can never clobber the current one (the abandoned effect's
isMounted closure is false by the time it resolves). 9/9 tests pass; see the
ablation below for proof the pin actually bites.

Ablation — prediction vs observation

Mutation (on-disk edit to packages/react/src/hooks/useSettledSchema.ts,
committed baseline 877944237 before mutating, restored from HEAD after):

- const ready = resolution !== null && resolution.key === key;
+ const ready = resolution !== null; // drops the key comparison

This reproduces ObjectTree's actual bug shape — a settle flag that doesn't
care which key it settled for.

Predicted before running: the objectui#6481 unwritable test and the
ABANDONED key test both go red — both assert ready === false in the render
right after a key switch, pre-settle.

Observed: only the objectui#6481 unwritable test went red
(expected true to be false); the ABANDONED key test stayed green. The
difference, on inspection: the ABANDONED key test switches keys before any
resolution ever settles
resolution is still null at that point, so
resolution !== null reads false under both the mutated and the
original code, and the mutation only diverges from correct behavior once a
prior key has already settled successfully (exactly ObjectTree's bug
shape: 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 -c of the exact line
(1 → 0 → mutated-marker present), not by any tool's exit code. Restoration was
proven by an empty git diff HEAD plus a byte-identical
git hash-object against the HEAD blob (1d89c21a1f26108a715dbe4b36eef22cffe21c12
both before and after). The mutate/restore script used a trap ... EXIT INT TERM
with 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).

Gate Command Verdict (gate's own printed line)
@object-ui/react vitest (full package scope) pnpm exec vitest run packages/react/ Test Files 62 passed (62) / Tests 916 passed (916)
@object-ui/react vitest (new file only) pnpm exec vitest run packages/react/src/hooks/__tests__/useSettledSchema.test.ts Test Files 1 passed (1) / Tests 9 passed (9)
@object-ui/react type-check pnpm --filter @object-ui/react type-check (tsc --noEmit && tsc -p tsconfig.test.json) exit 0, no diagnostics; confirmed via --listFiles that tsconfig.test.json includes both the new hook and its test file
check:control-bytes node 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 own scan() override params — the full-repo run in this fresh worktree is PRECONDITION 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:real
changeset: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.
lint (touched files, narrowed — see below) 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 3 files linted, 0 errors, 17 @typescript-eslint/no-explicit-any warnings (same class already present throughout the package — e.g. DataSource's own default type parameter; warn severity, lint.yml sets no --max-warnings)
lint (whole package, confirmation) pnpm --filter @object-ui/react lint exit 0, 380 problems (0 errors, 380 warnings) — all pre-existing

Lint narrowing justification: ESLint here (eslint.config.js) uses
tseslint.configs.recommended, not recommendedTypeChecked — no
parserOptions.project anywhere in the config — so no rule is type-aware and
no 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 json output
(len(data) == 3), is a real measurement, not a shrunk one. The whole-package
run above additionally confirms nothing else in @object-ui/react regressed.

check:readme-exports full-repo run — PRECONDITION NOT MET, reported in
those words:
this is a fresh worktree with only @object-ui/react and its
three workspace dependencies built (types, core, data-objectstack,
i18n); the gate's own population census reports
packagesRead: found 6, floor is 25 and calls that a collapse, because most
packages here are pre-existing-unbuilt in this worktree, unrelated to this
diff. Building all 40 workspace packages locally to clear that floor is the
repo-wide farm scan AGENTS.md reserves for CI. What is measured, and
green, 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 its
documented packageDirs/readmes/floors overrides (the same mechanism its
fixture suite uses) — 0 fabricated, 0 wrong-path, useSettledSchema:real.
CI's full run supplies the whole-workspace verdict.


Generated by Claude Code

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
@github-actions github-actions Bot added documentation Improvements or additions to documentation configuration package: react tests labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3231.7 KB 3266.6 KB
Main entry chunk (gzip) 157.2 KB 350 KB
Entry file index-BvF8zTYm.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) 9.26KB 3.13KB
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 15:22
@os-sales
os-sales added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit dba7d84 Aug 28, 2026
30 checks passed
@os-sales
os-sales deleted the claude/issue-6482-settled-schema-hook branch August 28, 2026 15:44
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

configuration documentation Improvements or additions to documentation package: react tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(ui): the schema-settled $expand gate is now four hand copies, and four more views resolve the schema without gating on it

2 participants