Skip to content

fix(app-shell): seed record_header recordIdParam from the URL record id during the page-record load window - #5182

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-5176-record-header-recordid
Aug 18, 2026
Merged

fix(app-shell): seed record_header recordIdParam from the URL record id during the page-record load window#5182
os-steve merged 2 commits into
mainfrom
claude/issue-5176-record-header-recordid

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#5176

Diagnosis (this was the deliverable, ahead of the patch)

The card's framing — "the header has two DOM variants, and only one of them injects" — does not survive contact with the source, but the reporter's measurement does. There is no second dispatch path and no second injection site. There is one path, and its seed value depends on when you click.

Why a window exists at all. The header is not gated on the record load. isLoading — the only state the SkeletonDetail early return reads — is flipped false in a route-keyed queueMicrotask (RecordDetailView.tsx, the effect keyed on [objectName, recordId]). pageRecord lands one dataSource.findOne round-trip later, in a separate effect with its own pageRecordStatus. Between those two moments the real header renders, with every record_header action live and clickable, and pageRecord still null. The window is not a rare hydration edge — it is the full duration of the record fetch, which is exactly why a user samples it about 1 click in 10.

Why the request went out empty. In the type:'api' handler the recordIdParam seeding chain was: stashed row -> literal recordId override -> pageRecord. It stopped there. It never consulted pureRecordId, the id the page is addressed by, which is read straight off the URL (recordIdOverride ?? params.recordId) and is therefore present on the very first render. Mid-load the chain resolves to null, and resolveRecordIdParamSeeddeliberately abstains on a null row — returning neither a value nor an error, because "no row context at all" is documented as not that guard's business. So the call site injected nothing, raised nothing, and dispatched anyway. The body that went out was literally {}; the backend answered missing_record_id.

The reporter's DOM correlation is real but is a co-symptom, not the cause. The same unresolved pageRecord that starves the seed also withholds the title and highlights chrome, so the header genuinely renders a different shape in exactly the failing window. Correlation 1:1, causation neither direction — one condition produces both.

The paths disagreed, and this one was the odd one out. The three sibling dispatch paths in the same file all already fall back to pureRecordId: the legacy dataSource.update default branch, the flow trigger, and the server-action bridge. Only the api path did not.

Which limb of the triage ruling, and why

The ruling allowed either "both variants inject identically" or "disable the action while the record id is unavailable". The measurement picks the first limb, and rules the second one out on the facts: the record id is not unavailable. It is in the URL, synchronously, before the first paint. Only the record body is pending. Disabling every header action for the whole duration of every record fetch would be a real UX regression imposed to work around an id the platform already holds. So this makes the paths agree instead.

The second limb still does the work the first cannot cover, and is pinned as such: an action naming a non-default recordIdField cannot be served by the URL (which carries the id and nothing else), so it now takes the helper's named refusal rather than emitting an under-specified request. Both halves of the acceptance floor are therefore satisfied, each where it actually applies.

Change

One seeding site in packages/app-shell/src/views/RecordDetailView.tsx. A minimal row synthesized from pureRecordId becomes the last resort in the chain:

  • a stashed row and an explicit recordId override still outrank it (pinned);
  • the existing retarget guard is preserved verbatim, so a child-object action is never handed the parent page's id (pinned);
  • a non-default recordIdField gets a row without that key and takes the named refusal (pinned).

No as any was added or broadened — the two type annotations in the diff are the ones already on the lines they replace.

Tests

New sibling RecordDetailView.headerRecordIdLoadWindow.test.tsx. The 1-in-10 timing report is made deterministic by holding the window rather than sampling it: findOne returns a promise the test releases on demand, and the harness captures the ActionProvider handlers built while the record is still in flight. That discriminator is the point of the new file — the existing pin RecordDetailView.recordIdParamSeed.test.tsx waits for the capture whose context carries the loaded record, which by construction sits after the failure window, which is why it never caught this.

Red on unpatched main (3 of 7 failing, expected {} to deeply equal { planId: 'rec-plan-1' } — the empty body is the defect itself), green after. The first test in the file pins the premise the card rests on: the header really does render live actions while the record is loading.

No existing test was weakened, skipped, or quarantined; the 6 pre-existing pins in the sibling file pass unchanged.

Verified at f08cfa8ec:

  • vitest run packages/app-shell/src/views/ — 272 files, 2680 passed, 1 skipped
  • vitest run on the new file + RecordDetailView.recordIdParamSeed.test.tsx + packages/core/src/actions/__tests__/recordIdParam.test.ts — 3 files, 23 passed
  • turbo run type-check --filter @object-ui/app-shell — 30 tasks successful (app-shell runs tsc --noEmit plus tsc -p tsconfig.test.json, so the new test compiles too)
  • turbo run lint --filter @object-ui/app-shell — 0 errors (2412 pre-existing no-explicit-any warnings, unchanged)
  • check-control-bytes, check-lint-coverage, check-type-check-coverage, check-changeset-presence, check-changeset-no-major, check-changeset-fixed — all pass
  • check:action-forward-parity, check:phantom-deps, check:self-import, check:spec-symbols, check:i18n-keys, check:i18n-drift — all pass

Changeset: @object-ui/app-shell patch.

Scope

Stayed inside the dispatched file surface. DeclaredActionsBar.tsx was not touched — the second dispatch path the dispatch allowed for does not live there, because there is no second path.

One adjacent observation, deliberately left alone: if pureRecordId were itself absent the chain would still end in the helper's silent abstention. That is unreachable on this route (the record route cannot resolve without it) and closing it would mean changing behaviour for a case I could not reproduce, so it is reported rather than patched.


Generated by Claude Code

…ng the record load window
The record header does not wait for the page record. `isLoading` — the only
state the <SkeletonDetail> early return reads — is flipped false in a
route-keyed microtask, while `pageRecord` arrives one `dataSource.findOne`
round-trip later. For that whole window the real header renders with every
`record_header` action live and clickable.
Inside it, the `type:'api'` handler's `recordIdParam` seeding had no source at
all: its chain was row -> literal `recordId` override -> `pageRecord`, and it
stopped there, never consulting `pureRecordId` — the id the page is addressed
by, read off the URL and present on the first render. Handed a `null` seed,
`resolveRecordIdParamSeed` abstains by design (no value AND no error, because
"no row context" is not that guard's business), so the caller injected nothing,
raised nothing, and dispatched anyway. The request went out naming no record
and the backend answered `missing_record_id`.
Seed a minimal row from `pureRecordId` as a last resort, which is the same
fallback the other three dispatch paths in this file already use. Row and
override still outrank it, and the retarget guard is unchanged, so a
child-object action is never handed the parent page's id. An action keyed on a
non-default `recordIdField` gets a row without that key and takes the helper's
named refusal — refusing beats under-specifying.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMHbqfPiETJHA95r6WzZWS
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-R-Rj1GMp.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)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.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.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)502.20KB112.21KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.03KB44.08KB
fields (index.js)234.25KB58.48KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
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)39.16KB10.97KB
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)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.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
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.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.85KB32.73KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)123.77KB30.07KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)198.05KB53.21KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.39KB27.03KB
plugin-map (index.js)20.02KB6.58KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.97KB11.33KB
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.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)31.55KB10.70KB
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)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.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

@os-steve
os-steve marked this pull request as ready for review August 18, 2026 10:01
@os-steve
os-steve added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit 6ca910bAug 18, 2026
22 checks passed
@os-steve
os-steve deleted the claude/issue-5176-record-header-recordid branch August 18, 2026 10:01
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-steve@claude