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
41 changes: 41 additions & 0 deletions .changeset/5034-navigateonsuccess-url-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@object-ui/plugin-form": minor
"@object-ui/types": minor
---

`navigateOnSuccess` is relative-only, escapes the interpolated id, and is deprecated in favour of `submitBehavior`

The url contract for this key was undeclared: it was same-origin-guarded (so a same-origin
ABSOLUTE value was accepted), it interpolated `{id}` / `{recordId}` without escaping the
substituted value, and nothing said which of those was intended. The maintainer ruled it on
2026-08-17: `navigateOnSuccess` is the pre-ruling ancestor of the `submitBehavior` family
rather than a second dialect, so as a compat alias it runs under the semantics
objectstack#7496 ruled for that family.

**Relative paths only.** A same-origin absolute such as `https://own-host/record/{id}` is
now refused like any other out-of-contract value, rather than accepted and navigated at
browser level. The destination is authored metadata, which is exactly where an address
somebody else chose gets copied in. Cross-origin and protocol-relative values were already
refused and still are; every relative shape that worked before still works.

**The interpolated id is URL-escaped.** `/r/{id}` with an id of `a/b c` resolved to
`/r/a/b c`, silently growing a path segment, and a template of `{id}` let the id become the
whole destination. The substituted value now goes through `encodeURIComponent`, so a token
is a value in the path and never a way to add path structure. The template is the author's
and is untouched — only the id, which is data read off the written record, is escaped.

Both halves are needed and neither implies the other: relative-only is a rule about where a
destination starts, so it cannot see structure injected further along; escaping runs only on
the substituted value, so it cannot see an absolute the author wrote out.

This can only narrow what is reachable. Every destination the key now accepts is a relative
reference, and a relative reference cannot carry an authority, so it was already accepted by
the same-origin guard this replaces — no value that was refused is now followed. With every
accepted destination relative, the browser-level `window.location.assign` fallback at both
call sites became unreachable and was removed; an accepted destination goes to the injected
navigation seam, and the absent-seam fallback inside the shared hook is unchanged.

**Deprecation.** `navigateOnSuccess` is marked `@deprecated` in favour of `submitBehavior`,
which already takes precedence over it and carries the richer `{{record.field_name}}`
interpolation. The `{id}` / `{recordId}` dialect keeps working for forms that already
declare it — the ruling converges the documentation and the semantics, not the spelling.
8 changes: 5 additions & 3 deletions packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
* and it had a real cost: a still-filled form invites a second submit, which
* writes a second record.
* - **defect 3** — a SAME-ORIGIN ABSOLUTE url is refused. The old guard
* (`isSameOriginUrl`) said yes to it, so this consumer accepted a spelling the
* authoring door refuses.
* (`isSameOriginUrl`, since deleted by objectui#5034) said yes to it, so
* this consumer accepted a spelling the authoring door refuses.
* - **defect 5** — `{{record.field_name}}` is substituted from the record the
* submit just wrote, URL-escaped.
*
Expand All @@ -32,7 +32,9 @@
*
* ## Reverse verification — predicted first, then measured (counts are measured)
*
* 1. **Restoring `isSameOriginUrl(behavior.url)` around the old assign** — i.e.
* 1. **Restoring the old same-origin guard around the old assign**
* (`isSameOriginUrl(behavior.url)`; the helper was deleted by objectui#5034,
* so the mutation is now spelled inline) — i.e.
* putting the whole pre-ruling consumption back: **6 of the 9 tests here go
* RED, 3 stay green** (12 red across both component files; WizardForm's is
* 6 of 7). The three survivors are `navigates to a ruled relative path` and
Expand Down
58 changes: 25 additions & 33 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
useSubmitRedirectNavigation,
type PendingSubmitRedirect,
} from './submitRedirectNavigation';
import { isAppRelativeDestination } from './thankYouRedirectNavigation';
import { usePermissions } from '@object-ui/permissions';
import { TabbedForm } from './TabbedForm';
import { WizardForm, NAVIGATE_ON_SUCCESS_REFUSED_NOTE } from './WizardForm';
Expand Down Expand Up @@ -917,9 +916,8 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
} else if (!schema.submitHandler) {
const nav = resolveSuccessNavigate(schema.navigateOnSuccess, result);
if (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.
// An ACCEPTED `navigateOnSuccess` destination goes to the host —
// objectui#5034, points 1 and 3.
//
// A rooted path such as `/apps/x/o/record/r1` handed to
// `window.location.assign` resolves against the ORIGIN root, so under a
Expand All @@ -928,37 +926,31 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
// 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.
// it are 440 lines up. `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.
// Handed over UNCONDITIONALLY, which is point 3's consequence rather
// than a relaxation. `resolveSuccessNavigate` now admits relative
// references only (maintainer ruling 2026-08-17: this key runs under the
// objectstack#7496 semantics, so a same-origin ABSOLUTE is refused at
// the door like any other out-of-contract value). `HostNavigationValue`
// 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" — and the caller has now judged it, once, at the
// admission door instead of twice.
//
// 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);
}
// The `window.location.assign(nav)` arm that used to stand here was
// deleted as unreachable, not as unwanted: nothing can reach it once
// every accepted value is relative. That is proved rather than reasoned
// — `navigateOnSuccess.urlContract.test.tsx` pins, over a corpus,
// that every value this helper accepts satisfies the predicate the arm
// branched on. The absent-seam fallback is unchanged and still
// `window.location.assign`; it lives in `useSubmitRedirectNavigation`.
setPendingRedirect({ url: nav, delayMs: 0 });
return result;
}
if (schema.navigateOnSuccess) {
Expand Down
4 changes: 3 additions & 1 deletion packages/plugin-form/src/WizardForm.submitRedirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
* See `ObjectForm.submitRedirect.test.tsx` for the full account; the numbers for
* this file:
*
* 1. **Restoring `isSameOriginUrl(behavior.url)` around the old assign**: **6 of
* 1. **Restoring the old same-origin guard around the old assign**
* (`isSameOriginUrl(behavior.url)`; the helper was deleted by objectui#5034,
* so the mutation is now spelled inline): **6 of
* the 7 tests go RED**, the survivor being `navigates to a ruled relative
* path` — behaviour the old line also had. The same-origin-absolute test fails
* on the assign (the old guard answers yes and navigates — defect 3); the
Expand Down
23 changes: 10 additions & 13 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
useSubmitRedirectNavigation,
type PendingSubmitRedirect,
} from './submitRedirectNavigation';
import { isAppRelativeDestination } from './thankYouRedirectNavigation';
import { useOccSave } from './occSave';
import type { FormSectionConfig } from './TabbedForm';

Expand All @@ -57,8 +56,8 @@ import type { FormSectionConfig } from './TabbedForm';
* 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
* an acceptance rule into user-visible prose, which objectui#5034 has since
* narrowed once already. 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
Expand Down Expand Up @@ -673,16 +672,14 @@ export const WizardForm: React.FC<WizardFormProps> = ({
if (nav) {
// Landing on the saved record is the confirmation — no toast needed.
//
// 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);
}
// WHO travels is what ObjectForm's arm does; see the long comment
// there (objectui#5034, points 1 and 3). The destination goes to the
// state the seam-owning effect above reads, so a mounted host's
// basename is applied instead of the origin root. Unconditionally,
// because `resolveSuccessNavigate` now accepts relative references
// only — the `window.location.assign` arm that used to stand here is
// unreachable and was deleted with the ruling that made it so.
setPendingRedirect({ url: nav, delayMs: 0 });
return result;
}
if (schema.navigateOnSuccess) {
Expand Down
Loading
Loading