Skip to content

refactor(charts): drop ObjectChart's fieldOptionLabel ref workaround - #5628

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-5587-objectchart-ref-workaround
Aug 21, 2026
Merged

refactor(charts): drop ObjectChart's fieldOptionLabel ref workaround#5628
os-sales merged 2 commits into
mainfrom
claude/issue-5587-objectchart-ref-workaround

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5587

All measurements below were taken at 337f55d8, the head of this branch.

What changed

packages/plugin-charts/src/ObjectChart.tsx held its fieldOptionLabel resolver behind a ref. That ref is gone; fetchData now depends on the resolver directly.

  • deleted the comment + fieldOptionLabelRef + the useEffect keeping it current
  • line 650's fieldOptionLabelRef.current(...)fieldOptionLabel(...)
  • added fieldOptionLabel to fetchData's useCallback dep array (the pre-existing exhaustive-deps disable stays — it covers other, older omissions on that list, which this PR does not touch)
  • dropped useRef from the React import; it was there for this ref alone
  • new pin test + changeset

The block that came out, verbatim (ObjectChart.tsx:269-275; line 268, the hook call, stays):

const{ fieldOptionLabel }=useSafeFieldLabel();// Keep a stable ref to fieldOptionLabel — the i18n hook returns a fresh// function reference on every render, which would otherwise invalidate// fetchData's useCallback identity and trigger an infinite refetch loop.constfieldOptionLabelRef=useRef(fieldOptionLabel);useEffect(()=>{fieldOptionLabelRef.current=fieldOptionLabel;},[fieldOptionLabel]);

Its one read, at line 650, and what replaced it:

-(value,fallback)=>fieldOptionLabelRef.current(schema.objectName,groupByField,value,fallback),+(value,fallback)=>fieldOptionLabel(schema.objectName,groupByField,value,fallback),

and the dependency that was hidden behind it is now declared:

-},[schema.objectName,datasetKey,aggregateKey,filterKey,compareToKey,schema.xAxisKey,schema.chartType,runAggregate,filterScope]);+},[schema.objectName,datasetKey,aggregateKey,filterKey,compareToKey,schema.xAxisKey,schema.chartType,runAggregate,filterScope,fieldOptionLabel]);

Verified line numbers

The card cited ObjectChart.tsx:268-271. Located by code against origin/main at 8c87f0583, the workaround is lines 269-275:

linecontent
268const { fieldOptionLabel } = useSafeFieldLabel(); — the hook call, kept
269-271the three-line "Keep a stable ref…" comment
272const fieldOptionLabelRef = useRef(fieldOptionLabel);
273-275the useEffect writing .current
650the single read: fieldOptionLabelRef.current(schema.objectName, groupByField, value, fallback)

So the card's range starts one line early (it includes the hook call, which stays) and stops four lines short of the useEffect it describes.

Why the precondition is gone — from PR #5585's diff, not from #5564 being closed

The workaround's own comment states the precondition: "the i18n hook returns a fresh function reference on every render". That is a claim about one memo, and PR #5585 (38a956877, an ancestor of this branch — confirmed with git merge-base --is-ancestor) is what falsified it.

useSafeFieldLabel() is useObjectLabel() ?? SAFE_FIELD_LABEL_FALLBACK, and useObjectLabel returns useMemo(() => {…}, [t, i18n]) (useObjectLabel.ts:116:675). fieldOptionLabel is a closure built inside that memo, so its identity is exactly the memo's identity.

Before #5585, useObjectLabel opened with const { t, i18n } = useObjectTranslation() and passed those straight into the dep list. useObjectTranslation delegates to react-i18next's useTranslation, which with no bound instance rebuilds its return value from a fresh {} every render. So i18n arrived with a new identity each render, the memo never held, and every closure on it — fieldOptionLabel included — was fresh each render. That is precisely the condition ObjectChart's comment describes.

After #5585, the same function reads (useObjectLabel.ts:108-110):

constbound=hasUsableI18nInstance(boundI18n);constt=bound ? boundT : NO_INSTANCE_T;consti18n=bound ? boundI18n : NO_INSTANCE_I18N;

NO_INSTANCE_T (:72) and NO_INSTANCE_I18N (:77) are module-level constants, so while nothing is bound both memo dependencies carry one identity for the life of the process and the memo holds. When an instance is bound they are the live values, which were already stable. Both paths, one identity — the precondition is gone.

This is asserted empirically rather than only read off the diff; see the ablation below.

The test that pins it

