Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/tidy-donkeys-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@object-ui/plugin-form": patch
---

`navigateOnSuccess` now honours a mounted host, and says so when its destination is refused

`ObjectForm` and `WizardForm` consume `navigateOnSuccess` through
`resolveSuccessNavigate`, and both arms travelled to an accepted destination with a bare
`window.location.assign`. A rooted path such as `/apps/x/o/record/{id}` 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) an authored in-app destination left the
application. Both arms now route an app-relative destination through the injected
navigation seam both components already held for `submitBehavior.url`, so a mounted host's
basename is applied. With no host seam the behaviour is byte-for-byte what it was — a host
with no router has no basename, so origin-rooted resolution is already correct there. A
same-origin ABSOLUTE destination also keeps browser-level navigation: the seam's declared
input is an application-relative path, and an author who spelled out a whole address asked
for that address.

A declared `navigateOnSuccess` whose destination is refused — a mistyped value, or a written
record carrying no usable id — used to produce a success toast identical to the one a form
with no `navigateOnSuccess` produces, so the navigation failed with nobody told. That toast
now carries a note that the declared navigation did not happen, and the template the author
wrote is logged for them. The write genuinely succeeded, so this stays a success rather than
becoming an error state.

Which destinations are ACCEPTED is unchanged: the same-origin guard, the `{id}` /
`{recordId}` dialect and the unescaped interpolation are the subject of an open contract
question and are deliberately untouched here.
68 changes: 65 additions & 3 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ import {
useSubmitRedirectNavigation,
type PendingSubmitRedirect,
} from './submitRedirectNavigation';
import { isAppRelativeDestination } from './thankYouRedirectNavigation';
import { usePermissions } from '@object-ui/permissions';
import { TabbedForm } from './TabbedForm';
import { WizardForm } from './WizardForm';
import { WizardForm, NAVIGATE_ON_SUCCESS_REFUSED_NOTE } from './WizardForm';
import { SplitForm } from './SplitForm';
import { DrawerForm } from './DrawerForm';
import { ModalForm } from './ModalForm';
Expand Down Expand Up @@ -907,10 +908,71 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
} else if (!schema.submitHandler) {
const nav = resolveSuccessNavigate(schema.navigateOnSuccess, result);
if (nav) {
window.location.assign(nav);
// WHO travels to an ACCEPTED `navigateOnSuccess` destination —
// objectui#5034 point 1, the same mount-blindness class as
// objectui#4989 defect 4 and objectui#5112.
//
// A rooted path such as `/apps/x/o/record/r1` handed to
// `window.location.assign` 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. Only the host
// knows its mount, and the seam that landed with PR #5111 is already
// wired into this component — the state below and the effect that owns
// it are 440 lines up. This arm was the one call site still bypassing
// it. `delayMs: 0` reuses that one mechanism rather than minting a
// second: this key declares no delay, and an unset delay was already a
// zero timer, i.e. "go now". Reuse also hands this arm the property
// objectui#5033 bought for the other one — unmounting cancels the wait,
// so a navigation cannot fire into a form the submitter has left.
//
// WHICH destinations are accepted is deliberately UNTOUCHED here:
// `resolveSuccessNavigate` is the authority and objectui#5548 is open on
// its contract (same-origin absolutes, the single-brace `{id}` dialect,
// the unescaped interpolation). This edit changes only who travels.
//
// The split is not a conservatism — it is the seam's own declared input
// contract. `HostNavigationValue.navigate` documents `to` as "an
// already-resolved, application-relative path, never an absolute URL …
// It is the CALLER's job to have judged the destination", and this key,
// unlike `submitBehavior.url`, is NOT relative-only: its same-origin
// guard admits an absolute `https://own-host/record/1` too. So the
// shared hook — written for a relative-only key, and correct to hand
// over everything it holds — must not be handed a value its contract
// says it never receives. Routing an absolute through a router would
// also rewrite the author's full address into a path the host then
// places somewhere else; an author who spelled the whole address asked
// for that address. Same judgement, same predicate, as objectui#5112
// made on `thankYouPage.redirectUrl`, whose acceptance set has exactly
// this shape — reused rather than re-derived.
if (isAppRelativeDestination(nav)) {
setPendingRedirect({ url: nav, delayMs: 0 });
} else {
window.location.assign(nav);
}
return result;
}
toast.success(schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'));
if (schema.navigateOnSuccess) {
// Declared, and refused (objectui#5034 point 2). This is NOT the total
// silence objectui#4989 defect 2 described — the submitter does get a
// success toast, so they are not left facing a still-filled form and are
// not invited to resubmit. The injury is narrower and real: that toast
// was indistinguishable from the one a form with no `navigateOnSuccess`
// produces, so the declared navigation failed with nobody told. The
// write genuinely succeeded, so the fix is a note on the success — not
// an error, not a blocking panel. The template goes to the console for
// the author, where naming it cannot mislead the submitter.
console.warn(
'[ObjectForm] `navigateOnSuccess` was declared but produced no destination:',
schema.navigateOnSuccess,
);
toast.success(
schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'),
{ description: NAVIGATE_ON_SUCCESS_REFUSED_NOTE },
);
} else {
toast.success(schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'));
}
}

return result;
Expand Down
66 changes: 64 additions & 2 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,46 @@ import {
useSubmitRedirectNavigation,
type PendingSubmitRedirect,
} from './submitRedirectNavigation';
import { isAppRelativeDestination } from './thankYouRedirectNavigation';
import { useOccSave } from './occSave';
import type { FormSectionConfig } from './TabbedForm';

/**
* What the submitter is told when a DECLARED `navigateOnSuccess` produced no
* destination — objectui#5034 point 2.
*
* The write succeeded, so this rides on the success toast as a note rather than
* becoming an error or a blocking panel: turning a successful write into an
* error state would be a worse lie than the silence it replaces. What was
* missing is one fact, and only one: the navigation the author declared did not
* happen. Before this, the toast was byte-identical to the toast a form with no
* `navigateOnSuccess` at all produces, so 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.
*
* Maintainer ruling, 2026-08-17: "A refused declared navigation is surfaced: the
* success toast carries a note that the declared navigation was not performed —
* never indistinguishable from the no-key case."
*
* The note names no REASON on purpose. `resolveSuccessNavigate` answers null for
* two different causes (no usable id on the written record; a destination the
* same-origin guard refused) and returns no discriminant, so a reason in this
* copy could only be re-derived by reimplementing that helper's internals at the
* call site — where it would drift from the helper, and would additionally bake
* today's acceptance rule into user-visible prose while objectui#5548 is still
* open on exactly that rule. The diagnosable detail — the template the author
* actually wrote — goes to `console.warn` at each call site instead.
*
* Lives here rather than in `successBehavior.ts` (the natural home, but read-only
* for this card) and is imported BY `ObjectForm`, which is the dependency
* direction that already exists — ObjectForm imports WizardForm, never the
* reverse. Single-sourced so a wizard and a flat form cannot tell a submitter
* two different things about one refusal; a test pins that they do not.
*/
export const NAVIGATE_ON_SUCCESS_REFUSED_NOTE =
'The `navigateOnSuccess` destination declared for this form was refused, '
+ 'so the navigation did not happen.';

// Falls back to English when no i18n provider is mounted.
const useWizardTranslation = createSafeTranslation(
{
Expand Down Expand Up @@ -580,10 +617,35 @@ export const WizardForm: React.FC<WizardFormProps> = ({
const nav = resolveSuccessNavigate(schema.navigateOnSuccess, result);
if (nav) {
// Landing on the saved record is the confirmation — no toast needed.
window.location.assign(nav);
//
// WHO travels is the same split ObjectForm's arm makes; see the long
// comment there (objectui#5034 point 1). An app-relative destination
// goes to the state the seam-owning effect above reads, so a mounted
// host's basename is applied instead of the origin root; anything
// else keeps this synchronous `window.location.assign`.
if (isAppRelativeDestination(nav)) {
setPendingRedirect({ url: nav, delayMs: 0 });
} else {
window.location.assign(nav);
}
return result;
}
toast.success(schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'));
if (schema.navigateOnSuccess) {
// Declared, and refused (objectui#5034 point 2). The write succeeded,
// so this stays a success toast — with the one fact that was missing
// attached to it. The template goes to the console for the author,
// where naming it cannot mislead the submitter.
console.warn(
'[WizardForm] `navigateOnSuccess` was declared but produced no destination:',
schema.navigateOnSuccess,
);
toast.success(
schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'),
{ description: NAVIGATE_ON_SUCCESS_REFUSED_NOTE },
);
} else {
toast.success(schema.successMessage || (schema.mode === 'create' ? 'Created' : 'Saved'));
}
if (schema.resetOnSuccess && schema.mode === 'create') {
// Back to a fresh step 1 for the next entry — same opening values
// as the first entry, defaults included (#4047).
Expand Down
Loading
Loading