Skip to content

fix(core,charts,dashboard): give a chart bucket an identity distinct from its display label - #4677

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4508-chart-bucket-identity
Aug 15, 2026
Merged

fix(core,charts,dashboard): give a chart bucket an identity distinct from its display label#4677
yinlianghui merged 1 commit into
mainfrom
claude/issue-4508-chart-bucket-identity

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4508

target:v17 board item. Implements the maintainer ruling of 2026-08-14 on the card (verbatim 「同意」 on the sentinel-identity direction): buildChartSeries writes a bucket identity distinct from the display string, findChartSeriesRow reads it back, aligned with the distinct-bucket-id form the pivot TABLE (buildPivot) already uses over the same dataset rows. One change covering both collisions.

The two collisions, and the one cause

The bucket's DISPLAY string served as the bucket's own key, so two pairs of genuinely different groups were conflated — and the segment click that drills a bar back to its records inherited both.

  1. A null group and an empty-string group drew ONE bar. The pivot branch keyed by String(xRaw ?? ''), which spells null and '' identically. The bar took its label from whichever row created the bucket, and the other group's segment resolved to no row — a visible bar whose click did nothing.
  2. A record whose stored value spells the bucket label stole the null bucket's drill. A row storing the literal text (None) (or any localized chart.nullCategory(未指定) and the other nine packs) kept its own bucket, so two bars carried the same axis text and BOTH resolved to the first. That one is a wrong drill, not a dead one: clicking the null bucket's bar opened the drawer on another group's records.

What changed

  • chartBucketId (@object-ui/core) is the identity — the SAME encoder buildPivot keys its buckets with (pivotBucketId over pivotDimensionValue), so the chart and the table stop answering one question two ways. The pivot branch buckets by it, which is what makes null and '' two groups again.
  • CHART_BUCKET_ID_KEY carries that identity on an emitted row, written exactly where two DISTINCT buckets paint the same axis text — the complete set of cases where the display string cannot name what was clicked, given the reader's matching is now exact. An ordinary chart's rows are returned untouched (by identity), so no renderer-internal key reaches an authoring surface (data on a chart schema). Rows lacking the category key never get one — that shape stays hasNoCategoryKey's (framework#4033).
  • findChartSeriesRow takes it back as options.bucketId and treats it as authoritative, ignoring category entirely when present.
  • Plumbing. The drill event gains categoryId, via ChartSegmentClickEvent — now declared once in core rather than as three inline literals across AdvancedChartImpl / ChartRenderer / ObjectChart, since a field added to one of three copies reaches the consumer as undefined with nothing red. AdvancedChartImpl reads the identity off the clicked row on the cartesian, pie and funnel paths; DatasetWidget.handleChartDrill hands it to the lookup.

Why the identity is an ordinary enumerable property

Measured, not assumed: recharts builds a pie sector's payload as { ...row, ...cellProps } — a spread copy — so a symbol key or a non-enumerable property would arrive as "this bucket has no identity", the drill would fall back to the shared axis text, and every pure test would still be green. There is a DOM test that clicks a real sector for exactly this reason.

On the cartesian path the clicked row is read out of our own data array by activeTooltipIndex, not out of the event: recharts 3 sends a MouseHandlerDataParam that carries an index and no row, so nothing in that read can be a stale or copied payload.

Behaviour change worth reviewing

An empty-string category no longer resolves to a null-valued row. That tolerance was justified in the code as the drill layer's own spelling of "no group value" (computeDrillFilter), but re-measurement does not support it: computeDrillFilter runs downstream of a resolved drill and never produces this lookup's category, whose one production producer is a chart click — while ''is the axis text a genuine empty-string group paints. So the tolerance was giving that group's bar a different group's records. A host that forwards no categoryId keeps its existing drill unchanged.

Pin updates

The three cases #4497 pinned in chart-series.nullCategory.test.ts as "the measured limits of the bucket label" — explicitly as limits and not as correct — are flipped here, in the commit that changed them, each keeping its fixture and stating what it now asserts and what it asserted before. The two ''-reads-as-null pins are updated with the re-measurement above. Every other pin in that file, including all the must-not-change ones, is untouched and green.

Verification