packages/plugin-charts/src/__tests__/ObjectChart.fieldOptionLabelRefetch.test.tsx counts fetches across forced re-renders, not rendered output — nothing renders wrong when the loop is present, the chart just refetches forever, so an output assertion would be green against the defect.

Two cases, one per identity path. Each mounts, waits for the first load, forces three more renders of the same tree, and asserts ds.aggregate and ds.getObjectSchema were each called exactly once. The only difference between them is the wrapper:

// no provider — the path objectui#5564 fixedconstseen=awaitcountFetchesAcrossRerenders((chart)=>chart);// inside a provider — the path that already worked, kept as a regression guardconstseen=awaitcountFetchesAcrossRerenders((chart)=>(<I18nProviderinstance={instance}persistLanguage={false}>{chart}</I18nProvider>));

The no-provider case runs first and asserts the raw option label (Won), because createI18n registers its instance as react-i18next's process-global; if one ever leaked into that case, the assertion fails rather than the case quietly becoming a second test of the bound path. The provider case asserts the localized label, so the two cases demonstrably exercise different paths.

Ablation — the test fails against the pre-#5585 code

With this PR's ObjectChart.tsx in place, packages/i18n/src/useObjectLabel.ts was reverted to 38a9568~1 and the same command re-run.

Mutation confirmed on disk before measuring, anchored on the text #5585 added and removed (an editor's exit code proves nothing):

