Skip to content

plugin-dashboard: hoist the "no rows yet" empty to module scope in ObjectDataTable and ObjectPivotTable - #5566

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-4629-objectdatatable-stable-empty
Aug 21, 2026
Merged

plugin-dashboard: hoist the "no rows yet" empty to module scope in ObjectDataTable and ObjectPivotTable#5566
os-sales merged 2 commits into
mainfrom
claude/issue-4629-objectdatatable-stable-empty

Conversation

@os-sales

@os-salesos-sales commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4629

ObjectDataTable and ObjectPivotTable both spelled the resolved row list as
Array.isArray(rawData) ? rawData : [], evaluating a fresh array literal on every
render. Both now fall back to a module-scope Object.freeze([]) — the same shape
data-table.tsx adopted for its own EMPTY_ROWS under #4618 / PR #4623, applied to
the provider: 'object' siblings. No second remedy was invented: no useMemo wrapper
around the conditional, no change to any memo's dependency list.

Verification

Gate union run at HEAD c8fd1be97 (the final commit), all green:

gateverdict line it printed
pnpm exec turbo run lint --filter=@object-ui/plugin-dashboardTasks: 2 successful, 2 total · 0 errors, 360 warnings
pnpm exec turbo run type-check --filter=@object-ui/plugin-dashboardTasks: 13 successful, 13 total (incl. tsc --noEmit && tsc -p tsconfig.test.json)
pnpm exec vitest run packages/plugin-dashboard/Test Files 72 passed (72) · Tests 653 passed (653)
node scripts/check-control-bytes.mjscheck-control-bytes: OK (scanned 4620 tracked text file(s))
node scripts/check-changeset-presence.mjs4 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
node scripts/check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.
node scripts/check-lint-coverage.mjslint coverage: 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjstest type-check coverage: 41/41 packages compile their tests, 0 declared debt
pnpm check:self-importNo package names itself inside its own src/.
pnpm check:phantom-depsEvery in-scope import is declared by the package that publishes it.

Declared narrowing of pnpm lint. Repo-wide pnpm lint (turbo across 46 packages)
was narrowed to this package's own declared lint task. Three pieces of evidence, not
an assertion: (1) the population is eslint's own config resolution — the package script
is literally eslint ., no hand-picked file list; (2) 102 files linted, read from
--format json output length, 0 errors / 360 warnings; (3) the narrowing can exclude
nothing, because eslint.config.js enables no type-aware linting (no
parserOptions.project, no projectService, no *TypeChecked preset) and none of the
seven rules in eslint-rules/ reads any file other than the one under lint (no
readFileSync / globSync / readdirSync), so this diff cannot move the verdict on any
file outside the 102. check-lint-coverage.mjs independently reports 46/46 packages
linted with 0 outstanding errors repo-wide.

The ESLint warning, and why clearing it is not the evidence

On origin/main, ObjectDataTable.tsx:310:9 reported exactly what the card quoted:

warning The 'finalData' conditional could make the dependencies of useMemo Hook
(at line 417) change on every render... react-hooks/exhaustive-deps

It is gone on this branch (measured both states in one trap-guarded run; the mutation
was confirmed on disk by anchored grep -c on the reverted text, not by an editor's
exit code). But a suppression would clear it too, so the identity is pinned directly.

ObjectPivotTable had no such warning on origin/main — its finalData reaches no
hook in that file, it flows to PivotTable through props. The sweep therefore found a
site the linter could not have flagged.

Tests: what they assert, and the reverse verification

Nothing renders wrong before or after, so a "the table renders correctly" assertion is
green against the broken code. The two new suites assert the two forms the churn takes:

  • ObjectDataTable.stableEmptyRows.test.tsxrecompute count. One buildFieldMeta
    call per declared column per memo evaluation, counted through a partial mock of
    ../recordFields. A load-bearing pre-assertion (toBe(2) at mount) keeps it from
    passing vacuously, and it also asserts the table is not rendered in that window.
  • ObjectPivotTable.stableEmptyRows.test.tsxdirect identity. The array handed to
    PivotTable must be one and the same object across host re-renders, and frozen.

Reverse verification, directions written before the run and both matched:

legpredictedmeasured
ObjectDataTable recompute countREDRED — expected 8 to be 2
ObjectPivotTable identityREDRED — expected 4 to be 1
both negative controls (rows arriving still re-derive / still forward)GREEN both sidesGREEN both sides

