Skip to content

fix(plugin-dashboard): bind chart-bucket drill assertion to content, not a cumulative render count - #4720

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4718-chart-bucket-drawer-count
Aug 15, 2026
Merged

fix(plugin-dashboard): bind chart-bucket drill assertion to content, not a cumulative render count#4720
yinlianghui merged 1 commit into
mainfrom
claude/issue-4718-chart-bucket-drawer-count

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4718

What was wrong

DatasetWidget.chartBucketIdentity.test.tsx pinned drillFilters.length to exactly 1
after a single bar click. drillFilters is a recording array of the mocked
DrillDownDrawer's render props — it only ever grows. useDatasetDimensionMeta issues
its own dimension-metadata fetch independently of the click; when that fetch's setMeta
lands its React commit strictly AFTER the click's own commit (a real possibility under a
saturated event loop, since the two updates are only incidentally ordered),
DrillDownDrawer renders a second time with the SAME filter, and drillFilters.length
overshoots to 2. Once it overshoots, waitFor can never recover — nothing shrinks the
array.

This is the same defect class PR #4708 fixed in drillTitleLabel.test.tsx for card
objectui#4706, and card #4718 reports it observed on a shard 2 red against an unrelated
PR (data-objectstack test-only change), same causality argument as the earlier sibling.

The fix

