Skip to content

fix(console): nest internal /forms/:name in the console shell; internal submit lands on the created record (#4109) - #4279

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4109-internal-form-shell
Aug 11, 2026
Merged

fix(console): nest internal /forms/:name in the console shell; internal submit lands on the created record (#4109)#4279
yinlianghui merged 1 commit into
mainfrom
claude/issue-4109-internal-form-shell

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #4109 — both deliverables of the ruling are implemented, but one half of deliverable 1 (the sidebar) is deliberately left open rather than faked; see "What is NOT in this PR". The card should stay open for that call.

The ruling this implements

Maintainer ruling of 2026-08-10, quoted verbatim on #4109 (from objectstack#7245) — reproduced untranslated because it is the spec:

the type: 'form' contract means in-shell, and an internal submit lands on the record.

  1. /forms/:name in mode="internal" nests inside the console shell (keep the route — deep-linking survives; the missing chrome is the defect, not the navigation).
  2. Internal-mode submit defaults to redirect-to-created-record; thank-you stays the default for the public /f/:slug path only.
  3. showcase_task.edit may declare a submitBehavior as an interim corpus fix, but the platform default is the ruling above — the corpus must not have to opt out of a wrong default.

Premise re-verified against origin/main

Both measured starting points on the card are still true at 4cb0562b5 (the card cited a ~line 233 that has since drifted to 217):

  • apps/console/src/App.tsx:217/forms/:name was a top-level route, a sibling of the app-shell routes.
  • apps/console/src/components/FormPage.tsx:555const behavior: SubmitBehavior = loaded?.form?.submitBehavior ?? { kind: 'thank-you' }, unconditional across both modes.

1. In-shell (ruling point 1)

The route stays exactly where it is; only its element changes, to a new InternalFormRoute that wraps FormPage in the console's layout for app-independent authed pages — the same chrome /home and /organizations already use. The public /f/:slug path is untouched and stays chrome-less.

2. Mode-aware submit default (ruling points 2 and 3)

resolveSubmitBehavior(mode, declared) is a pure exported function: a declared submitBehavior is returned as-is in both modes, and only the empty case differs — internal gets "land on the created record", public keeps thank-you.

No authorable surface changed. The "land on the created record" behaviour is deliberately not a new submitBehavior.kind: the spec's union (thank-you / redirect / continue / next-record) is a strict discriminated union and stays exactly as it is. Nothing parses the new internal default out of metadata — it is only what the renderer does when an author declared nothing, which is what lets the platform default differ from every authorable kind without widening the contract.

The seam: what the submit call actually returns

The redirect needs the created record's id, so I verified the response contract rather than assuming it. POST /api/v1/data/:object answers the spec-declared CreateDataResponse = { object, id, record, droppedFields? } (@objectstack/spec, api/protocol.zod.ts), and packages/rest's server returns it bare (res.status(201).json(result)).

readCreatedRecordId reads that one declared key. record.id carries the same value and is deliberately NOT read as an alias — that would be the second de-facto contract AGENTS.md #0.1 forbids, and it is pinned by a negative test.

What it does absorb is the transport envelope, which is not a metadata dialect but a platform fact: the runtime's http-dispatcher wraps every success as { success, data, meta } while the REST server does not. The platform already resolves this in exactly ONE rule, in @objectstack/client.unwrapResponse; FormPage hand-rolls fetch instead of going through that client, so the same rule is applied at this call site. Mirroring it is not inventing a dialect — spelling a different one would be. Both shapes are pinned.

A response naming no id, or a workspace where no app can host the record page, falls back to the confirmation panel rather than navigating to record/undefined — the record really was created, so silence would be the worse answer.

What is NOT in this PR, and why

The sidebar. The source card measured "no sidebar, navigation, or breadcrumb". This PR delivers the header/navigation; it does not mount UnifiedSidebar, and that is a judgement I did not want to make silently:

ConsoleLayout (the layout that owns the sidebar and breadcrumb) is app-SCOPED by construction — it takes an activeAppName/activeApp and publishes them as the shell's current app. /forms/:name names no app, so mounting it here means inventing one, and on a cold deep-link that invention resolves to whichever app happens to be first. Wrapping someone's form in an arbitrary app's sidebar and breadcrumb — and writing that guess into shared navigation state on the way past — is worse than no sidebar.

Doing it properly needs app-shell's own resolveHostAppSegment (utils/appRoute.ts), which is written for exactly this case ("a framework-owned, app-independent page") but is not reachable: @object-ui/app-shell exports only its package root, and that root re-exports ./utils nowhere. Exporting it was out of scope here (a parallel agent holds that package). The same unavailability is why createdRecordPath.ts implements only the first two steps of that resolver, documented as a subset with the divergence spelled out.

?recordId= is still ignored by this route — filed as #4278, not fixed here.

Tests

New: createdRecordPath.test.ts (host-app policy), FormPage.submit.test.tsx (rendered submit behaviour), internalFormShell.test.tsx (the route table). Extended: FormPage.test.ts with resolveSubmitBehavior + readCreatedRecordId.

internalFormShell.test.tsx renders the realApp.tsx route table rather than a transcription, for the reason AppContent.systemHubRoutes.test.tsx documents at length: a hand-copied route list is free to agree with whatever the source does.

Reverse verification (predicted, then measured)

Change removedPredictedMeasured
resolveSubmitBehavior back to thank-you for both modesthe two internal-default pins go red, everything else greenexactly 2 red / 51 green — expected { kind: 'thank-you' } to deeply equal { kind: 'created-record' } and expected '/forms/showcase_task.edit' to be '/apps/…/record/task-42'
route element back to bare FormPage mode="internal"the shell pin goes red, the chrome-less pin stays green1 red / 1 green — Unable to find an element by: [data-testid="console-shell-layout"]

Honest note on the other pins: the two precedence tests and the public-default test are guards, green before and after by design. They are here to stop a future "smarter default" from swallowing a declared behaviour, and to stop the new internal default leaking onto the anonymous path — a pin that only goes red with its own change would cover neither risk.

Commands

pnpm exec vitest run apps/console/ → 37 files, 391 tests passed
cd apps/console && pnpm exec tsc --noEmit → exit 0
cd apps/console && pnpm exec tsc -b tsconfig.node.json --force → exit 0
pnpm exec eslint (new files) → 0 problems
node scripts/check-changeset-presence.mjs → pass (changeset added, minor)
node scripts/check-changeset-no-major.mjs → pass

Lint delta on FormPage.tsx is +2 react-refresh/only-export-components warnings, one per new exported pure function — the same pattern the file already had 5 of. Warnings, not errors.

Not browser-verified

Deliverable 3 (the showcase journey end-to-end in a browser) was not performed — no dev stack was booted in this environment. It is compensated with the route-level and component-level tests above, not claimed. Worth a browser pass on acceptance, particularly the cold-deep-link case where no app has been published to navigation context yet.


Generated by Claude Code

…al submit lands on the created record (#4109)
A `type: 'form'` action navigates to `/forms/:name`, which was declared at
the TOP level of the route tree — a sibling of the app-shell routes — so
clicking a button inside an app dropped the user onto a bare form with no
header, no navigation and no way back. The route is unchanged (deep links
keep working; the missing chrome was the defect, not the navigation); it now
renders inside the console's layout for app-independent authed pages, the
same chrome /home and /organizations use. The public /f/:slug path stays
chrome-less — an anonymous visitor has no console to be inside.
The post-submit default was `{ kind: 'thank-you' }` for BOTH modes, so a
signed-in operator who had just created a record got the anonymous
confirmation with no link to it. `resolveSubmitBehavior` makes the default
mode-aware: internal lands on the created record, public keeps thank-you. A
declared `submitBehavior` still wins in both modes — the point of a default
is that the corpus never has to opt out of a wrong one.
The record id comes from the spec-declared `CreateDataResponse = { object,
id, record }`; only that one declared key is read (`record.id` carries the
same value, but reading both would be a second contract for one fact). No
authorable surface changed: the internal default is deliberately not a new
`submitBehavior.kind`, and nothing parses it out of metadata.
Per the maintainer ruling of 2026-08-10 quoted verbatim on objectui#4109
(from objectstack#7245).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 10:47am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.9 KB350 KB
Entry fileindex-gOolFs_6.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.88KB3.25KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
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)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
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)4.91KB0.87KB
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)488.62KB108.26KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.45KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)16.38KB5.47KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
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.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.73KB17.54KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.79KB30.79KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.88KB59.71KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.97KB49.90KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)109.18KB26.48KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
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)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 11:01
@yinlianghui
yinlianghui added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit 90e792eAug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4109-internal-form-shell branch August 11, 2026 11:01
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.

2 participants

@yinlianghui@claude