Skip to content

fix(plugin-map): memoize ObjectMap's mapConfig so the marker useMemo actually memoizes - #6016

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-5976-objectmap-config-memo
Aug 24, 2026
Merged

fix(plugin-map): memoize ObjectMap's mapConfig so the marker useMemo actually memoizes#6016
yinlianghui merged 2 commits into
mainfrom
claude/issue-5976-objectmap-config-memo

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5976

What was wrong

getMapConfig(schema) was called bare in ObjectMap's render body, so mapConfig carried a fresh object identity on every render. The marker transform names mapConfig in its dependency array ([data, mapConfig, objectSchema]), so that useMemo recomputed on every single render while declaring that it does not — a useMemo in spelling only. Every pass walked all records through extractCoordinates and the display-name resolver and re-ran ObjectMapConfigSchema.safeParse, and the invalidation cascaded on into filteredMarkersclusteredData / markerBoundsinitialViewState.

Measured, not inferred: this is failure mode (b) — the builder called outside the memo — not a fresh-identity value inside an otherwise-sound dep array. The memo's own dep list is fine; its input was not.

Re-measured on current main, after #5953 merged

#5953 (PR #5975) rewrote this exact region under the card, so the quoted code was re-derived rather than trusted. Delta against the card body:

  • marker titles now resolve through getRecordDisplayName(objectSchema, record, { titleField, fallback }), not record[titleField];
  • the || 'name' fallback is gone from bothgetMapConfig branches;
  • objectSchema is now in the marker memo's deps — [data, mapConfig, objectSchema], not the card's [data, mapConfig].

The defect itself is unchanged.ObjectMap.tsx:594 still read const mapConfig = getMapConfig(schema);, and the extra objectSchema dep neither causes nor worsens it.

The fix, and why it is not the neighbouring idiom

constmapConfig=useMemo(()=>getMapConfig(schema),[schema]);

[schema] is the entire dependency, not a shorthand for one: getMapConfig is a pure function of schema and reads nothing else.

No JSON.stringify deep-compare key was needed, because the identity reaching this component is already stable across the renders that matter. Every re-render ObjectMap causes itself — data landing, the object definition landing, search typing, zoom, selection, geolocation — leaves the schema prop untouched by construction, and each caller upstream hands over a memoized node:

hopvaluememoized?
SchemaRendererschemaForComponent = evaluatedSchemauseMemo, SchemaRenderer.tsx:516
ObjectMapRendererElementDataSourceGatebound.schema = mappedReact.useMemo, useElementDataSourceSchema
ListViewcase 'map' flattenviewComponentSchemaReact.useMemo, ListView.tsx:2007

Two further reasons the serialize would have been the wrong trade here, beyond its per-render cost: JSON.stringifydrops undefined values, and an absenttitleField is load-bearing in this config since #5953 — it must never compare equal to a present one. Identity is sufficient; nothing on this path needs value equality.

The sibling dataConfig at :590 keeps its JSON.stringify key and is untouched — it is not the same defect (it does memoize) and changing it is a separate question.

Verification

New pin: packages/plugin-map/src/ObjectMap.configMemo.test.tsx (5 tests). It measures identity, at the two module boundaries the component actually crosses — getRecordDisplayName call count (a direct read of how many times the marker memo evaluated) and the initialViewState object handed to MapGL (the tail of the cascade, so a toBe on it pins the whole chain).

Red before, green after. Direction predicted before running; on unmodified main the three stability pins failed and the two counter-probes passed — Tests 3 failed | 2 passed (5), exit 1. One failure reads:

AssertionError: expected { bounds: [ …(2) ], …(1) } to be { bounds: [ …(2) ], …(1) } // Object.is equality
Received: serializes to the same string
Compared values have no visual difference.

— deep-equal, identity-fresh: the defect's exact signature.

Reverse-verification. Reverting the memo to the bare call reproduced that split exactly — Tests 3 failed | 2 passed (5), exit 1. Mutation proved on disk by grepping the injected text and separately the removed text; restore ran under trap … EXIT INT TERM, and git diff HEAD --stat was empty afterwards. No rebuild was involved: the root vitest config aliases @object-ui/* to packages/*/src and the mutated file is the test's own relative import, so nothing resolved through dist/.

Counter-probes (without these, "stable identity" is also satisfiable by freezing a stale config forever — a worse bug, invisible to the positive assertion alone). Both assert a new identity with a visible consequence:

  • changing the declared titleField re-resolves the titles and the new binding reaches the rendered popup (Harbour DepotHD-01);
  • changing the declared center/zoom rebuilds the camera and it moves to the newly declared position.

Gates

gateresult
pnpm --filter @object-ui/plugin-map type-checkexit 0 — pnpm echoed > @object-ui/plugin-map@17.6.0 type-check / > tsc --noEmit && tsc -p tsconfig.test.json, so this is not a zero-match silent pass
pnpm exec vitest run packages/plugin-map/src (root form)exit 0Test Files 15 passed (15), Tests 88 passed (88)
pnpm exec eslint --no-inline-config on changed filesexit 0 — see delta below
node scripts/check-changeset-presence.mjsexit 0✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)

All four run on the final commit 0f754a839.

eslint delta against merge-base c0091b82b:ObjectMap.tsx is unchanged — 0 errors / 15 warnings before and after, with an identical rule breakdown (no-explicit-any 12, react-hooks/exhaustive-deps 2, react-hooks/use-memo 1). The baseline was measured by checking out the merge-base copy of the file under a restoring trap and linting it. The new test file adds 4 warnings, all @typescript-eslint/no-explicit-any on the react-map-gl mock props — the established idiom in this directory, and fewer than its siblings carry (ObjectMap.markerTitle.test.tsx 7, ObjectMap.schemaDataShorthand.test.tsx 9). No new rule class, no errors.

Notes

  • Downstream consumers keying on the identity (the harm triage priced) are all derived-value memos, not effects: filteredMarkers, clusteredData (which calls clusterMarkers), markerBounds (which calls computeMarkerBounds), selectedMarker and initialViewState. No useEffect re-fired — neither of the component's two effects names mapConfig or markers. So the blast radius is wasted per-render computation cascading through the whole marker pipeline, which is real, but it is narrower than "re-fire effects per render" would suggest. Recorded as the dispatch asked, not as a reason to skip the fix.
  • react-hooks/exhaustive-deps is active in this repo but only at warn, and there is no rule that would catch a builder called outside a memo at all. Nothing mechanical would have caught this, and nothing mechanical would catch its regression — the new identity test is what makes the declared intent stay true.
  • ObjectMap.listViewMapConfigReach.test.tsx:138 narrates a getMapConfig literal in the present tense that no longer exists #5977 is untouched, as instructed. Nothing here falsifies or repairs what it narrates: it is about stale prose above an arm of ObjectMap.listViewMapConfigReach.test.tsx, and that file is not modified.

⛔ Draft on purpose — not marked ready, not enqueued, auto-merge not enabled. The PM lands it.


Generated by Claude Code

…actually memoizes
getMapConfig(schema) ran unmemoized in the render body, giving mapConfig a fresh
object identity every render. The marker transform names it in its dependency
array, so that useMemo recomputed on every render while declaring that it does
not, cascading through filteredMarkers / clusteredData / markerBounds /
initialViewState.
Memoized on [schema] — the single value getMapConfig reads — rather than on a
JSON.stringify deep-compare key: the schema identity reaching this component is
already stable across the renders that matter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
`at` is outside this package's configured `lib` target, so `tsc -p
tsconfig.test.json` rejected it (TS2550).
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)3233.0 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-DHqG8_dO.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.23KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.88KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
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)8.50KB2.88KB
plugin-view (index.js)84.57KB20.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)52.40KB17.45KB
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 marker useMemo never memoizes — getMapConfig runs unmemoized in the render body, so mapConfig has a fresh identity every render

2 participants

@yinlianghui@claude