Skip to content

fix(plugin-detail): implement sections[].group, bound a section's blast radius, and resolve enumerated field labels through the declared label - #8584

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8497-detail-section-group-and-labels
Sep 8, 2026
Merged

os-justin merged 2 commits into
mainfrom
claude/issue-8497-detail-section-group-and-labels

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8497

Two defects, two commits. Base pinned at e411c3e58; all readings below are against that commit, not against the moving origin/main.


Defect 1 — an authored sections[].group threw and blanked the page

Reproduced before touching anything, on e411c3e58:

threw: "Cannot read properties of undefined (reading 'name')"
dom:   ""            <- the well-formed sibling section is gone too

The card's framing of the evidence needs one correction

The card reads the spec's docblock as predicting a silent no-op, quoting field-group-layout.ts — "the reference surface would accept a key the declaring surface refuses, so the reference could never resolve and the section would render nothing with nothing reported." Read in place, that sentence is a counterfactual: it is the rationale for declaring FIELD_GROUP_KEY_PATTERN once, i.e. the harm that single-sourcing the key grammar prevents. It is not a statement about what an authored group does.

What the spec actually declares (installed pin @objectstack/spec 17.3.0, packages/spec/src/ui/component.zod.ts) is a fully specified, working capability:

{ group: 'contact_info' } inherits the object's fieldGroups entry with that key: its members … and its own presentation … all come from deriveFieldGroupLayout (ADR-0085 §5).

So this is a two-face disagreement, not three, and it resolves cleanly: the spec declares group as implemented; objectui's own props description said "authoring it does nothing on this renderer"; the runtime threw.

Which side was intended — measured, not assumed

git log -S on the crashing expression names exactly one commit:

commit date subject
e997708419c1d311c2126c93762578d330cb48cf 2026-05-01 feat(plugin-detail): record detail aesthetic polish (Sprint D)

That is months before objectstack#13855 declared group (2026-08-31). The throw was never a deliberate refusal of the key — DetailView mapped every section through s.fields unguarded, flatMap kept the resulting undefined as an element (it flattens arrays; undefined is not one), and the next line read .name off it. The declaration was right and the runtime had never honoured it, which is the verdict class the brief pointed at. Hence: implement group, do not make it a no-op.

Resolution goes through deriveFieldGroupDetailSections — the same adapter the synthesized default record page already uses, deliberately not a second derivation. That is what makes acceptance 4 true by construction rather than by coincidence.

Blast-radius containment is two layers, because one cannot do it

The crash fires inside a useMemo in DetailView's own body — above the section loop, outside every per-section subtree. A React error boundary catches what throws while rendering its subtree, so a per-section boundary alone could never have caught this one.

  1. sectionFieldEntries — one tolerant spelling of "every field entry named by every section", guarding the path that actually fired.
  2. Each section wrapped in the existing SchemaErrorBoundary, pushing its granularity from per-component to per-section, bounding every other way a section can throw.

An unresolvable group renders nothing and is reported once on the console. Existence is not a parse question — the spec assigns it to @objectstack/lint's page-section-group-unknown, and deriveFieldGroupLayout already drops a declared group nothing references — so dropping is the contract; vanishing without a word is not.

Defect 2 — enumerated fields reached only the i18n bundle

DetailSection renders fieldLabel(objectName, field.name, field.label || field.name) — an i18n lookup whose third argument is the fallback. The ladder is bundle key -> field.label -> field.name, and a spec-shaped section enumerates bare strings, which became { name } with no label. Rung 2 was missing entirely.

withDeclaredLabel fills it from the bound object definition. Not a new source: it is the same read the group-derived body has always done (toField, label: f.label || name) — which is exactly why the platform's synthesized page showed declared labels while a hand-authored enumeration of the same fields did not.


The caricature was run, not predicted — and it changed this PR twice