The fix was committed first so the revert had a restore point; the tree was restored
byte-identically afterwards (git diff --exit-code HEAD clean). The script carried a
trap … EXIT INT TERM restore, so a foreground-cap SIGTERM mid-mutation could not
have left the tree mutated for later measurements.

Sweep disposition — every site measured, none silently skipped

sitedispositionmeasurement
ObjectDataTable.tsx:310finalDataFIXEDkeys derivedColumns (dep list :417); ESLint-reported
ObjectPivotTable.tsx:154finalDataFIXEDreaches PivotTable as schema.data, keys its cross-tab memo
ObjectDataTable.tsx:162colsSKIPPED — not the defect shape (see below)inside computeLookupExpand, a module-scope pure function, not a component body; cols never escapes the call
PivotTable.tsx:176 + the data: rawData = [] destructuring defaultSKIPPED — out of declared file surface, filed as #5562ESLint on origin/main: "The 'data' conditional could make the dependencies of useMemo Hook (at line 241) change on every render"
DashboardFilterBar.tsx:300skippedinside a .then() callback; feeds setDynamicOptions(pairOptionRows(...)), not a per-render literal
DashboardWithConfig.tsx:111-112skippedinside selectedWidgetConfig = React.useMemo(...) (deps [selectedWidgetId, configVersion])
DatasetWidget.tsx:400-401skippedeach already wrapped in its own useMemo
DatasetWidget.tsx:529skippedinside a .then(); a setState payload
ObjectMetricWidget.tsx:253skippedinside an async useCallback body; local to the call

ObjectDataTable.tsx:162 — the dispatch listed this as in-surface "same defect shape";
measured otherwise and left alone.
It sits inside export function computeLookupExpand,
which is not a component body. cols is consumed only within that call (.length,
.map) and never returned; the function's own result is a fresh Array.from(out) on
every call regardless, and its single call site (:263) is a fetch-effect body, not a
dependency list. By the card's own criterion — "only ever safe when nothing memoizes or
synchronizes on the result" — nothing does. Hoisting a shared empty there would imply a
render-identity concern that does not exist.

Two premise corrections, both measured

1. "The whole pre-fetch window" is too wide.rawData is
boundData || schema.data || fetchedData, and fetchedData is a useState seeded with []
an array with a stable identity from the first render. So the plain pre-fetch window
was never the churning case. The churn needs a truthy non-array: a provider-config
data, or a bind path that resolves to an object. This narrows the exposure; it does
not change the fix, since the identity bug and the ESLint warning are unconditional.

2. The render-loop question — confirmed impossible, by a stronger argument than the
card's.
In ObjectDataTable the fallback fires ifffinalData.length === 0, and
that is exactly the condition under which the component takes an early return (loading /
error / no-datasource / empty state) and never renders SchemaRenderer at all. So the
churn does not merely fail to feed a setState — it never reaches data-table in the
first place. Observation-class grading stands. ObjectPivotTable has no unconditional
empty-state early return, so there the churn did escape into PivotTable's memo, which
is why it is the site with the direct identity assertion.

Filed out of scope (unassigned, not fixed here)


Generated by Claude Code

ObjectDataTable and ObjectPivotTable both spelled the resolved row list as
`Array.isArray(rawData) ? rawData : []`, evaluating a fresh array literal on
every render. In ObjectDataTable that value keys the `derivedColumns` memo; in
ObjectPivotTable it is handed to PivotTable and keys its cross-tabulation memo.
Same module-scope frozen empty data-table.tsx already uses (#4618, PR #4623).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
… swept-out findings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3784.8 KB3867.2 KB
Main entry chunk (gzip)151.2 KB350 KB
Entry fileindex-Dnhpe7iD.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.94KB113.63KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
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)30.51KB7.57KB
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.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.51KB32.96KB
plugin-designer (index.js)212.39KB42.83KB
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.52KB20.67KB
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)1.45KB0.83KB
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-sales
os-sales marked this pull request as ready for review August 21, 2026 12:59
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit f24195aAug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-4629-objectdatatable-stable-empty branch August 21, 2026 12:59
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-dashboard): ObjectDataTable's finalData fallback re-derives every column on every render while no rows have arrived

2 participants

@os-sales@claude