The one site (:151, the issue's named site) now waits for the drill to have opened AT
LEAST once (toBeGreaterThan(0)) instead of pinning the exact cumulative count. The
content assertion immediately after (lastFilter()) is unchanged and remains the
substantive check — the case is about which records the drawer filters on, not how many
times React chose to render it. A drawer that never opens still times out exactly as
before; only the overshoot-vulnerable exact-count pin is relaxed.

Family-wide sweep (packages/plugin-dashboard/src/__tests__/, all files)

Per the card's direction, every waitFor(() => expect(recordingArray.length).toBe(N))-
shaped site in the directory was surveyed, not just the two files already known. Grepped
for .length).toBe(, toHaveLength(, and every vi.mock('../DrillDownDrawer', …) /
.push( usage across the whole directory.

Relaxed (this PR):

  • DatasetWidget.chartBucketIdentity.test.tsx:151drillFilters.length).toBe(1)
    toBeGreaterThan(0). Same overshoot mechanism as objectui#4706: drillFilters is a
    push-based recording array (mocked DrillDownDrawer), and this file's widget uses
    useDatasetDimensionMeta, which is the only hook in this package whose independently-
    resolving fetch can land a late, unrelated re-render.

Already relaxed (pre-existing, untouched by this PR):

  • DatasetWidget.nullCategoryI18n.test.tsx:190,215drillFilters.length already
    toBeGreaterThan(0).
  • DatasetWidget.optionLabelI18n.test.tsx:302 — same, already relaxed.
  • DatasetWidget.localSelectI18n.test.tsx:342 — same, already relaxed.

Kept exact-count, with a stated causal reason (untouched):

  • DatasetWidget.chartBucketIdentity.test.tsx:145,173,199,218,243
    chartRows().length).toBe(2). chartRows() reads capturedChartProps, which the
    mocked chart component OVERWRITES (capturedChartProps = props) on every render, not
    pushes. It is a snapshot of the latest render, not a cumulative recording — a late,
    unrelated re-render re-assigns the same reference and cannot grow the array beyond the
    actual row count the chart was given. No overshoot is structurally possible.
  • DatasetWidget.drillTitleLabel.test.tsx:172,218,244,270,314chartSeries()/
    chartData(), same capturedChartProps-snapshot idiom as above. Also
    DatasetWidget.nullCategoryI18n.test.tsx:158,169,202,227,236 (categories(), same
    idiom). All snapshot reads, not recording arrays — safe to keep exact.
  • DatasetWidget.test.tsx:93screen.getAllByText('100.0%').length).toBe(2). A live
    testing-library DOM query re-evaluated by waitFor, not a recording array — cannot
    accumulate past the actual DOM's current match count.
  • DatasetWidget.test.tsx:794, DatasetWidget.dottedDimensionTable.test.tsx:352,
    DatasetWidget.compareTo.test.tsx:460, DatasetWidget.localSelectI18n.test.tsx:375
    blobs/calls recording arrays from a stubbed URL.createObjectURL, but each is
    checked SYNCHRONOUSLY immediately after a synchronous fireEvent.click, never inside a
    waitFor polling loop — there is no repeated re-evaluation window in which a
    transient/overshot state could be caught, so this idiom's specific vulnerability (a
    polling assertion that can observe a later, larger value) does not apply.
  • PivotTable.drill.test.tsx:32,38queryAllByRole('button').length).toBe(0), live DOM
    queries, synchronous (no waitFor), and PivotTable takes its data as a prop with no
    internal async fetch (no useDatasetDimensionMeta usage) — no race exists to overshoot.
  • DatasetWidget.queryOptions.test.tsx, DashboardFilterBar.options.test.tsx,
    DashboardFilterBar.optionsFromRawValue.test.tsx, DashboardRenderer.headerActions. test.tsx, ObjectMetricWidget.compareTo.test.tsx — various
    waitFor(() => expect(mock).toHaveBeenCalledTimes(N)) sites. Different defect class:
    these count calls to the widget's OWN primary data-fetch mock (queryDataset/
    aggregate/find/onWidgetClick handler), driven deterministically by the test's own
    actions and effect-dependency changes, not by an incidental re-render from an unrelated
    hook. None of the components under test in these files use useDatasetDimensionMeta
    (confirmed: grep -rl useDatasetDimensionMeta packages/plugin-dashboard/src/*.tsx
    returns only DatasetWidget.tsx), so the specific race this card is about cannot reach
    these counts.

No third file needed the treatment — the two files the card flagged as "possibly a third"
turned out to already carry the relaxed form.

Verification

Deterministic reproduction (pre-fix, red). Temporarily gated chartBucketIdentity. test.tsx's meta-fetch mock behind a manually-releasable promise, wrapped the bar click in
its own act() so its commit lands alone, then released the gate and flushed inside a
second act() — forcing the click's commit and the metadata commit into two separate
React passes (same technique as PR #4708's body). With the OLD assertion, this reproduced
the exact CI failure text: AssertionError: expected 2 to be 1, with drillFilters.length
observed at 2 after the forced late commit.

Fix re-verified under the same forced race. With the fix applied and the forced
overshoot still active, the test passes.

Positive control (assertion bite-check). With the fix in place, temporarily forced
handleChartDrill's bucketId: ev?.categoryId to bucketId: undefined in
DatasetWidget.tsx (simulating the identity carrying nothing, i.e. the pre-objectui#4508
behavior). The touched test's second click correctly went red on the wrong records
({ owner_id: 'user-literal' } instead of the expected { owner_id: 'user-absent' }
the exact defect objectui#4508 fixed), while the three BOUNDARY cases in the file stayed
green. (Note, for completeness: the file's other collision case, "an empty-string group
drills to itself," stayed green under this particular override — that collision is
evidently guarded by something beyond the bucketId parameter alone, so this override
doesn't reach it; it wasn't the site this PR touches.) Confirms the relaxed count
assertion does not mask a real regression of the underlying drill-identity feature — the
content assertion right after it still carries the real check.

All temporary repro/gate/positive-control changes were reverted before the commit in this
PR (verified byte-identical to origin/main before re-applying only the real fix) —
git diff against origin/main is the fix alone (test file + changeset).

Gates run (final HEAD 00d868d5e)

  • pnpm --filter '@object-ui/plugin-dashboard' lint — 0 errors (334 pre-existing
    any/fast-refresh warnings, none introduced by this change)
  • pnpm --filter '@object-ui/plugin-dashboard' type-check — clean
  • pnpm exec vitest run packages/plugin-dashboard/58 files / 462 tests passed
  • node scripts/check-control-bytes.mjs — OK
  • node scripts/check-changeset-presence.mjs — OK (empty-frontmatter changeset declared)
  • node scripts/check-changeset-fixed.mjs / check-changeset-no-major.mjs — OK

scripts/pm/dispatch-gates.mjs does not exist in this repo; the gate set above is the one
the dispatch prompt named, matching PR #4708's own gate list for the same class of change.

Changeset

Test-only change → empty-frontmatter changeset
(.changeset/dataset-widget-chart-bucket-drill-render-count.md). No published behaviour
changes.

Not in scope

Card #4717 (the data-objectstack PR whose shard-2 red surfaced this) and card #4508 (the
chart-bucket-identity feature this file pins) are unrelated to this change beyond being
named for context above; neither is addressed or closed by this PR.


Generated by Claude Code

…not a cumulative render count
Fixes#4718
DatasetWidget.chartBucketIdentity.test.tsx pinned `drillFilters.length` to
exactly 1 after a single bar click. `drillFilters` is a recording array of
the mocked DrillDownDrawer's render props — it only ever grows.
useDatasetDimensionMeta issues its own dimension-metadata fetch independently
of the click; when that fetch's setMeta lands its React commit strictly
AFTER the click's own commit, DrillDownDrawer renders a second time with the
SAME filter and drillFilters.length overshoots to 2, which waitFor can never
recover from. Same defect class PR #4708 fixed in drillTitleLabel.test.tsx
(objectui#4706), second file.
The one site now waits for the drill to have opened AT LEAST once
(toBeGreaterThan(0)) instead of pinning the exact cumulative count. The
content assertion immediately after (lastFilter()) is unchanged and remains
the substantive check.
Family-wide sweep of packages/plugin-dashboard/src/__tests__/ found no other
site needing this treatment: the sibling `drillFilters` sites in
nullCategoryI18n/optionLabelI18n/localSelectI18n.test.tsx already use
toBeGreaterThan(0); every other `.length).toBe(N)` waitFor pin in the family
reads a capturedChartProps-style snapshot (overwritten on each render, not
pushed) or a live DOM/mock-call count with no useDatasetDimensionMeta-driven
race, so those stay exact-count by construction.
Co-Authored-By: Claude <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 4:20pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)24.7 KB350 KB
Entry fileindex-jEJS7CXw.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)156.79KB43.21KB
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)64.44KB18.28KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)121.90KB31.76KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)239.86KB59.99KB
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)192.10KB51.24KB
plugin-kanban (index.js)52.74KB14.53KB
plugin-list (index.js)111.14KB26.92KB
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.28KB0.68KB
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

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Review verdict: ACCEPT (reviewer of record, PM session session_01RnQd8iMMUwXQEV1crFmQiQ).

Verified against this PR, not the report: two changed files (+20/−1), Fixes #4718 the only closing keyword, and the named-for-context cards explicitly disclaimed. The family-wide sweep is the strongest form this class has gotten yet: one genuine overshoot site relaxed with its content assertion untouched, three pre-existing relaxed sites confirmed, and every exact-count survivor classified under a stated causal-safety category (snapshot-overwrite reads, live DOM queries, synchronous post-click reads, no-async-hook components, and deterministic call-count sites — the last backed by a grep proving only DatasetWidget.tsx uses useDatasetDimensionMeta in this package). The reproduction hits the exact CI failure text under the forced two-commit race, and the positive control is discriminating, with the unreached collision case honestly noted rather than claimed. Gates pinned to 00d868d5e, matching this PR's current head.

One process note: the dev self-corrected a transient skip-changeset label during the run — third occurrence of that false mechanism today. The actual source is the shared os-dev agent contract text, not this repo's docs; filing that upstream separately so it stops recurring.

Will flip ready + enable auto-merge once Lint, Type Check, and Test shards 1–4 conclude success on this head.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 15, 2026 16:34
@yinlianghui
yinlianghui added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit fec0683Aug 15, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4718-chart-bucket-drawer-count branch August 15, 2026 16:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flaky: DatasetWidget.chartBucketIdentity pins the same overshooting drawer-render count the drillTitleLabel fix removed — the sweep was file-scoped

2 participants

@yinlianghui@claude