Caricature: swallow the throw and render nothing (the page stops crashing, the section silently disappears). Mutation proven on disk in both directions, restore proven by state (git diff HEAD empty and each blob hash equal to HEAD's).

leg reddens stays green
caricature (group section dropped, not resolved) SECTION_GROUP_RENDERS_MEMBERS, BODIES_AGREE SIBLINGS_SURVIVE, ONE_BAD_SECTION_IS_BOUNDED, HARNESS_KILL, all rung legs
tolerant reader reverted to the bare flatMap SECTION_WITHOUT_MEMBERS_IS_BOUNDED (with the original Cannot read properties of undefined (reading 'name')) everything else
per-section boundary made a passthrough ONE_BAD_SECTION_IS_BOUNDED, SECTION_WITHOUT_MEMBERS_IS_BOUNDED everything else
rung 2 never filled RUNG_2_DECLARED_LABEL, RUNG_1_BUNDLE_WINS, BODIES_AGREE + the three containment legs (they assert the sibling's label) RUNG_3_FIELD_NAME, AUTHORED_LABEL_WINS, SECTION_GROUP_RENDERS_MEMBERS, HARNESS_KILL

⚠️ The stated non-regression axis does not discriminate. The brief's axis — "the well-formed sections still render their fields, with their labels" — passes under the caricature, because the siblings are precisely what a caricature preserves. The assertion that separates the right fix from the plausible wrong one is that the group-referenced section renders its own members. Both are pinned, and the file says out loud which is which so nobody re-reads the weak one as the strong one.

⚠️ Two guards were unpinned until the ablation said so. With group implemented, a { group } section is resolved into one carrying fields before DetailView sees it — so every group leg passed with the tolerant reader reverted. SECTION_WITHOUT_MEMBERS_IS_BOUNDED was added specifically to pin it, and it is the only leg that does.

Each discriminating assertion was confirmed to execute in its own leg, by line number, not by assumption — e.g. RUNG_1_BUNDLE_WINS reddens on its third assertion (the field the bundle does not cover still gets its declared label), proving the first two ran and passed rather than short-circuiting it.

Harness-kill leg. Every leg navigates by requireLiveBody, whose anchor is a section no leg is about, and whose failure text (HARNESS DEAD: expected exactly 1 …) is textually unlike every content assertion. It fires in both directions — zero anchors and duplicated anchors — so a caricature cannot satisfy the harness by adding a section. No positional navigation anywhere.

Fixture triage — 9 pre-existing tests, three dispositions considered

Four files navigated rows by the raw field name, which is the behaviour defect 2 changes. All four are "change the spelling": their own object schemas already declared the labels, and their subjects (title dedupe, empty-section heuristic, hideEmpty retirement) are untouched. Two comments that documented the missing rung as if it were the contract are rewritten rather than deleted, and the one negative assertion among them was re-pointed so it cannot pass vacuously against a string the page no longer renders. Scanned all four for leftovers with a lit control.

Verification

check result
packages/plugin-detail full suite 144 files / 1292 tests pass
packages/types full suite 147 files / 2807 tests pass
plugin-detail type-check (both programs) pass — and --listFiles proves the pin file is in the test program and record-details.tsx in the src program, so neither is NOT MEASURED
packages/types type-check (three programs) pass
repo-wide eslint . (no narrowing needed) exit 0, 4517 files, 0 errors
check:control-bytes pass, 6769 files
invisible/control code points, Python scanner 0 hits, instrument proven live by a lit control injecting U+200B / U+0000 / U+FEFF / U+00A0 via chr(). ⚠️ grep -naP '[\x{200b}]' fails outright on this container and, left of an &&, renders as a clean pass
check:changeset-presence / check:changeset-no-major pass
governed-surface guard 11 paths checked, none governed

Declared narrowings. check:sdui-registration-pins exits 2 with its own "this is not a pass — build the console first" message; left to CI, because the diff adds and removes no registration (only a description string). The remaining turbo --affected packages (calendar, gantt, kanban, runner, console, examples, site) are affected through the workspace graph, not through the code touched.

⚠️ Overlaps objectui#7051 — please read before merging

objectui#7051 is open, unassigned, no linked PR, labelled pm:on-hold, and is precisely this work: "Consume the spec section group-reference form … record:details sections and view form.sections resolve group: keys via deriveFieldGroupLayout".

  • This PR delivers its record:details half.
  • Its form.sections half is still open: measured on this branch, plugin-form reads field.group (field-to-group membership) but no form-section renderer reads section.group.
  • Its on-hold gate is stale. The gate's second condition — "objectui has adopted the @objectstack/* version carrying it" — was last measured ⛔ NO on 2026-09-03. It is satisfied today: the installed pin is @objectstack/spec 17.3.0 and it declares group: SectionGroupKeySchema on RecordDetailsProps.sections[].

⛔ Deliberately not using a closing keyword for #7051 here: this PR closes only one of its two halves. Re-measure and re-queue it for the rest.

Out of scope, filed

  • objectui#8583 — RecordDetailsComponentProps.sections[] omits six member keys the spec declares (columns, icon, description, showBorder, defaultCollapsed, headerColor) and declares one it refuses (collapsed). This PR moves only the two members it needs (fields to optional, group added). Found by the compile-time type pin — and note vitest was green on the offending literal while tsc was red.

Not done

Per the card's boundaries: no browser re-drive against a current @objectstack/console (the card's own measurements are the original reporter's), and hideEmpty is untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…t's declared label

An enumerated `sections[].fields` list resolved its labels ONLY through the
i18n bundle, so every app with no translations configured rendered raw
snake_case field names on its detail pages while the object's own `label:`
sat unread one lookup away — and nothing reported it.

`DetailSection` renders `fieldLabel(objectName, field.name, field.label ||
field.name)`: an i18n lookup whose third argument is the fallback, i.e. the
ladder is `bundle key -> field.label -> field.name`. A spec-shaped section
enumerates bare strings, which `normaliseField` turned into `{ name }` with
no `label`, so rung 2 was missing entirely and the ladder collapsed to
`bundle key -> field name`.

`withDeclaredLabel` fills that rung from the bound object definition. This is
not a new source: it is the same read the GROUP-derived body has always
performed (`toField` in `synth/buildDefaultPageSchema.ts`, `label: f.label ||
name`), which is precisely why the platform's synthesized default record page
showed declared labels while a hand-authored enumeration of the same fields
did not. An authored entry `label` still wins, and an app WITH a full bundle
is untouched because rung 1 resolves before rung 2 is read.

`pickLocalized` rather than a raw read, so an inline `{ en, 'zh-CN' }` label
cannot reach the DOM as `[object Object]`.

Pins all three rungs, not just the first and the last: bundle key wins; with
no bundle the declared label wins; a field declaring no label still falls
back to its own name.

Four existing fixtures navigated rows by the raw field NAME, which is the
behaviour this commit changes, so their row identifiers move to the declared
label their own object schemas already carried. Their subjects — the title
dedupe, the empty-section heuristic, the `hideEmpty` retirement — are
untouched; only how a row is named moved. Two comments that documented the
missing rung as if it were the contract are rewritten rather than deleted, and
the one negative assertion among them was re-pointed so it cannot pass
vacuously against a string the page no longer renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…n's blast radius

An authored `{ group: 'parties' }` on `record:details` threw
`Cannot read properties of undefined (reading 'name')` and took every sibling
section down with it — one authored key blanking an entire record page.

`@objectstack/spec` 17.3.0 declares the group-reference form in full
(`RecordDetailsProps.sections[].group`, objectstack#13855): it inherits the
object's `fieldGroups` entry with that key, members and presentation both,
from `deriveFieldGroupLayout` (ADR-0085 §5). This renderer read the key
nowhere and its props description told authors it "does nothing", which was
false in the worst direction. `git log -S` on the crashing expression names
one commit, `e997708419c1d311c2126c93762578d330cb48cf` (2026-05-01), months
before the key it fires on was declared — so the throw was never a deliberate
refusal of anything. The declaration was right and the runtime had simply
never honoured it, so `group` is implemented rather than made a no-op.

Resolution goes through `deriveFieldGroupDetailSections`, the SAME adapter the
synthesized default record page already uses — deliberately, not a second
derivation: that is what makes the two bodies agree, which is what let this
ship (the synthesized page showed declared labels, the enumerated section did
not). The spread order is the spec's precedence: a group-reference section may
carry only `columns` / `showBorder` / `headerColor`, so the group owns its
presentation and the page owns its layout, with no key having two sources.

A `group` naming no declared group renders nothing and is reported once on the
console. Existence is not a parse question — the spec assigns it to
`@objectstack/lint`'s `page-section-group-unknown` — and `deriveFieldGroupLayout`
already drops a declared group nothing references, so dropping is the contract.
Vanishing without a word is not.

Blast-radius containment is two layers because one cannot do it. The crash
fired inside a `useMemo` in `DetailView`'s own body, ABOVE the section loop and
outside every per-section subtree, so a boundary alone could never have caught
it: `sectionFieldEntries` guards that path. Each section is additionally
wrapped in the existing `SchemaErrorBoundary`, pushing its granularity from
per-component to per-section, so any OTHER throw shows in the broken section's
own place while its siblings keep rendering their fields and their labels.

`@object-ui/types` is the same contract's type half: `RecordDetailsComponentProps`
required `fields` and declared no `group`, so the shape this commit implements
did not COMPILE for a TypeScript author — the same declared-versus-enforced
disagreement pointed the other way. `fields` becomes optional and `group` is
declared, exactly as the spec has them. The pin is compile-time first; writing
it also measured that the section type declares no `columns` either (nor
`icon` / `description` / `showBorder` / `headerColor` / `defaultCollapsed`),
a PRE-EXISTING divergence left alone here and filed instead.

The `inputs` description is part of the fix, not a footnote: it now documents
`group` as the reference form it is, and `fields` as required only when `group`
does not supply the members.

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) 3476.3 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-Bsvyk4nw.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.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 192.72KB 53.55KB
fields (index.js) 243.24KB 61.42KB
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.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 250.72KB 64.81KB
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.30KB 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

@os-justin
os-justin marked this pull request as ready for review September 8, 2026 13:33
@os-justin
os-justin enabled auto-merge September 8, 2026 13:33
@os-justin
os-justin added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 81a51db Sep 8, 2026
35 checks passed
@os-justin
os-justin deleted the claude/issue-8497-detail-section-group-and-labels branch September 8, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment