Skip to content

fix(dashboard,report): encode an empty pivot dimension value as JSON null, not a placeholder string - #4275

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4056-pivot-null-bucket
Aug 11, 2026
Merged

fix(dashboard,report): encode an empty pivot dimension value as JSON null, not a placeholder string#4275
yinlianghui merged 1 commit into
mainfrom
claude/issue-4056-pivot-null-bucket

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4056

The last encoding in the pivot family that still relied on "the data will not contain this character". objectstack#5473 / objectstack#5665 (PR #3414) replaced the delimiter-joined bucket ids with JSON.stringify, because every delimiter tried before it — an empty string, a plain space, a control character — assumed the data would not contain it and each assumption failed on ordinary data. The ids became JSON; the values fed into them did not:

String(row[d] ?? '∅')

An absent dimension value became the ordinary string "∅" (U+2205), so it shared a bucket with a row whose value literally is that character — one bucket, later row overwriting the earlier one, the cell showing a different row's measure, the overwritten row unreachable, and drill-through following the same wrong index into the wrong records. Per the card this is the assumption being removed, not a defect users hit today.

An empty value now encodes as JSON null, which JSON.stringify renders as a bare null no string can spell.

Where the null representation lives

In @object-ui/core, as pivotDimensionValue (absent ⇒ null, everything else ⇒ its string form) — not at each call site. A placeholder spelled by a caller is a placeholder that can collide again, which is exactly how this one survived PR #3414's convergence. This follows the routing ruling on the source thread (objectstack#5666, triage 2026-08-06):

正解在共享 helper:让 pivotBucketId 接受 (string | null)[](或等价的判别式编码),把 null 的表示从调用方的字面量收进 @object-ui/core;六个调用点随之去掉 ?? '∅'。⛔ 不要在两个插件里各自换一个新占位符 —— 那只是把碰撞面搬个地方

pivotBucketId widens to Array< string | null > accordingly. That is a widening, so existing callers passing string[] are unaffected.

Together-changed key inventory

The card's radius warning is the crux: a bucket id and the subtotal map keyed by it are built from the same expression, so changing one alone would split the headers while the subtotal map still merged — every column subtotal landing under the wrong header, the "one id, two encodings" shape PR #3414 had just converged away. Every key below moves in this PR:

KeyWhereBeforeAfter
Row bucket idDatasetWidget.buildPivotridpivotRowId(dims.map(d =String(row[d] ?? '∅')))pivotBucketId(dims.map(d =pivotDimensionValue(row[d])))
Column bucket id / column header idDatasetWidget.buildPivotcidString(row[colDim] ?? '∅')a bare string, not through the encoderpivotBucketId([pivotDimensionValue(row[colDim])])
Cell keyDatasetWidget.buildPivotcellIndexpivotCellKey(rid, cid)unchanged call, both inputs re-encoded
Row subtotal keyDatasetWidgetrowTotalByIdsame expression as ridsame expression as rid
Column subtotal keyDatasetWidgetcolTotalByIdsame bare string as cidsame expression as cid
Row + column + cell + both subtotal keysDatasetReportRenderer.bucketIdone helper feeding all fiveone helper, values normalized

plugin-report already funnelled all five of its keys through one local bucketId, so it is a one-line change. plugin-dashboard had the column axis on a second, hand-built encoding — the reason the across axis kept carrying this collision after the row ids were fixed — and it now goes through the same shared encoder as a one-element tuple. So the two files converge on the shared helper rather than being parallel-edited.

Column ids are opaque lookup keys: consumed only as Map keys and React keys (colTotalById.get(cc.col.id), pivotCellKey(rh.id, cc.col.id)), never parsed back into a value, never displayed, never persisted. Drill-through reads raw values (drillRawRows[index], and the report's own key: Row), never the id.

Red-first evidence

The five pins were written and run against unmodified source first. Every one failed, for the defect's own reason:

FAIL DatasetWidget > buildPivot keeps a null row-dimension value apart from the literal placeholder character
expected [ { id: '["∅"]', labels: [ '—' ] } ] to have a length of 2 but got 1
FAIL DatasetWidget > buildPivot keeps a null COLUMN-dimension value apart from the literal placeholder character
expected [ { id: '∅', label: '—' } ] to have a length of 2 but got 1
FAIL DatasetWidget > gives the null bucket its OWN cell and drills it to the null rows, not the placeholder rows
expected [ '—', '222' ] to deeply equal [ '—', '111' ]
FAIL DatasetWidget > matches column subtotals to the null bucket and the placeholder bucket separately
expected [ 'Status', '—', 'Total' ] to deeply equal [ 'Status', '—', '∅', 'Total' ]
FAIL DatasetReportRenderer > matrix keeps a null bucket apart from the literal placeholder character, on BOTH axes
expected [ [ '—', '4', '20' ], …(1) ] to deeply equal [ [ '—', '1', '2', '10' ], …(1) ]

Two of these are worth reading closely. The third is the objectstack#5473 signature verbatim — the null row displaying the other row's measure (222 where 111 belongs). The second shows both encodings side by side in one log: the row id '["∅"]' (JSON) against the column id '∅' (bare), which is the two-encodings state this PR removes. The report case collapsed a 4-row × 2-column cross-tab to a single row showing the last row's measure and the last subtotal.

Controls

  • Display layer untouched. A null dimension still renders as the em dash formatDimensionValue has always produced, and a value that literally is the placeholder renders as itself. Pinned verbatim in both renderers' assertions (['—', '111'] / ['∅', '222']; report row labels and ). The placeholders only ever entered ids.
  • Drill-through index alignment — the objectstack#5473 symptom class. Clicking the null bucket's cell drills to the null rows: $filter is { region: 'was_null', quarter: 'q1' }, not the placeholder row's record set. Raw drill values are deliberately distinguishable so the assertion names which flat index the cell resolved to.
  • Regrouping stability. For data with neither an absent value nor that character, bucket structure is identical: same header count, same first-seen order, same labels, same cell→index mapping, and the row id for all-string values is still exactly JSON.stringify(values). This control passed before the change and still passes after.
  • No i18n surface. Ids only; no translated string is added or changed. check:i18n-keys and check:i18n-drift both green.

Reverse verification

Reverting only the two plugin call sites (core's helper left in place, so the failure is the collision and not a compile error) — predicted RED on the collision pins, controls green:

Test Files 2 failed | 39 passed (41)
Tests 5 failed | 377 passed (382)

Exactly the five new pins, and only those five. Restored, then re-verified green.

The cross-package type change got its own reverse verification, since a stale .d.ts would report a false green in either direction. A probe compiled inside plugin-dashboard against the rebuilt @object-ui/core:

pivotBucketId([null, 'a']) → accepted (the OLD `string[]` signature rejects this)
pivotBucketId([pivotDimensionValue(null)]) → accepted
pivotBucketId([undefined]) → error TS2322: Type 'undefined' is not assignable to type 'string | null'

The acceptances prove tsc read the rebuilt declaration rather than a cached one; the rejection proves the element type is enforced downstream, so a caller cannot skip the normalizer. Probe removed after measuring.

Verification

Build closure first (--filter '< pkg >^...' build, suffix ^... = the packages it depends on), then:

  • vitest run packages/plugin-dashboard/ packages/plugin-report/ packages/core/118 files, 2040 tests, all passing
  • type-check on @object-ui/core, @object-ui/plugin-dashboard, @object-ui/plugin-report — all Done
  • eslint on the six changed files — 0 errors (31 pre-existing warnings; the one added is a no-explicit-any on a mock-call assertion, matching the identical pre-existing line in the objectstack#5473 drill test beside it)
  • check:control-bytes green, plus a direct scan of the changed files for the bytes the gate does not cover
  • Changeset: patch × @object-ui/core, @object-ui/plugin-dashboard, @object-ui/plugin-report

Generated by Claude Code

…null
The pivot's bucket ids became JSON tuples in objectstack#5473 /
objectstack#5665, because every delimiter tried before that assumed the data
would not contain it and each assumption failed on ordinary data. The values
fed into those tuples still carried one: `String(row[d] ?? '∅')` spelled an
absent value as the ordinary string "∅", so it shared a bucket with a row whose
value literally is that character — one bucket, later row overwriting the
earlier one, the cell showing a different row's measure and drill-through
following the same wrong index.
An empty value now encodes as JSON `null`, which no string can spell. The
normalization lives in `@object-ui/core` as `pivotDimensionValue`, not at each
call site, because a caller-spelled placeholder is what survived the previous
fix; `pivotBucketId` widens to `Array<string | null>` to accept it.
All bucket keys move together, as the fix requires — a bucket id and the
subtotal map keyed by it are built from the same expression, so changing one
alone would split the headers while the subtotal map still merged. Dashboard:
row id, column id, cell key, `rowTotalById`, `colTotalById`. Report: the single
`bucketId` helper feeding all five. The dashboard's column id also stops being
a bare string and becomes a one-element tuple through the shared encoder — the
last id in the family still built by hand, and why the across axis kept
carrying this collision after the row ids were fixed.
Display is untouched: these placeholders only ever entered ids, never labels.
Fixes#4056
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 10:22am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.5 KB350 KB
Entry fileindex-DTt4pr9V.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.88KB3.25KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
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)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
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)4.91KB0.87KB
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)488.62KB108.26KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.45KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)16.38KB5.47KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
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.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.73KB17.54KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.66KB30.76KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.88KB59.71KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.97KB49.90KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)109.18KB26.48KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
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)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
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

Development

Successfully merging this pull request may close these issues.

objectui: pivot bucket id 把 null 维度值编码成占位符 U+2205,与「值恰好等于该占位符」的行仍会撞

2 participants

@yinlianghui@claude