Skip to content

fix(types): memoise the two zod lazy getters that can be, pin why the other eight cannot (objectui#7918) - #8226

Merged
os-sam merged 1 commit into
mainfrom
claude/check-zod-lazy-getter-identity-7918
Sep 7, 2026
Merged

os-sam merged 1 commit into
mainfrom
claude/check-zod-lazy-getter-identity-7918

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #7918

The card asked for a check, not a fix: ten z.lazy exports of the zod node face rebuild their schema on every getter call, and the open question was whether that spelling is buying a temporal-dead-zone dodge. It is, for eight of the ten. Two are memoised here; the other eight are deliberately untouched, and the check turned up two corrections to the finding that are worth more than the edit.

The probe, reproduced, with its control

Run against the built barrel. The card's own positive control (a z.lazy whose getter returns a module-level constant) had to hit before any false counted as a reading, and a negative control proves the probe discriminates.

POSITIVE control (getter -> module-level const) : true    (must be true)
NEGATIVE control (getter builds fresh)          : false   (must be false)

ActionSchema  AppMenuItemSchema  FilterBuilderConditionSchema  FilterGroupSchema
MenuItemSchema  NavLinkSchema  NavigationItemSchema  NavigationMenuItemSchema
SchemaNodeSchema  TreeNodeSchema                    -> false, all ten

The card's claim holds exactly as written.

AppMenuItemSchema — the name triage could not locate

It exists, and the list really is ten. It has no declaration of its own: index.zod.ts:46 re-exports app.zod.ts's MenuItemSchema under that name, while the barrel's own MenuItemSchema is a different schema from overlay.zod.ts:186. Triage's sweep mapped MenuItemSchema to app.zod.ts:184 — that line is AppMenuItemSchema's source — and overlay.zod.ts:186 never surfaced, which is why one name looked missing. Verified by reference: AppMenuItemSchema === app.zod.MenuItemSchema, MenuItemSchema === overlay.zod.MenuItemSchema, and the two are not each other.

The TDZ check — measured one schema at a time

Each of the ten was rewritten in place to the card's own naive memoisation (const inner = BODY; z.lazy(() => inner)), @object-ui/types rebuilt, and the built barrel imported in a fresh process. Each leg proved the mutation reached the source and dist before its result was read; the restore leg is git checkout HEAD -- ABSOLUTE_PATH with git diff HEAD empty and the marker gone from dist.

Export Recursive? Naive memoisation Verdict
ActionSchema self, direct (actions: z.array(ActionSchema)) ReferenceError: Cannot access 'ActionSchema' before initialization TDZ-bound — unchanged
AppMenuItemSchema self, direct ReferenceError: Cannot access 'MenuItemSchema' before initialization TDZ-bound — unchanged
FilterGroupSchema self, direct (in the conditions union) ReferenceError: Cannot access 'FilterGroupSchema' before initialization TDZ-bound — unchanged
MenuItemSchema self, direct ReferenceError: Cannot access 'MenuItemSchema' before initialization TDZ-bound — unchanged
NavLinkSchema self, direct ReferenceError: Cannot access 'NavLinkSchema' before initialization TDZ-bound — unchanged
NavigationMenuItemSchema self, direct ReferenceError: Cannot access 'NavigationMenuItemSchema' before initialization TDZ-bound — unchanged
SchemaNodeSchema not recursive; forward-names BaseSchemaCore ReferenceError: Cannot access 'BaseSchemaCore' before initialization TDZ-bound — unchanged
TreeNodeSchema self, direct ReferenceError: Cannot access 'TreeNodeSchema' before initialization TDZ-bound — unchanged
FilterBuilderConditionSchema no recursion at all loads clean, identity stable memoised
NavigationItemSchema self, but already deferred by the inner z.lazy(() => NavigationItemSchema) on children loads clean, identity stable memoised

None of the ten is mutually recursive. FilterGroupSchema names FilterBuilderConditionSchema, but not the reverse; the import graph across the six files is a clean DAG onto base.zod.ts.

⇒ For eight, the z.lazy is load-bearing — it buys a TDZ dodge, exactly as the card suspected. They keep the spelling they have. The two changed use the shape the face's other z.lazy sites already use, a getter returning a module-level constant; no third spelling is introduced.

Correction 1 — the recursion point was already comparable, through the right handle

zod@4.4.3 caches a lazy's resolved inner type on def._cachedInner, and its own source comment says why: to preserve "identity for cycle detection on recursive schemas". _zod.innerType reads that cache and is stable for all ten, including the eight, and survives .describe() clones.

What is unstable is the public accessor, because ZodLazy defines it as inst.unwrap = () => inst._zod.def.getter() — straight around the cache. (ZodPromise spells its own as a stored field, which is why this is specific to lazy.)

Handle Kind The eight The two
S._zod.def.getter() internal unstable stable
S.unwrap() public unstable stable
S._zod.innerType internal, zod-cached stable stable

⇒ A walker can recognise the recursion point today, for all ten, via _zod.innerType. The #7581 false negative — ActionSchema reported "not exported by name" when it plainly is — was the wrong handle, not an unrecognisable schema. Memoising is still worth doing where it is free, because it makes the public .unwrap() honest.

Correction 2 — consequence ② does not reproduce

The card recorded, explicitly unmeasured, that a document with N nodes reconstructs the recursive sub-schema N times. Measured, it does not: the getter runs once per lazy for the life of the process, via the same _cachedInner — one call during the first parse of a 13-node document, zero during the second.

Wall clock agrees. NavigationItemSchema over a 73-node document, memoised vs. not, medians of nine trials of 200 parses each:

memoised   149,973 ns/parse   (min 143,913  max 153,846)
baseline   140,293 ns/parse   (min 137,266  max 149,399)
baseline / memoised = 0.94x

⚠️ Shared-box seconds — the verify lock excludes other locked runs, not unlocked sibling work — so the absolutes are not idle-machine figures. The ratio is the reading, the ranges overlap, and the reading is no difference. ⛔ No performance win is claimed here, and whoever prices the strict face (#7935 / objectstack#5250) should strike consequence ② from the input list rather than budget for it.

Verification

Union re-run at e496bdcda, the final commit:

  • pnpm --filter @object-ui/types build — ✓ dist completeness: 1 package(s) complete (124 emitted files verified)
  • pnpm exec vitest run --project unit packages/types/ — 137 files, 2588 tests passed (2576 before; the 12 new are this card's). All 137 test files in the package ran, zod-mirror-parity.test.ts among them.
  • pnpm --filter @object-ui/types type-check — clean (tsc --noEmit, tsconfig.examples.json, tsconfig.test.json). The new test is confirmed in the tsconfig.test.json program via --listFiles, so its @ts-expect-error was really checked.
  • pnpm --filter @object-ui/types lint — 0 errors, 272 warnings, all pre-existing no-explicit-any on z.ZodType any-parameter annotations this PR does not add to (count unchanged vs. base: app.zod.ts 3, complex.zod.ts 2).
  • Gates: check:control-bytes, check:spec-symbols, check:side-effects-array, check:unreferenced-sources, check:esm-specifiers — all exit 0.

Repo-scale scans (pnpm lint over the whole tree, check:eager-closure, which needs a console bundle) are left to CI, which runs the farm exactly once regardless.

Scope

Left open deliberately

The eight could be memoised by hoisting each body to a module const and pushing the self-reference behind an inner z.lazy(() => X) — the shape NavigationItemSchema.children already uses. Not taken here: it trades one identity for another, since children is currently z.array(TreeNodeSchema), whose element is the exported schema, and the rewrite replaces that element with a fresh anonymous wrapper. With consequence ② disproved and _zod.innerType already stable, the only remaining prize is public .unwrap() identity. That is a maintainer call, not a cleanup, and it is recorded in the test file's header rather than acted on.


Generated by Claude Code

… other eight cannot (objectui#7918)

Ten `z.lazy` exports of the zod node face rebuild their schema on every getter
call, so `S._zod.def.getter() !== S._zod.def.getter()`. The card did not claim
that was wrong — it asked whether the spelling was dodging a temporal dead zone,
and that check is what this commit carries.

Each of the ten was rewritten in place to `const inner = <body>; z.lazy(() =>
inner)`, the package rebuilt, and the built barrel imported in a fresh process.
Eight refuse to load: seven name the very const being declared, and
`SchemaNodeSchema` names `BaseSchemaCore`, which `base.zod.ts` declares below it.
Their `z.lazy` is load-bearing and they keep the spelling they have. The two that
loaded clean are memoised here, using the shape the face's other `z.lazy` sites
already use — a getter returning a module-level constant:

  FilterBuilderConditionSchema  not recursive at all
  NavigationItemSchema          self-reference already deferred by the inner
                                `z.lazy(() => NavigationItemSchema)` on `children`

Two corrections to the finding came out of the check, both measured:

- The recursion point was already identity-comparable through the right handle.
  zod 4.4.3 caches a lazy's inner type on `def._cachedInner` to preserve
  "identity for cycle detection on recursive schemas", and `_zod.innerType`
  reads that cache — stable for all ten, including the eight. What is unstable
  is the public `.unwrap()`, which `ZodLazy` defines as `() =>
  _zod.def.getter()`, going around the cache. Memoising is still worth doing
  where it is free, because it makes `.unwrap()` honest.

- The "rebuilt on every parse" cost does not exist. The getter runs once per
  lazy for the life of the process: one call during the first parse of a
  13-node document, zero during the second. Wall clock agrees — 0.94x with
  overlapping ranges over nine trials of 200 parses.

No accept/reject behaviour moves; a memoised getter changes schema identity, not
what is declared or admitted. The measurement, the eight ReferenceError
messages, the identity matrix and executable reproductions of both the TDZ
mechanism and the once-per-process getter are pinned in
`zod-lazy-getter-identity-7918.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3189.7 KB 3191.4 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-hEe9Dmno.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) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
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) 498.00KB 113.91KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 187.85KB 52.13KB
fields (index.js) 243.04KB 61.36KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.99KB 0.87KB
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 (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
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) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 47.67KB 13.25KB
plugin-charts (index.js) 70.62KB 19.71KB
plugin-chatbot (index.js) 193.54KB 46.04KB
plugin-dashboard (index.js) 131.41KB 34.43KB
plugin-designer (index.js) 211.51KB 43.01KB
plugin-detail (index.js) 247.68KB 63.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 52.83KB 14.63KB
plugin-list (index.js) 113.35KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
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) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
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 (expression.js) 0.20KB 0.18KB
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.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
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) 8.11KB 3.32KB
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

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.

finding(types): ten z.lazy exports in the zod node face rebuild their schema on every getter call, so the recursion point cannot be compared by identity

1 participant