Skip to content

perf(plugin-map): key ObjectMap's dataConfig memo on [schema], not a per-render JSON.stringify - #6266

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-6018-objectmap-dataconfig-memo
Aug 25, 2026
Merged

perf(plugin-map): key ObjectMap's dataConfig memo on [schema], not a per-render JSON.stringify#6266
yinlianghui merged 2 commits into
mainfrom
claude/issue-6018-objectmap-dataconfig-memo

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6018

getDataConfig(schema) was called bare in the render body and its result re-serialized with JSON.stringify on every render, only to hand back the object the memo already held:

constrawDataConfig=getDataConfig(schema);// Memoize dataConfig using deep comparison to prevent infinite loopsconstdataConfig=useMemo(()=>rawDataConfig,[JSON.stringify(rawDataConfig)]);

getDataConfig is a pure function of schema — it reads exactly data, staticData, objectName, nothing ambient — so useMemo(() => getDataConfig(schema), [schema]) gives the same stable identity the fetch effect needs, with no serialize and no per-render rebuild. That is the shape #5976 landed one line below for mapConfig; the two lines now agree, and mapConfig's docblock (which narrated the JSON.stringify dep key as a live neighbour) was updated so it does not describe a line that no longer exists.

The hazard the old comment recorded is real, and was checked rather than assumed

dataConfig is a dependency of the fetch effect and that effect calls setData, so a fresh identity there is a refetch loop rather than mere waste. The loop path is specifically a re-render ObjectMap causes itself (data landing, the object definition landing, search typing, zoom, selection, geolocation) — and on that path the schema prop is untouched by construction, so [schema] closes it. Pinned by two tests that drive exactly those renders.

Which assertions survive a revert (measured, not asserted)

Measured twice: once against pristine origin/main before any edit, and once as an explicit revert leg on the committed tree (mutation proven on disk by grepping the injected and the separately removed text; restored under trap … EXIT INT TERM with a cwd-independent git -C … checkout, git diff HEAD --stat confirmed empty afterwards).

packages/plugin-map/src/ObjectMap.dataConfigMemo.test.tsx, 4 tests — 3 survive a revert, 1 is red before the fix:

AssertionOn the serialize formOn [schema]
no re-fire on re-render with unchanged schemapasspass
no re-fire on a state-driven re-render (search typing)passpass
DOES re-fire against the new object when schema changespasspass
renders inline data the serializer cannot handleFAILpass

The three identity-contract tests passing on both forms is the honest result and is what the issue predicted: the stringify key really did buy a stable identity, so #6018 is a cost card, not a correctness card. Their value is as regression pins — they fail if this memo is ever re-keyed on something render-fresh again, which is precisely the #5976 defect one line up. They are also the "verify the fetch-effect identity contract against the existing tests" half of the ruling, discharged.

The fourth is the red direction, and it is a genuine correctness gain rather than a timing proxy. JSON.stringify is not a total function — it throws on a value it cannot serialize — and getDataConfig's passthrough branch returns the author's own schema.data object verbatim, inline value rows included. So a map handed inline records carrying a back-reference (an $expand-ed lookup) threw from the render body and took the whole subtree down:

TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
--- property 'parent' closes the circle

Comparing identities never serializes, so the config no longer has to be serializable at all. A BigInt id is the same class of input.

No assertion here counts renders as a proxy for speed. The observable is how many times the fetch effect fired, read at the module boundary it crosses (dataSource.find), and every stability assertion is paired with a counter-probe — "stable identity" is equally satisfiable by freezing a stale config forever, which would be the worse bug.

useMemo is not a semantic guarantee — and here something does depend on it

Stated explicitly because it is load-bearing: React may discard a useMemo cache, and if it discards this one the fetch effect sees a fresh dataConfig and re-fires. That is a genuine behaviour dependency, not merely a performance one — setData re-renders, which is what made "prevent infinite loops" the original comment.

This PR does not change that exposure: both the old and the new form are a useMemo, and the new one is strictly cheaper and no less safe. But it is not fixed here either. A form that does not depend on the cache holding would key the effect on the primitive values it actually reads (dataConfig.provider, dataConfig.object) instead of on an object identity at all — a different and larger change, out of scope for this card and deliberately not smuggled in.

Premise fork: is schema identity itself unstable at some call site?

Checked before applying the memo, because that is the reason someone reached for stringify in the first place. Measured with a throwaway probe component registered through the real SchemaRenderer path (probe deleted; not committed) — it recorded the schema identity actually reaching a leaf renderer across a parent re-render:

  • plain node → stable: true
  • node declaring responsiveStylesstable: false

So there is a render-fresh schema, and it is SchemaRenderer.tsx:1076:

constschemaForComponent=scopeClass
? { ...evaluatedSchema,className: mergedClassName}
: evaluatedSchema;

unmemoized, so any node carrying responsiveStyles (ADR-0065 scoped styling) gets a fresh object on every SchemaRenderer render. Reported rather than worked around, per the ruling — no identity hack was swapped in for the removed one. Three things bound it:

  1. It is not the loop path. It is fresh per parent render, not per ObjectMap render. setData re-renders ObjectMap only; SchemaRenderer does not re-render, so the identity holds across exactly the renders the loop would need. Bounded extra fetch, not an unbounded loop.
  2. It is not new exposure from this PR.mapConfig on the adjacent line has been keyed [schema] since ObjectMap's marker useMemo never memoizes — getMapConfig runs unmemoized in the render body, so mapConfig has a fresh identity every render #5976 and is subject to the identical instability, as is the whole marker cascade downstream of it.
  3. It is a larger card and belongs to packages/react, which is outside this card's collision bounds. Recorded in the structured report for the PM to file and grade.

The other two callers were re-verified stable on current main: useElementDataSourceSchema's mapped is a React.useMemo that returns schema by identity when there is no composed binding, and ListView's viewComponentSchema is a React.useMemo.

Verification

All run from the repo root (the canonical invocation — pnpm --filter … exec vitest and package-cwd runs hit the #3378 guard), and re-run on the final commit 8311dbb0e:

pnpm exec vitest run packages/plugin-map/src/ --maxWorkers=2
→ Test Files 16 passed (16) | Tests 92 passed (92)
pnpm --filter @object-ui/plugin-map type-check → exit 0
pnpm --filter @object-ui/plugin-map lint → 0 errors, 126 warnings (pre-existing house style)
node scripts/check-changeset-presence.mjs
→ ✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-control-bytes.mjs
→ ✅ check-control-bytes: OK (scanned 5173 tracked text file(s); skipped 85 binary)
node scripts/check-vi-mock-specifiers.mjs → ✅ OK
node scripts/check-changeset-no-major.mjs → ✅ No changeset declares a `major` bump

Dependency closure built first (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-map^...' build) so the type-check read rebuilt dist/*.d.ts rather than stale siblings.

Repo-wide lint across the farm is left to CI. The local run was narrowed to the changed package and the narrowing is measured, not assumed: the file population came from eslint's own config resolution (eslint . in packages/plugin-map → 23 files, counted from --format json), and eslint.config.js declares no project / projectService, so type-aware linting is off and every rule is single-file AST — this diff cannot move the verdict on any file it does not touch.

Downstream consumers (prefix form, pnpm --filter '...@object-ui/plugin-map'): @object-ui/console, @object-ui/site, examples/schema-catalog — all apps/examples. No exported surface moved; the change is a private render-body memo.

Scope

packages/plugin-map only, plus the changeset. #5977 (ObjectMap.listViewMapConfigReach.test.tsx stale narration) is open in the same area and is not addressed here — it is a different file and was not touched; no overlap to report.


Generated by Claude Code

…per-render JSON.stringify
`getDataConfig(schema)` was called bare in the render body and its result
re-serialized with `JSON.stringify` on every render, only to hand back the
object the memo already held. `getDataConfig` is a pure function of `schema`
(it reads exactly `data`, `staticData`, `objectName`), so `[schema]` gives the
same stable identity the fetch effect needs with no serialize and no per-render
rebuild — the shape #5976 landed one line below for `mapConfig`.
Dropping the serialize is also a correctness move. `JSON.stringify` throws on a
value it cannot serialize, and the passthrough branch returns the author's own
`schema.data` verbatim, inline rows included — so a record graph with a
back-reference took the whole map subtree down from the render body.
Part of #6018
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3223.0 KB3266.6 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-CVuKnteP.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.33KB42.81KB
plugin-detail (index.js)244.74KB62.20KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.39KB30.80KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ObjectMap's dataConfig pays a JSON.stringify on every render to buy an identity that [schema] already gives for free

2 participants

@yinlianghui@claude