hasUsableI18nInstance (added by #5585, expect 0): 0
NO_INSTANCE_T (added by #5585, expect 0): 0
pre-#5585 guard line (removed by #5585, expect 1): 1

Result:

× fetches once across re-renders with NO i18next provider
AssertionError: expected 2 to be 1
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)

alongside 4 × React Maximum update depth exceeded in the same log — the refetch loop, caught by React's own guard, which is why the count reads 2 rather than climbing without bound.

Direction, as observed rather than as predicted: only the no-provider case goes red. The with-provider case stays green, and that is correct — #5585 only changed the unbound path; the bound path's memo was already holding. So one case is the unlock pin and the other is a regression guard on the path that already worked.

No rebuild was needed for this ablation and none was skipped: vitest.config.mts aliases @object-ui/i18n to packages/i18n/src, so a root-launched run reads that source file directly rather than dist/. The ablation flipping red is itself the proof of that — a run reading dist/ would have stayed green. The restore leg ran from an EXIT INT TERM trap and is verified: git status clean, marker back (5 hits), and git diff --quiet origin/main -- packages/i18n/src/useObjectLabel.ts reports identical.

Over-delete sweep, with a same-scope control

check-action-forward-parity and gates like it derive their owed set from what the runtime reads, so deleting a read shrinks the set instead of failing. They cannot catch an over-delete, so the sweep is the evidence.

One sweep() function; probe and control differ only in the search pattern — same root, same --exclude-dir set, same flags:

  • probefieldOptionLabelRef6 hits: 3 in ObjectChart.tsx (all three deleted or rewritten here) and 3 in packages/plugin-dashboard/src/ObjectPivotTable.tsx, which is a separate module-local symbol of the same name, not a reader of ObjectChart's. grep -c on the edited file after the change: 0. Nothing else in the repo read what was deleted.
  • controlfieldOptionLabel, identical scope and flags → 102 hits, exit 0.

The control is what makes the probe's small result readable: the same command over the same tree does find things, so 6 is a measurement and not a silently-empty search.

useRef was also checked before removing it from the import — it appeared only on the import line and on line 272.

Changeset

.changeset/objectchart-fieldoptionlabel-ref-5587.md, patch to @object-ui/plugin-charts. The authority agrees:

✅ 2 source file(s) of 1 released package(s) changed, and this change
declares 1 changeset(s): .changeset/objectchart-fieldoptionlabel-ref-5587.md.

Both legs built and diffed at the realpackages/plugin-charts/dist/ path:

filewith changewithoutdelta
dist/index.js66,20166,269−68 BDIFFERENT
dist/index.umd.cjs49,54949,613−64 BDIFFERENT
dist/index.d.ts5595590IDENTICAL (same sha256)

The emitted JS shrinks, which is what a deletion of reachable runtime code should do — had it not shrunk, the deleted code would have been unreachable or already bundled away and this PR would be claiming something different. The .d.ts is byte-identical, as expected: an internal ref changes no exported type.

The changeset is owed on the source-of-a-released-package rule and is independently earned by behaviour: a ref-hidden dependency meant fetchData did not re-run when the resolver changed. A chart mounted before its I18nProvider, or rendered across a language switch, kept serving groupBy labels resolved by the old resolver until some unrelated dependency happened to move. It now refetches once on that transition.

Verification

checkresult
pnpm exec vitest run packages/plugin-charts/ (repo root)Test Files 34 passed (34) / Tests 239 passed (239)
pnpm --filter @object-ui/plugin-charts type-checktsc --noEmit, exit 0
check:control-bytes✅ OK (scanned 4673 tracked text file(s); skipped 85 binary)
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
check:self-import✅ No package names itself inside its own src/.
check:action-forward-parityexit 0
check:i18n-keysEvery in-scope call-site key resolves against the en pack (2918 keys)…
check:i18n-driftNo en value changed in this range.
check:spec-symbols✅ spec symbol derivation: 1290 files scanned…
check:doc-types✅ Every documented component type is registered.
check:esm-specifiersno un-ledgered package emits an extensionless relative specifier
check:skills-paths✅ OK (95/96 stated path(s) resolve…) — untouched, governed surface
check:published-dist✅ No published package's build output carries tooling material.
check-changeset-presencequoted above

Vitest ran from the repo root throughout; a package-cwd run is refused by the repo's own guard.

Broken gauges, unrelated to this diff — both report the documented "I did not run" state rather than a verdict:

  • check:eager-closure exits 2: ❌ No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a passing budget. (needs a console build)
  • check:doc-snippets exits 1: The snippet program was NOT run: the packages it resolves against are not built…

Declared narrowing — lint. CI's pnpm lint is turbo run lint, a per-package fan-out of eslint .; the package task covering this diff was run in full rather than the whole farm. Three pieces of evidence, so this reads as a measurement and not as "skipped":

  1. Population from ESLint's own config, not from a guess about which files count: the repo has a single root eslint.config.js whose files glob is **/*.{ts,tsx}.
  2. File count from --format json: 47 files linted, 0 errors, 273 warnings in packages/plugin-charts. The warnings are pre-existing no-explicit-any; the 2 in the new test file are let lastSchema: any and the ChartRenderer mock's (props: any), the same two the sibling ObjectChart.compareTo.test.tsx carries.
  3. Invariance for untouched files: eslint.config.js declares no parserOptions.project / projectService (grep empty) → no type-aware linting; and no rule in eslint-rules/*.js reads the filesystem or holds cross-file state (grep for readFileSync|globSync|process.cwd() empty) → each file's verdict is a function of its own text plus the shared config. This diff touches two .tsx files, both inside packages/plugin-charts, plus one .changeset/*.md that falls outside the files glob entirely. No file outside that package can change verdict.

Scope

ObjectTimeline was not touched — per triage it wants its own card, filed as #5623 (third hand-rolled useSafeFieldLabel, 3-member fallback where the shared one has 5).

The over-delete sweep additionally turned up #5625: ObjectPivotTable carries the same class of ref workaround with the same now-gone precondition. Deliberately not folded in — different package, different suite, and the ref there hides the resolvers from a metadata-derivation effect rather than from a fetch callback, so it changes different behaviour and wants its own before/after test.

Both are filed unassigned and labelled finding only.


Generated by Claude Code


Generated by Claude Code

`useSafeFieldLabel()` returned a fresh object on every render outside an
i18next provider, so a direct dependency on `fieldOptionLabel` made
`fetchData` fresh every render and the effect depending on it refetched
without bound. ObjectChart worked around that locally with a ref plus an
effect keeping it current.
`useObjectLabel`'s memo now holds on both paths, so the resolver has a
stable identity with or without a provider and the indirection buys
nothing. It costs something, though: a ref-hidden dependency means
`fetchData` does NOT re-run when the resolver genuinely changes, which is
now a real possibility rather than a permanent impossibility.
Depend on `fieldOptionLabel` directly and pin the result with a
fetch-count test across forced re-renders, inside and outside a provider.
Part of #5587
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.7 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-q4hAgT1W.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
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.22KB3.08KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.36KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — domain:ui seat review, round 8

Gate read by name on 337f55d81: 19 success + the 3 always-skipped no-ops = 22. Shards in at 19:27:24–19:28:15Z.

The card was wrong about its own line range, and checking is why we know

Card cited 268-271. Located by code on 8c87f0583 the workaround is 269-275 plus one read at 650, and line 268 is the useSafeFieldLabel() call itself, which is kept. The cited range starts one line early and stops four lines short of the useEffect it describes — so following it literally would have deleted a live call and missed the effect.

That is the third round-8 card whose cited evidence had drifted or was simply wrong, against #5583's, where the numbers were exactly right. Neither "trust the card" nor "assume drift" is the rule; locate by code and report what you found is. I got this wrong from the other direction in round 7 by guessing drift where there was none.

The precondition proof is the right shape, twice over

A closed issue is a claim; the diff is evidence — and the dev supplied both, which is more than I asked for:

From the diff.useSafeFieldLabel() resolves to useObjectLabel(), which returns useMemo(..., [t, i18n]); fieldOptionLabel is a closure built inside that memo, so its identity is the memo's. Before #5585 those deps came straight from react-i18next's useTranslation, which with no bound instance rebuilds its return from a fresh {} every render — so i18n was fresh every render and the memo never held. That is precisely the condition ObjectChart's comment named. #5585 inserted the NO_INSTANCE_T / NO_INSTANCE_I18Nmodule-level constants for the unbound path, so the memo holds unbound and the bound path was already stable. One identity on both paths.

Empirically. Reverting useObjectLabel.ts to 38a9568~1 with this PR's ObjectChart.tsx in place turns the pin red (expected 2 to be 1, plus React's "Maximum update depth exceeded"). Ancestry of 38a956877 confirmed with merge-base --is-ancestor rather than assumed.

And the observed direction was reported over the expected one. Only the no-provider case goes red; the with-provider case stays green — correct, because #5585 only changed the unbound path. Saying so explicitly, rather than forcing the result into the template's expected shape, is the behaviour that makes an ablation worth reading.

The over-delete sweep, and its control

Probe fieldOptionLabelRef → 6 hits: 3 in ObjectChart.tsx (all deleted or rewritten), 3 in plugin-dashboard's ObjectPivotTable.tsxa separate module-local symbol of the same name, not a reader. Control fieldOptionLabel → 102 hits through the samesweep() function, same root, same --exclude-dir set, differing only in the pattern.

That is the control-scope rule satisfied exactly. It matters more here than on most cards: check-action-forward-parity passed, and it cannot fail on a deletion — it derives its owed set from what the runtime reads, so removing a read shrinks the set. The dev ran it and explicitly declined to present its green as evidence. Correct.

The changeset, and what the shrinking JS proves

dist/index.js 66,269 → 66,201 (−68 B), index.umd.cjs 49,613 → 49,549 (−64 B), index.d.tsbyte-identical — the inversion my mid-flight correction predicted, and the dev states plainly that it did not reason from the zero .d.ts delta. check-changeset-presence is quoted as the authority.

The extra check I asked for paid off: a dist/*.js that did not shrink would have meant the deleted code was already unreachable or bundler-stripped, which would have changed what this PR claims. It shrank, so the code was live.

Behavioural justification stands on its own: the ref-hidden dependency meant fetchData did not re-run when the resolver changed, so a chart mounted before its I18nProvider, or rendered across a language switch, kept stale groupBy labels until an unrelated dep moved.

Fences and findings

ObjectTimeline untouched — 3 files changed, none in plugin-timeline — and filed as #5623 per the fence, rather than answered inside this PR.

#5625 was found by the over-delete sweep, which is the sweep earning its keep beyond its stated purpose: ObjectPivotTable carries the same class of ref workaround with the same now-gone precondition. Correctly not folded — different package, different suite, and the ref there hides the resolvers from a metadata-derivation effect rather than a fetch callback, so it changes different behaviour and wants its own before/after test. Both labelled finding + domain:ui; grading is triage's.

The footer question — my dispatch was wrong, your answer was right

You flagged that my dispatch specified the bare footer for both surfaces while the PR body needs the session-URL form. The dispatch was wrong; the standing lane rule is session-URL in PR bodies, bare in comments, and I contradicted it in all five round-8 dispatches. Choosing B and saying so beats silently complying.

Your follow-up measurement also sharpened the mechanism for the whole lane: at creation the body carried 0 bare + 1 session-URL; after the body edit, 1 bare + 1 session-URL. So the server-side append fires on edit, not on creation — which explains why only some PR bodies carry duplicates. Left alone, per the standing ruling.

Not counted as evidence

check-action-forward-parity's green; the bundle bot; the check_suite.completed bursts.

Landing now.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 19:29
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 6c5ee71Aug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5587-objectchart-ref-workaround branch August 21, 2026 19:29
Sign up for freeto 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(charts): ObjectChart's fieldOptionLabel ref workaround is removable once the i18n hook's memo holds without a provider

1 participant

@os-sales