Skip to content

fix(charts): null-keyed groups render as an explicit bucket instead of silently vanishing (#4466) - #4498

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4466-null-group-bucket
Aug 12, 2026
Merged

fix(charts): null-keyed groups render as an explicit bucket instead of silently vanishing (#4466)#4498
yinlianghui merged 1 commit into
mainfrom
claude/issue-4466-null-group-bucket

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Closes #4466

The defect

buildChartSeries' single-dimension branch (packages/core/src/utils/chart-series.ts) passed rows through verbatim, so a row whose category value is null reached recharts with a null category and drew no mark. hasNoCategoryKey in AdvancedChartImpl never fired, because 'user_id' in row is true — the key is present, its value is null.

The visible outcome was not an empty chart but a quietly wrong one. Measured, both levels, pre-fix:

  • Partial case (the sharpest) — rows [{user_id: null, event_count: 51}, {user_id: 'Dev Admin', event_count: 2}] drew exactly ONE bar. The dominant group (51 of 53 events) was dropped while the y-axis scale still accommodated it, so the chart understated its own data and the axis proved the data had been there.
  • All-null[{user_id: null, event_count: 50}] drew axes, gridlines and the axis title with zero bar rectangles and no empty state. This is the shipped first-boot state of the built-in System Overview board's "Events by User": every seeded sys_audit_log row is written with user_id = NULL.

The fix

The mapping lives in the shared series layer, so dashboard widgets and standalone ObjectChart get one answer instead of a per-chart patch in the recharts wrapper. It also resolves the two-answers disagreement the card names: an empty result set keeps the designed empty state, a non-empty result always draws bars — the null bucket included.

  • @object-ui/core gains NULL_CATEGORY_LABEL and ChartSeriesOptions; buildChartSeries and findChartSeriesRow each take an optional trailing options. Purely additive — see the .d.ts diff below.
  • The label comes through the i18n channel. chart.nullCategory, en (None) / zh (未指定), in all ten packs, following the measured sibling convention for a parenthesised bucket label (report.allLabel (All) / report.emptyLabel (Empty), translated in every pack). @object-ui/core is React-free and cannot read the locale bundle, so the renderer passes the resolved string down — the same division dimensionOptionTranslator already uses, one layer down in the same file. The English constant is the floor for a provider-less host, not the mechanism.
  • The two helpers are a pair on purpose. A caller matches a clicked segment against the rows it charted FROM, which still carry the raw null, so findChartSeriesRow reads the bucket label back to that row. Without it the one bar this fix made visible would resolve to -1 and its drill-through would silently no-op — DatasetWidget's handleChartDrill returns early on a negative index.
  • hasNoCategoryKey is untouched and now documented against this. A row that does not carry the category key AT ALL is a different defect (a dimension grouped by but never projected, framework#4033) and keeps its explanatory placeholder. The bucket deliberately never ADDS the key to such a row, which is what keeps that guard's only signal alive. Key absent → the placeholder; key present with a null value → the bucket. Asserted in both directions.

Red-first

Written before the fix, run against unfixed code. Verbatim, at both levels the defect was proven at.

Series transform (unit, @object-ui/core):

AssertionError: expected [ { user_id: null, event_count: 50 } ] to deeply equal [ { user_id: undefined, …(1) } ]
-     "user_id": undefined,
+     "user_id": null,

Rendered DOM (@object-ui/plugin-charts, .recharts-bar-rectangle counts through the real transform):

× draws BOTH bars for the partial case, the null one labelled and counted
AssertionError: expected 1 to be 2 // Object.is equality

× draws one labelled bar for the all-null result instead of an empty axis
AssertionError: expected +0 to be 1 // Object.is equality

The same two counts reproduce end-to-end through a dataset-bound ObjectChart (reverse-verified by taking the core fix out with git checkout origin/main -- ... after the fix was green):

× draws the null group as a labelled bucket instead of an empty axis    → expected +0 to be 1
× keeps the dominant null group when a named group is present too       → expected 1 to be 2
✓ renders no bucket bar for a genuinely empty result set                (green both sides)

Post-fix all of it is green: 2 bars for the partial case with (None) and Dev Admin on the axis, 1 labelled bar for the all-null case.

Must-not-change (green on both sides)

  • Non-null groups render identically, and a result with no null category is still returned by array identity (expect(r.data).toBe(rows)).
  • A genuinely empty result set stays empty — no phantom bucket bar, designed empty state untouched.
  • The key-absent shape still reaches the data-chart-error="missing-category-key" placeholder with no svg drawn.
  • The multi-dimension pivot branch is unchanged, and pinned as-is so a future change to it has to be deliberate. Its sibling gap is filed unassigned as A NULL first-dimension value is still dropped by the multi-dimension pivot branch of buildChartSeries (the sibling of #4466) #4497 rather than fixed here.
  • Caller rows are never mutated — dataset surfaces drill through by index into the raw rows, which must keep their null.

Verification

Repo-root vitest, paths relative to the repo root.

  • pnpm exec vitest run packages/core/ packages/i18n/ packages/plugin-charts/ packages/plugin-dashboard/ packages/plugin-report/ packages/react/241 files, 3727 tests passed. plugin-dashboard reaches the fix through core with no file of its own edited, and no test there pinned the dropped-row behaviour.
  • tsc --noEmit and tsc -p tsconfig.test.json for @object-ui/core and @object-ui/i18n, tsc --noEmit for @object-ui/plugin-charts — all exit 0, after pnpm --filter '@object-ui/plugin-charts^...' build.
  • Reverse verification of the rebuilt .d.ts: a deliberate typo in the new option is rejected by the consumer's own typecheck, so the build closure is genuinely being read and not a cached artifact.
src/ObjectChart.tsx(842,11): error TS2561: Object literal may only specify known
properties, but 'nullCategoryLabell' does not exist in type 'ChartSeriesOptions'.
  • check-i18n-call-site-keys.mjs, check-i18n-en-drift.mjs (1 key(s) added), check-control-bytes.mjs, check-changeset-presence.mjs, check-changeset-no-major.mjs, check-changeset-fixed.mjs — all exit 0. ESLint: 0 errors on every touched file.

.d.ts diff

Additive in one direction only, which is what sets the grading:

+export declare const NULL_CATEGORY_LABEL = "(None)";
+export interface ChartSeriesOptions { nullCategoryLabel?: string; }
-export declare function buildChartSeries(..., fields?: ChartResultField[] | null): ChartSeriesResult;
+export declare function buildChartSeries(..., fields?: ChartResultField[] | null, options?: ChartSeriesOptions): ChartSeriesResult;
-export declare function findChartSeriesRow(..., seriesKey?: string): number;
+export declare function findChartSeriesRow(..., seriesKey?: string, options?: ChartSeriesOptions): number;

Old call sites compile and behave identically against the new types (both parameters optional); new call sites do not compile against the old ones. The public surface therefore grows, so the changeset is @object-ui/core: minor with @object-ui/plugin-charts and @object-ui/i18n patch — per the ruling's own "minor only if a public surface grows", and following #4431's precedent for core gaining a shared executor. Never major.


Generated by Claude Code

…f silently vanishing (#4466)

`buildChartSeries`' single-dimension branch passed rows through verbatim, so a
row whose category VALUE is null reached recharts with a null category and drew
no mark. The partial case is the sharpest: two groups in, ONE bar out — the
dominant null-keyed group (51 of 53 events) dropped while the y-axis scale still
accommodated it. With every group null it drew axes and gridlines with zero
marks and no empty state: the shipped first-boot state of System Overview's
"Events by User".

The mapping lives in the shared series layer so dashboard widgets and standalone
ObjectChart get one answer. The label flows from the renderer through the i18n
channel (`chart.nullCategory`), because core is React-free. `findChartSeriesRow`
reads the bucket label back to its raw-null row so the newly visible bar keeps
its drill-through. `hasNoCategoryKey` (framework#4033) keeps meaning "key
absent" — the bucket never adds the key to a row that lacks it.
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 9:26pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-Cuvcf4uz.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.79KB 41.35KB
fields (index.js) 230.07KB 57.07KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.13KB 12.76KB
plugin-charts (index.js) 62.07KB 17.65KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.85KB 31.41KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.13KB 50.00KB
plugin-kanban (index.js) 48.62KB 13.42KB
plugin-list (index.js) 111.07KB 27.08KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

ACCEPT — step-7 复核 by PM session session_017Qqyix2QcnpUC9XeYVDzx3 (focused review).

Flipping ready + arming auto-merge.


Generated by Claude Code


Generated by Claude Code

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

Projects

None yet

2 participants