All at f3e14d59a (the final commit), gate union re-run after it.

  • pnpm exec vitest run packages/core/ packages/plugin-charts/ packages/plugin-dashboard/170 files, 2463 tests, all passing.
  • turbo run type-check lint for the three packages with the dependency closure built — 19 tasks successful, tsc --noEmit && tsc -p tsconfig.test.json confirmed executed, 0 lint errors (warnings are the packages' pre-existing any counts).
  • check:changeset-fixed, changeset-no-major, changeset-presence, control-bytes, spec-symbols, phantom-deps, i18n-keys, i18n-drift, lint-coverage, type-check-coverage, skills-paths — all OK.

Cross-package type reverse check: renaming ev.categoryId to a non-existent field in DatasetWidget fails as error TS2339: Property 'categoryIdNoSuchField' does not exist on type 'ChartSegmentClickEvent' — the error names the type from the freshly built core .d.ts, proving plugin-dashboard type-checked against the rebuilt declaration rather than a cached one.

Reverse verification, directions predicted before running, split into two legs so each collision is attributed to its own half:

ablationpredictedobserved
writer (pivot bucket key + identity write)both collisions' bucketing and every identity-carrier assertion go red; reader-only cases and all boundaries stay green9 red / 39 green — exactly the predicted set, including the pie-click DOM test losing categoryId
reader (exact display matching)the empty-string cases go red; identity cases stay green4 red / 44 green — the predicted set

Each leg was restored byte-for-byte from the committed state before the next (git checkout of the commit sha, path-scoped — never a stash), and the final numbers above are from the restored tree.

Out of scope, filed while measuring

Branch was cut from b1119ece4; main has since advanced to 541c72425 (#4671), which touches none of this PR's files.


Generated by Claude Code

…from its label (#4508)
`buildChartSeries` used a bucket's DISPLAY string as the bucket's own key, so
two pairs of genuinely different groups were conflated, and the segment click
that drills a bar back to its records inherited both conflations:
- the pivot branch keyed by `String(xRaw ?? '')`, which spells a null group and
an empty-string group identically — two groups drew ONE bar and the later
one's segment resolved to no row at all;
- a record whose stored value literally spells the bucket label (`(None)`, or a
localized `chart.nullCategory`) kept its own bucket, so two bars carried the
same axis text and BOTH resolved to the first — a wrong drill, not a dead one.
`chartBucketId` is the identity: the SAME encoder `buildPivot` already keys the
pivot TABLE's buckets with (`pivotBucketId` over `pivotDimensionValue`), so the
two surfaces stop answering one question about the same rows two ways. The pivot
branch buckets by it. Where two DISTINCT buckets still paint the same axis text,
the emitted row carries that identity under `CHART_BUCKET_ID_KEY` — the complete
set of cases the display string cannot name, so an ordinary chart's rows are
returned untouched and no renderer-internal key reaches an authoring surface.
`findChartSeriesRow` takes it back as `options.bucketId` and treats it as
authoritative. The renderers forward it: the drill event gains `categoryId`
(`ChartSegmentClickEvent`, now declared once in core instead of inline in three
packages), `AdvancedChartImpl` reads it off the clicked row on the cartesian,
pie and funnel paths, and `DatasetWidget.handleChartDrill` hands it to the
lookup. The identity is an ordinary enumerable property because recharts builds
a pie sector's `payload` as a spread copy of the row.
The reader's matching is exact now: a null category reads as its rendered bucket
label and no longer also as `''`. That tolerance was justified as the drill
layer's own spelling of "no group value", but no producer of this lookup's
`category` writes it, while `''` IS the axis text a genuine empty-string group
paints — so it was handing that group's bar a different group's records.
The three cases objectui#4497 pinned as "measured limits, not correct" are
updated here, in the commit that changed them, as their own note said they would
have to be. A host that forwards no `categoryId` keeps its existing drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 15, 2026 7:40am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-gT0UDKrZ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)38.46KB10.17KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.35KB1.07KB
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.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.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)489.91KB108.67KB
core (index.js)3.79KB1.52KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)163.56KB44.83KB
fields (index.js)230.37KB57.17KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.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.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.86KB12.91KB
plugin-charts (index.js)62.32KB17.77KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)121.87KB31.76KB
plugin-designer (index.js)212.58KB42.83KB
plugin-detail (index.js)239.93KB60.01KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.72KB27.70KB
plugin-gantt (index.js)164.30KB40.02KB
plugin-grid (index.js)190.02KB50.48KB
plugin-kanban (index.js)52.74KB14.53KB
plugin-list (index.js)111.82KB27.23KB
plugin-map (index.js)18.16KB5.81KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.38KB11.09KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.64KB9.44KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.26KB0.67KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
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.05KB1.52KB
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

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

Projects

None yet

2 participants

@yinlianghui@claude