Skip to content

plugin-form: route navigateOnSuccess through the host navigation seam, and say when a destination is refused (#5034) - #5558

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5034-navigateonsuccess-mount-seam
Aug 21, 2026
Merged

plugin-form: route navigateOnSuccess through the host navigation seam, and say when a destination is refused (#5034)#5558
os-sales merged 1 commit into
mainfrom
claude/issue-5034-navigateonsuccess-mount-seam

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Refs #5034

Lands points 1 and 2 of #5034. Point 3 — the url contract for this key — is deliberately not touched here and stays with the decision card #5548.

resolveSuccessNavigate and submitRedirectNavigation.ts are unmodified. Which values are accepted is byte-identical; this PR changes only who navigates once a value is accepted and what is said when one is refused.


Point 1 — mount-blindness

Both call sites — ObjectForm's else if (!schema.submitHandler) arm and WizardForm's trailing else — travelled to an accepted destination with a bare window.location.assign(nav). A rooted path such as /apps/x/o/record/r1 assigned that way resolves against the origin root, so under a host mounted at a sub-path (the framework CLI configures one for every embedded deployment; the console runs at basename /_console) an authored in-app destination left the application.

The mechanism for this was already ruled and already landed — PR #5111, the optional injected navigation seam — and both components already held it: the pendingRedirect state and useSubmitRedirectNavigation sit ~440 lines above each of these arms, imported and wired for submitBehavior.url. This arm was the one call site still bypassing it. No second seam was built; the rejected alternatives recorded in submitRedirectNavigation.ts's module comment were not re-derived.

The delayMs judgement call.PendingSubmitRedirect carries delayMs and navigateOnSuccess has no delay concept. This reuses the same hook with delayMs: 0, as the dispatch leaned toward. The seam's own comment notes an unset delay was already "go now" — a zero timer — so this is the existing semantics, not a new case. Reuse also hands this arm the property #5033 bought for the other one: unmounting cancels the wait, so a navigation cannot fire into a form the submitter has left.

An arm split, required by the seam's own contract rather than by caution.submitBehavior.url is relative-only (objectstack#7496), so the shared hook is correct to hand the host everything it ever holds. navigateOnSuccess is not relative-only — its same-origin guard accepts https://own-host/record/1 too — and HostNavigationValue.navigate declares:

to is an already-resolved, application-relative path … never an absolute URL: a renderer that holds an external destination must not launder it through the host's router. It is the CALLER's job to have judged the destination.

So this call site judges. An app-relative destination goes through the seam; a same-origin absolute keeps the browser-level window.location.assign it has always had. Routing an absolute through a router would rewrite the author's full address into a path the host then places at a different one — an author who spelled out the whole address asked for that address.

This is the same judgement #5112 made on thankYouPage.redirectUrl, whose acceptance set has exactly this shape, and its predicate isAppRelativeDestination is reused rather than re-derived — as the cross-reference comment on the card invited. It leaves the acceptance set untouched either way: if #5548 rules convergence and refuses same-origin absolutes at the door, that branch simply becomes unreachable and can be deleted.

Point 2 — a refused destination was reported only as a generic success

Matching the card's own correction of #4989's overstatement: this was not total silence. The submitter does get toast.success, so they are not left facing a still-filled form and are not invited to resubmit. The injury is that the toast was indistinguishable from the no-navigateOnSuccess case — an author who mistyped the destination, or whose record carried no usable id, saw a form that looked entirely healthy and had silently stopped honouring a key they wrote.

The write genuinely succeeded, so this stays a success, not an error and not a blocking panel. The success toast now carries a note, per the maintainer ruling of 2026-08-17 ("the success toast carries a note that the declared navigation was not performed — never indistinguishable from the no-key case"). The authored template goes to console.warn for the author.

The note names no reason on purpose: resolveSuccessNavigate answers null for two different causes and returns no discriminant, so a reason could only be re-derived by reimplementing its internals at the call site — where it would drift, and would bake today's acceptance rule into user-visible prose while #5548 is open on exactly that rule.

The note is single-sourced (NAVIGATE_ON_SUCCESS_REFUSED_NOTE) so a wizard and a flat form cannot tell a submitter two different things about one refusal, and a test pins that they do not. It lives in WizardForm.tsx rather than in the natural home successBehavior.ts, which is read-only for this card; ObjectForm already imports WizardForm, so this is the dependency direction that exists and adds no cycle. A reviewer may prefer a small shared module — the file fence did not permit creating one, so flagging it rather than silently widening scope.


Verification

All runs local, through the shared verify lock, on the exact pushed tree 156a31274. Exit codes captured before any pipe.

runresult
pnpm exec vitest run packages/plugin-form/58 files / 597 tests passed, VITEST_EXIT=0
pnpm --filter @object-ui/plugin-form type-checkTYPECHECK_EXIT=0
pnpm lint (full repo, turbo run lint)FULL_LINT_EXIT=047/47 tasks, 0 errors
node scripts/check-control-bytes.mjs0 — "OK (scanned 4604 tracked text file(s))"
node scripts/check-changeset-presence.mjs0 — "3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"
node scripts/check-lint-coverage.mjs0 — "46/46 packages linted, 0 with outstanding errors"

The full repo lint ran, so no narrowed-scan claim is being made.

⚠️CI has never seen this tree. The GitHub API write path was unavailable while this was completed, so no gate job has run. Every result above is a local run.

Reverse verification — direction and counts predicted before running

Both predictions are recorded in the test file's docblock (17 cases total) and both were measured exactly:

mutationpredictedmeasured
A — replace the arm split at both call sites with the pre-change bare window.location.assign(nav)3 red / 14 green3 failed, 14 passed
B — drop the { description } argument from both success toasts7 red / 10 green7 failed, 10 passed

Mutation A's red set is the 2 cases (one per component) asserting a host navigate received an app-relative destination, plus the mounted-host placement case. Its green set is named deliberately rather than counted as credit: the 2 absent-seam cases and the 2 same-origin-absolute cases should survive — they describe behaviour that was already correct. Those are the negative control; without them an implementation that also replaced the no-provider fallback, or that laundered an absolute through the host router, would pass this file just as well. The 8 point-2 cases survive because the refusal note is independent of the navigation site.

Mutation B's green set includes the 2 "no key declared" cases, which assert the absence of a note — they make the distinction the defect is about measurable rather than detecting this mutation.

Both mutation legs ran under a trap … EXIT INT TERM restore, each mutation was proved on disk by grep counts of the injected and removed text before the suite was read, and the tree was confirmed byte-identical to HEAD afterwards. Mutation B's first anchor missed (ANCHOR MISS: expected 1 occurrence, found 0) and the script refused to run rather than reporting a green no-op; the anchor was corrected and re-run.

One property asserted by construction rather than by a case

Unmount-cancellation reaches this arm because it reuses the same hook, but with delayMs: 0 the window between arming and firing is a single macrotask. A test of it would be either a race or a test of the timer rather than of this arm's wiring, so it is not claimed here; it stays pinned against a declared delay in submitRedirect.timerLifetime.test.tsx.

Note on the local test invocation

pnpm --filter @object-ui/plugin-form test is refused by a repo guard (objectui#3378) because running vitest from a package directory silently runs @object-ui/console's 22 files and reports them as green. The suite above was therefore run from the repo root, which is what CI does.


🤖 Generated with Claude Code

https://claude.ai/code/session_014zHsbJoTkTZeJQ5DLbRXrE


Generated by Claude Code

…ation (#5034)
Points 1 and 2 of objectui#5034. Point 3 (the url contract for this key) is
deliberately untouched and remains with objectui#5548.
Point 1 — mount-blindness. Both call sites (`ObjectForm`'s
`else if (!schema.submitHandler)` arm and `WizardForm`'s trailing `else`)
travelled to an accepted destination with a bare `window.location.assign`,
which resolves a rooted path against the ORIGIN root and so leaves the
application under a host mounted at a sub-path. Both now route an app-relative
destination through `pendingRedirect` + `useSubmitRedirectNavigation` — the
seam PR #5111 landed for `submitBehavior.url`, already imported and wired into
both components ~440 lines above these arms. `delayMs: 0` reuses that one
mechanism rather than minting a second, and incidentally gives this arm the
unmount-cancellation property objectui#5033 bought for the other one.
A same-origin ABSOLUTE destination keeps browser-level navigation.
`submitBehavior.url` is relative-only so the shared hook is right to hand over
everything it holds; this key is not, and `HostNavigationValue.navigate`
declares `to` to be "an already-resolved, application-relative path, never an
absolute URL ... It is the CALLER's job to have judged the destination". Same
judgement, same reused predicate, as objectui#5112 made on
`thankYouPage.redirectUrl`.
Point 2 — a refused destination was reported only as a generic success. The
toast was byte-identical to the one a form with no `navigateOnSuccess`
produces, so a mistyped destination or a record with no usable id failed with
nobody told. The success toast now carries a note, and the authored template is
logged for the author. The write succeeded, so this stays a success rather than
becoming an error state.
WHICH destinations are accepted is unchanged; `resolveSuccessNavigate` and
`submitRedirectNavigation.ts` are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zHsbJoTkTZeJQ5DLbRXrE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.0 KB3867.2 KB
Main entry chunk (gzip)151.5 KB350 KB
Entry fileindex-C--Vpr9o.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.52KB59.62KB
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.94KB
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 11:04
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 26a2238Aug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5034-navigateonsuccess-mount-seam branch August 21, 2026 11:05
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

@os-sales@claude