Repository navigation
ObjectMap's dataConfig pays a JSON.stringify on every render to buy an identity that [schema] already gives for free #6018
Description
Activity
Concentrated triage batch:
finding→pm:queue+domain:ui, Task, S —ObjectMappaysJSON.stringify(rawDataConfig)on every render (plus a baregetDataConfigcall in the render body) to buy an identityuseMemo(() => getDataConfig(schema), [schema])gives for free. Apply the memo shape, verify the fetch-effect identity contract with the existing tests; ifschemaidentity is itself unstable at some call site (the reason stringify was reached for), measure and report that instead of keeping the stringify.
Generated by Claude Code
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Aug 25, 2026 Claimed + dispatched —
domain:ui@ objectui execution seatPM session
session_01CSoz9uGhaaSgiq3hshtN7L, branchclaude/issue-6018-objectmap-dataconfig-memo.
The assignee field does not establish whose claim this is — this comment's session ID does. No earlier claim comment exists.裁决 (binding)
ObjectMappaysJSON.stringify(rawDataConfig)on every render (plus a baregetDataConfigcall in the render body) to buy an identity thatuseMemo(() => getDataConfig(schema), [schema])gives for free. Apply the memo shape, and verify the fetch-effect identity contract against the existing tests.⛔ Premise fork — the important half
If
schemaidentity is itself unstable at some call site — which is the reason someone reached forstringifyin the first place — ⛔ measure and report that instead of keeping the stringify. Do not "fix" it by leaving the stringify in place, and do not silently swap one identity hack for another. An unstableschemaidentity is a different and larger card.So: before applying the memo, find out whether any caller passes a fresh
schemaobject per render. If one does, that is a stop-and-report.Traps
⚠️ A performance card's pin is the identity contract, not the timing. ⛔ Do not assert on render counts as a proxy for speed. The distinguishing assertion is that the fetch effect does NOT re-fire when the component re-renders with an unchangedschema, and DOES re-fire whenschemachanges — proven red before the fix on at least one of those directions.⚠️ useMemois not a semantic guarantee — React may discard the cache. If any behaviour depends on the memo holding rather than merely being faster, say so; the correct tool there is a ref or a stable key, notuseMemo.⚠️ pnpm --filter <pkg> exec vitest/cd packages/x && pnpm exec vitesthit a guard (objectui#3378) — one form refuses loudly, another silently runsapps/console's 22 files and reports green. Always run vitest from the repo root; sanity-check the file/test count.⚠️ tscresolves workspace deps throughdistwhilevitestaliases tosrc/— a type-check red can be unbuilt siblings, a real error, or both. Build the closure, then re-read.Standing rules
Predict direction before running; prove mutations on disk by grepping injected AND separately removed text; print the landing site; assert anchor uniqueness; restore under
trap … EXIT INT TERMwith a cwd-independent command and confirmgit diff HEAD --statempty. Derive gates by enumerating each CI job's own step list. Changeset: letcheck-changeset-presencedecide and quote its verdict line; ⛔ nevermajor.Collision bounds
packages/plugin-mapis yours alone. ⛔ #5977 (ObjectMap.listViewMapConfigReach.test.tsxstale narration) is open in the same area and is not yours — do not fold it in; report any same-file overlap. Eight sibling agents are live elsewhere — ⛔ do not editpackages/plugin-form/**,packages/components/**,packages/plugin-list/**,packages/plugin-detail/**,packages/types/**,packages/core/**,packages/plugin-kanban/**,packages/app-shell/src/utils/**,examples/schema-catalog/**,apps/site/**.⛔ Leave the PR draft — the PM lands it.
Generated by Claude Code
{ "issue": 6018, "status": "done", "branch": "claude/issue-6018-objectmap-dataconfig-memo", "pr": "https://github.com/objectstack-ai/objectui/pull/6266", "premise_still_valid": true, "summary": "Replaced ObjectMap's `const rawDataConfig = getDataConfig(schema)` + `useMemo(() => rawDataConfig, [JSON.stringify(rawDataConfig)])` with `useMemo(() => getDataConfig(schema), [schema])` — the shape #5976 landed one line below for `mapConfig`. `getDataConfig` is a pure function of `schema` (reads exactly `data`, `staticData`, `objectName`), so `[schema]` is the whole dependency. Also refreshed `mapConfig`'s docblock, which narrated the deleted `JSON.stringify` dep key as a live neighbour. PREMISE FORK ANSWERED: `schema` identity IS unstable at one call site — `SchemaRenderer.tsx:1076`'s unmemoized `schemaForComponent = { ...evaluatedSchema, className: mergedClassName }` hands a fresh object every parent render to any node declaring `responsiveStyles` (measured: stable=false vs stable=true for a plain node). Reported, not worked around — no identity hack was swapped in. It is bounded and is NOT the loop path: it is fresh per PARENT render, while the loop the old comment guarded needs freshness on ObjectMap's OWN re-renders (setData re-renders ObjectMap only; SchemaRenderer does not re-render), so `[schema]` closes the loop. It is also not new exposure from this PR — `mapConfig` has been keyed `[schema]` since #5976 under the identical instability. It belongs to `packages/react`, outside this card's collision bounds, and is a larger card.", "tests": "All from the repo root (canonical invocation; `pnpm --filter … exec vitest` and package-cwd runs hit the #3378 guard). Union re-run on final commit 8311dbb0e. (1) NEW `packages/plugin-map/src/ObjectMap.dataConfigMemo.test.tsx`, 4 tests, pinning the fetch-effect identity contract via `dataSource.find` call count — the module boundary the effect crosses — never render counts as a speed proxy. REVERSE VERIFICATION run twice: once against pristine origin/main before any edit, once as an explicit revert leg on the committed tree. Mutation proven on disk both directions (injected 'JSON.stringify(rawDataConfig)' = 1, removed 'getDataConfig(schema), [schema]' = 0), restored under `trap '<git -C … checkout>' EXIT INT TERM`, `git diff HEAD --stat` empty afterwards. PREDICTED before running: identity directions green on both forms, serializer direction red. OBSERVED, matching: 'Test Files 1 failed (1) | Tests 1 failed | 3 passed (4)' with `AssertionError: expected [Function] to not throw an error but 'TypeError: Converting circular structure to JSON … property 'parent' closes the circle' was thrown`. So 3 of 4 assertions SURVIVE A REVERT (regression pins against the memo ever being re-keyed on something render-fresh — the #5976 defect one line up; also the ruling's 'verify against the existing tests' half) and 1 is genuinely red before the fix. That red is a correctness gain, not a timing proxy: `JSON.stringify` throws on unserializable input and `getDataConfig`'s passthrough branch returns the author's own `schema.data` VERBATIM (inline rows included), so a back-referencing record graph from an `$expand`-ed lookup crashed the whole map subtree from the render body. (2) `pnpm exec vitest run packages/plugin-map/src/ --maxWorkers=2` → 'Test Files 16 passed (16) | Tests 92 passed (92)' — count sanity-checked as plugin-map scope, not apps/console's 22 files. (3) `pnpm --filter @object-ui/plugin-map type-check` → exit 0, script name echoed ('tsc --noEmit && tsc -p tsconfig.test.json') so not a zero-match silent pass; dependency closure built first via `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-map^...' build` (exit 0) so tsc read rebuilt dist/*.d.ts, not stale siblings. (4) `pnpm --filter @object-ui/plugin-map lint` → 0 errors, 126 warnings (pre-existing house style; lint.yml sets no --max-warnings). DECLARED NARROWING for repo-wide lint, with all three pieces of evidence: population came from eslint's own config resolution (`eslint .` in packages/plugin-map), file count read from `--format json` (23 files, 0 errors, 126 warnings), and `eslint.config.js` declares no `project`/`projectService` so type-aware linting is OFF — every rule is single-file AST, therefore this diff cannot move the verdict on any untouched file. Full farm left to CI. (5) Gates derived by enumerating each CI job's own step list: `node scripts/check-changeset-presence.mjs` → '✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/wild-donkeys-shake.md.'; `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` → '✅ check-vi-mock-specifiers: OK'; `node scripts/check-changeset-no-major.mjs` → '✅ No changeset declares a `major` bump.'; `node scripts/check-changeset-fixed.mjs` → '✅ All workspace packages are in the changeset fixed group.'. All exit codes captured before any pipe. 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.", "open_questions": [ { "question": "`useMemo` is not a semantic guarantee, and this memo is load-bearing rather than merely faster: React may discard the cache, at which point the fetch effect sees a fresh `dataConfig` and re-fires — and `setData` re-renders, which is exactly what made the original comment say 'prevent infinite loops'. This PR does not change that exposure (old and new form are both a `useMemo`, the new one strictly cheaper and no less safe), but it does not fix it either. Should it be fixed, and as its own card?", "options": [ "A — leave as is: both forms carry the identical exposure, React discarding a memo is rare, and no reported defect traces to it. Zero cost, keeps this card narrow.", "B — file a follow-up to key the fetch effect on the PRIMITIVES it actually reads (`dataConfig.provider`, `dataConfig.object`) rather than on an object identity at all, so no cache has to hold for correctness. The `useMemo` then becomes a pure optimisation, which is the only thing it is contractually allowed to be.", "C — swap the memo for a `useRef` / stable-key form that React cannot discard. Guarantees the identity, but keeps correctness resting on identity plumbing." ], "recommendation": "B, as a separate card. It is the only option that removes the dependency on the cache rather than reinforcing it, and it is contract-first: the effect declares the values it genuinely reads instead of a proxy object whose identity it must police. C is the identity hack the ruling explicitly forbade swapping in. A is acceptable in the interim and is what this PR ships, since the exposure is unchanged by it." } ], "out_of_scope_findings": [ "NOT FILED — the GitHub issue search/list API is rate-limited for this identity right now ('API rate limit already exceeded for user ID 6219465'), and the standing rule is to search before filing, so I did not file blind. Handing it to the PM to file, fully specified: `SchemaRenderer` hands a render-fresh `schema` object to any node declaring `responsiveStyles`, defeating every downstream `[schema]` memo. `packages/react/src/SchemaRenderer.tsx:1076` — `const schemaForComponent = scopeClass ? { ...evaluatedSchema, className: mergedClassName } : evaluatedSchema;` — is not memoized, so for a node with ADR-0065 scoped styles it allocates a new object on every SchemaRenderer render even when the `evaluatedSchema` memo held. MEASURED with a throwaway probe component registered through the real SchemaRenderer path (probe deleted, not committed): plain node → stable=true; node with `responsiveStyles: { large: {...} }` → stable=false. Note `hasResponsiveStyles` requires a `large`/`medium`/`small`/`xsmall` key, so a `{ base: … }` shape does NOT trigger it. Blast radius is every renderer memoizing on `[schema]`, which now includes ObjectMap's `dataConfig` and `mapConfig` plus the whole marker cascade downstream (`markers` → `filteredMarkers` → `clusteredData`/`markerBounds` → `initialViewState`). Bounded, not a loop: fresh per PARENT render only, and ObjectMap's own setState does not re-render SchemaRenderer. Likely fix is memoizing `schemaForComponent` on `[evaluatedSchema, mergedClassName]`. Belongs to `packages/react` — outside this card's collision bounds, and a larger card as the dispatch anticipated. Suggested labels: `finding`, `domain:ui`." ] }
Generated by Claude Code
os-dev-report
(Supersedes the previous comment — the GitHub body sanitizer ate its leading HTML-comment marker on write, which would make it invisible to the PM's scan. Same report, marker as literal text, and the one angle-bracket fragment inside
testsrewritten so it survives too.){ "issue": 6018, "status": "done", "branch": "claude/issue-6018-objectmap-dataconfig-memo", "pr": "https://github.com/objectstack-ai/objectui/pull/6266", "premise_still_valid": true, "summary": "Replaced ObjectMap's `const rawDataConfig = getDataConfig(schema)` + `useMemo(() => rawDataConfig, [JSON.stringify(rawDataConfig)])` with `useMemo(() => getDataConfig(schema), [schema])` — the shape #5976 landed one line below for `mapConfig`. `getDataConfig` is a pure function of `schema` (reads exactly `data`, `staticData`, `objectName`), so `[schema]` is the whole dependency. Also refreshed `mapConfig`'s docblock, which narrated the deleted `JSON.stringify` dep key as a live neighbour. PREMISE FORK ANSWERED: `schema` identity IS unstable at one call site — `SchemaRenderer.tsx:1076`'s unmemoized `schemaForComponent = { ...evaluatedSchema, className: mergedClassName }` hands a fresh object every parent render to any node declaring `responsiveStyles` (measured: stable=false vs stable=true for a plain node). Reported, not worked around — no identity hack was swapped in. It is bounded and is NOT the loop path: it is fresh per PARENT render, while the loop the old comment guarded needs freshness on ObjectMap's OWN re-renders (setData re-renders ObjectMap only; SchemaRenderer does not re-render), so `[schema]` closes the loop. It is also not new exposure from this PR — `mapConfig` has been keyed `[schema]` since #5976 under the identical instability. It belongs to `packages/react`, outside this card's collision bounds, and is a larger card.", "tests": "All from the repo root (canonical invocation; `pnpm --filter … exec vitest` and package-cwd runs hit the #3378 guard). Union re-run on final commit 8311dbb0e. (1) NEW `packages/plugin-map/src/ObjectMap.dataConfigMemo.test.tsx`, 4 tests, pinning the fetch-effect identity contract via `dataSource.find` call count — the module boundary the effect crosses — never render counts as a speed proxy. REVERSE VERIFICATION run twice: once against pristine origin/main before any edit, once as an explicit revert leg on the committed tree. Mutation proven on disk both directions (injected 'JSON.stringify(rawDataConfig)' = 1, removed 'getDataConfig(schema), [schema]' = 0), restored under a `trap ... EXIT INT TERM` whose restore command was the cwd-independent `git -C /home/user/objectui-6018 checkout HEAD -- packages/plugin-map/src/ObjectMap.tsx`, with `git diff HEAD --stat` confirmed empty afterwards. PREDICTED before running: identity directions green on both forms, serializer direction red. OBSERVED, matching: 'Test Files 1 failed (1) | Tests 1 failed | 3 passed (4)' with `AssertionError: expected [Function] to not throw an error but 'TypeError: Converting circular structure to JSON … property 'parent' closes the circle' was thrown`. So 3 of 4 assertions SURVIVE A REVERT (regression pins against the memo ever being re-keyed on something render-fresh — the #5976 defect one line up; also the ruling's 'verify against the existing tests' half) and 1 is genuinely red before the fix. That red is a correctness gain, not a timing proxy: `JSON.stringify` throws on unserializable input and `getDataConfig`'s passthrough branch returns the author's own `schema.data` VERBATIM (inline rows included), so a back-referencing record graph from an `$expand`-ed lookup crashed the whole map subtree from the render body. (2) `pnpm exec vitest run packages/plugin-map/src/ --maxWorkers=2` → 'Test Files 16 passed (16) | Tests 92 passed (92)' — count sanity-checked as plugin-map scope, not apps/console's 22 files. (3) `pnpm --filter @object-ui/plugin-map type-check` → exit 0, script name echoed ('tsc --noEmit && tsc -p tsconfig.test.json') so not a zero-match silent pass; dependency closure built first via `pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-map^...' build` (exit 0) so tsc read rebuilt dist/*.d.ts, not stale siblings. (4) `pnpm --filter @object-ui/plugin-map lint` → 0 errors, 126 warnings (pre-existing house style; lint.yml sets no --max-warnings). DECLARED NARROWING for repo-wide lint, with all three pieces of evidence: population came from eslint's own config resolution (`eslint .` in packages/plugin-map), file count read from `--format json` (23 files, 0 errors, 126 warnings), and `eslint.config.js` declares no `project`/`projectService` so type-aware linting is OFF — every rule is single-file AST, therefore this diff cannot move the verdict on any untouched file. Full farm left to CI. (5) Gates derived by enumerating each CI job's own step list: `node scripts/check-changeset-presence.mjs` → '✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/wild-donkeys-shake.md.'; `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` → '✅ check-vi-mock-specifiers: OK'; `node scripts/check-changeset-no-major.mjs` → '✅ No changeset declares a `major` bump.'; `node scripts/check-changeset-fixed.mjs` → '✅ All workspace packages are in the changeset fixed group.'. All exit codes captured before any pipe. 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.", "open_questions": [ { "question": "`useMemo` is not a semantic guarantee, and this memo is load-bearing rather than merely faster: React may discard the cache, at which point the fetch effect sees a fresh `dataConfig` and re-fires — and `setData` re-renders, which is exactly what made the original comment say 'prevent infinite loops'. This PR does not change that exposure (old and new form are both a `useMemo`, the new one strictly cheaper and no less safe), but it does not fix it either. Should it be fixed, and as its own card?", "options": [ "A — leave as is: both forms carry the identical exposure, React discarding a memo is rare, and no reported defect traces to it. Zero cost, keeps this card narrow.", "B — file a follow-up to key the fetch effect on the PRIMITIVES it actually reads (`dataConfig.provider`, `dataConfig.object`) rather than on an object identity at all, so no cache has to hold for correctness. The `useMemo` then becomes a pure optimisation, which is the only thing it is contractually allowed to be.", "C — swap the memo for a `useRef` / stable-key form that React cannot discard. Guarantees the identity, but keeps correctness resting on identity plumbing." ], "recommendation": "B, as a separate card. It is the only option that removes the dependency on the cache rather than reinforcing it, and it is contract-first: the effect declares the values it genuinely reads instead of a proxy object whose identity it must police. C is the identity hack the ruling explicitly forbade swapping in. A is acceptable in the interim and is what this PR ships, since the exposure is unchanged by it." } ], "out_of_scope_findings": [ "NOT FILED — the GitHub issue search/list API is rate-limited for this identity right now ('API rate limit already exceeded for user ID 6219465'), and the standing rule is to search before filing, so I did not file blind. Handing it to the PM to file, fully specified: `SchemaRenderer` hands a render-fresh `schema` object to any node declaring `responsiveStyles`, defeating every downstream `[schema]` memo. `packages/react/src/SchemaRenderer.tsx:1076` — `const schemaForComponent = scopeClass ? { ...evaluatedSchema, className: mergedClassName } : evaluatedSchema;` — is not memoized, so for a node with ADR-0065 scoped styles it allocates a new object on every SchemaRenderer render even when the `evaluatedSchema` memo held. MEASURED with a throwaway probe component registered through the real SchemaRenderer path (probe deleted, not committed): plain node → stable=true; node with `responsiveStyles: { large: {...} }` → stable=false. Note `hasResponsiveStyles` requires a `large`/`medium`/`small`/`xsmall` key, so a `{ base: … }` shape does NOT trigger it. Blast radius is every renderer memoizing on `[schema]`, which now includes ObjectMap's `dataConfig` and `mapConfig` plus the whole marker cascade downstream (`markers` → `filteredMarkers` → `clusteredData`/`markerBounds` → `initialViewState`). Bounded, not a loop: fresh per PARENT render only, and ObjectMap's own setState does not re-render SchemaRenderer. Likely fix is memoizing `schemaForComponent` on `[evaluatedSchema, mergedClassName]`. Belongs to `packages/react` — outside this card's collision bounds, and a larger card as the dispatch anticipated. Suggested labels: `finding`, `domain:ui`." ] }
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026
Measured while implementing #5976 (PR #6016). Out of that card's scope — filing plainly for triage to grade.
What was measured
packages/plugin-map/src/ObjectMap.tsx:588-592, the line immediately above the one #5976 fixed:getDataConfig(schema)is called bare in the render body — structurally the same shape as the #5976 defect. The difference is where the cost lands:dataConfigdoes end up with a stable identity, so this is not the same bug. It buys that stability by runningJSON.stringifyover the config on every render, plus a freshgetDataConfigcall to feed it.The reason that mattered is real:
dataConfigis a dependency of the fetch effect, which callssetData— a fresh identity there is a re-render loop, not just waste. That is what the "prevent infinite loops" comment records.Why the serialize now looks unnecessary rather than merely costly
#5976 established, by tracing all three callers, that the
schemaidentity reaching this component is already stable across the renders that matter:SchemaRendererhands over the memoizedevaluatedSchema(SchemaRenderer.tsx:516);ElementDataSourceGatehands overmapped, aReact.useMemoinuseElementDataSourceSchema;ListView'scase 'map'flatten hands overviewComponentSchema, aReact.useMemo(ListView.tsx:2007);ObjectMapcauses itself leaves the prop untouched by construction.getDataConfigis a pure function ofschemaand reads nothing else, soappears to give the same stable identity the effect needs, with no serialize and no per-render rebuild. That is the shape #5976's fix landed for
mapConfig, and its identity test now pins it.What has NOT been measured, and would need to be before acting
ObjectMap's markeruseMemonever memoizes —getMapConfigruns unmemoized in the render body, somapConfighas a fresh identity every render #5976's pin covers the marker pipeline, not the fetch effect.JSON.stringifyis currently masking a false-equal. It is key-order sensitive and dropsundefinedvalues, so two semantically different configs can produce the same key (stale — actually wrong, not merely wasteful). I did not find a reachable path:getDataConfigbuilds its objects with literal key order in every branch, and the one passthrough branch returns the author's ownschema.dataobject, whose key order is fixed per object. Recorded as "not found", not as "cannot happen".Scope note
Not folded into PR #6016. That PR's claim is narrow —
mapConfig's identity — and it deliberately leaves:590untouched and says so, because this is a different value with a different consumer (an effect, not a derived-value memo) and a genuine loop hazard in its history. Changing it needs its own measurement of that effect.Unassigned; filed plainly for triage to grade.
Generated by Claude Code