Skip to content

fix(plugin-detail): one definition of emptiness for the whole record page (#8394) - #8457

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8394-remaining-emptiness-spellings
Sep 8, 2026
Merged

fix(plugin-detail): one definition of emptiness for the whole record page (#8394)#8457
os-justin merged 1 commit into
mainfrom
claude/issue-8394-remaining-emptiness-spellings

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8394

hasCellValue moves out of DetailSection.tsx into packages/plugin-detail/src/emptiness.ts, and every remaining band of the record page reads it instead of its own raw null | undefined | '' test.

Per-site verdict — three convergences, one genuine difference, and a fifth spelling the card's census missed

site verdict why
HeaderHighlight.tsx:171 CONVERGED Identical question. isEmpty chooses between the em-dash affordance and CellRenderer — the same two branches DetailSection's displayValue chooses between, with the same detail.noValue label. Objects reach the type-aware renderer here exactly as they do there.
HistoryTimeline.tsx:134 CONVERGED Identical question. formatDiffValue chooses between '—' and content, and its object branch is JSON.stringify — real content, so the object half must not delegate for the same reason.
DetailView.tsx:979 CONVERGED Same question, with one caveat below: the chip is rendered by a hand-rolled stringifier rather than a cell renderer, so hasCellValue is the non-regressive choice — it moves whitespace-only strings and nothing else.
DetailView.tsx:438 NOT IN THE CARD — a fifth spelling, and load-bearing autoSummaryFields' has() decides the same thing one rung earlier, over data?.[n] with the operands reversed, which is why the card's grep did not see it. It picks which field becomes a chip. Raw, a whitespace-only status satisfied it and won the single status slot — and a render-only fix then dropped that chip, so the header showed no status chip at all where a genuinely filled stage should have taken the slot. Fixing only the line the card names turns a blank chip into a missing one.
RecordMetaFooter.tsx:82 GENUINELY DIFFERENT — the fix is not at that line Four consumers ask "is there an actor?" about one value: hasCreated / hasUpdated, the sameUser suppression, the choice between the Created by and the "by"-less Created label, and MetaEntry's own {user ? … } gate. Only the last reaches UserRef, so line 82 is unreachable for null / undefined / '' in the first place, and converging it alone removes the blank while leaving Created by · 5m ago standing over an actor that is not there — the exact dangling phrase the label branch's own comment says it prevents. The footer is normalized at the read instead, so all four agree; line 82 stays as a converged defensive floor.

The card's guess about UserRef is contradicted by measurement. It suggested this site "may genuinely want the title predicate". It does not: UserRef hands objects to LookupCellRenderer, which resolves them through its own display chain. Measured on this branch — a bare { id: 'u1' } actor renders u1 on screen, while the title authority calls that payload empty. Delegating would have dropped the actor and fallen the label back to Created. That is now a pinned non-regression case.

What was deliberately left alone

ConcurrentUpdateDialog.tsx:65 has the same shape but a different question: it renders '' as "" on purpose, because a conflict dialog reports what is stored, where "empty string" and "absent" are facts the reader must be able to tell apart. Converging it would delete information. The reason is written into the shared module's docblock so the next census does not pattern-match its way in.

The pin, and whether something worse than the bug would pass

packages/plugin-detail/src/__tests__/detailPage.emptinessAuthority-8394.test.tsx — 11 cases, all asserting the rendered outcome, never the predicate alone. Every case carries a control that rendered by value, because "the blank is gone" is trivially true of a document that rendered nothing.

Ablations, each from the committed implementation, each proven to reach disk (git hash-object differs from git rev-parse HEAD:PATH) and restored by state (git diff HEAD empty and the hash back to the HEAD blob), never by an exit code:

ablation rows it reddens
HeaderHighlight back to raw AFFORDANCE — a whitespace-only highlight draws the No value em-dash
DetailView render back to raw CHIP — a whitespace-only summary field renders no chip
DetailView picker back to raw (i.e. the render-only fix) PICKER AND RENDER AGREE
HistoryTimeline back to raw PLACEHOLDER — a whitespace-only audit value reads as —
RecordMetaFooter renderer-only fix (reads un-normalized) THE "BY"-LESS LABEL
hasCellValue returns EMPTY for everything (deleting the feature) 10 of 11 red, including every control and both 0 / false cases
hasCellValue delegates the object half wholesale 4 red: the geolocation/multiselect chips, the object audit value, the bare { id } actor, and the measurement

So no: an implementation strictly worse than the bug does not pass — the two dedicated non-regression axes (objects still render; 0 and false are still values) are what refuse it.

Verification

  • pnpm --filter @object-ui/plugin-detail test138 files, 1242 tests, all passing
  • pnpm --filter @object-ui/plugin-detail type-check — clean (tsc --noEmit and tsc -p tsconfig.test.json)
  • package-scoped eslint . — 0 errors (905 pre-existing warnings, unchanged)
  • Affected consumers, run from the repo root with explicit positional paths: app-shell 649 files / 6261 tests, console 89 / 1069, plugin-kanban 30 / 204, plugin-calendar 30 / 216, plugin-gantt 63 / 496, runner 4 / 17, schema-catalog 29 / 2088 — all green
  • node scripts/check-changeset-presence.mjs verdict: 5 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s). Also green: check-changeset-no-major, check-changeset-fixed, check-changeset-overwrite, check-control-bytes, check-unreferenced-sources
  • node scripts/check-governed-queue-guard.mjs --test on all eight changed paths: NOT GOVERNED

Changeset: minor on @object-ui/plugin-detail, matching the landed precedent for a record:details render change (PR #8396). No major, and skip-changeset is not used — it is a phantom label here.

In-flight overlap

claude/issue-8395-copyfield-object-values is open against the same base and is expected to touch DetailSection.tsx. The overlap point is textual only and far apart: this PR removes hasCellValue plus its docblock from the top of that file (and adds one import), while #8395's subject is handleCopyField's String(value) further down. Taking the union is a plain merge; the merge queue rebuilds on current main either way.


🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…page (#8394)

objectui#8350 gave the `record:details` dedupe ladder the page H1's authority
and objectui#8376 converged `DetailSection`'s three raw `null | undefined | ''`
tests onto `hasCellValue`. Four more raw spellings were left on the same page,
none trimming, so a whitespace-only value read EMPTY at the H1 and the body grid
and FILLED at every band between and around them — each painting a blank.

`hasCellValue` moves out of `DetailSection.tsx` into `./emptiness`, and the
highlight strip, the summary chips, the audit timeline and the record footer
read it. Objects stay values: they go to type-aware renderers that draw them,
which is why the scalar half delegates to `recordDisplayValueAt` and the object
half does not.

Two sites are not a plain predicate swap:

  * `DetailView` decides this TWICE — `autoSummaryFields`' picker as well as the
    render. Raw, a whitespace-only `status` won the single status slot and the
    render then dropped the chip, so a render-only fix turns a blank chip into a
    missing one.
  * `RecordMetaFooter` asks it four times over one value and only the last of
    them reaches `UserRef`; converging that alone leaves `Created by · 5m ago`
    standing over an actor that is not there. Normalized at the read instead.

Pinned on the DOM in `detailPage.emptinessAuthority-8394.test.tsx`, whose
non-regression cases are red for a wholesale delegation and red for an emptiness
test that answers EMPTY for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.9 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CDwlij6t.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.52KB 46.34KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.67KB 63.95KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

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

Projects

None yet

2 participants