Skip to content

Commit b6eb25f

Browse files
docs(spec): state the submitBehavior.url redirect contract without naming a navigation mechanism (#22714)
Fixes #22655 Clause-②: no ## What was false `packages/spec/src/ui/view.zod.ts` ships in the npm tarball (`files[]` carries `src/**/*.zod.ts`). Its docblock above the `submitBehavior.url` check said two things that are false at the pinned Console: - "objectui's `FormPage` performs a **verbatim redirect** on this value" (present tense) - "the value that reaches `window.location.assign` is this string with its tokens substituted" At `.objectui-sha` `20c6d351ad74d2b14a93becdc134d51b91b2d2e6`, read with `git show SHA:PATH` in the objectui checkout: - `apps/console/src/components/FormPage.tsx:2233` handles `case 'redirect'` by calling `resolveSubmitRedirect(behavior.url, …)` at `:2248`. A refused url calls `toast.error(verdict.refusal)` and still calls `setSubmitted(true)` (`:2264`–`:2266`). - `apps/console/src/components/submitRedirect.ts:247` substitutes each `{{record.field}}` token with `encodeURIComponent(urlValue(record[field]))`. - `FormPage.tsx:2143` calls `navigate(pendingRedirect)` from a `setTimeout` of `redirectDelayMs`, where `redirectDelayMs` is `behavior.delayMs ?? 0` (`:2140`) and `navigate` is React Router's `useNavigate()` (`:1947`). The Console's redirect arm never calls `window.location.assign`. ## Which sentences change, and which stay The #7496 ruling (comment 5250627847) says, verbatim: "a verbatim redirect on the resolved relative path IS the intended consumption." That is point 3 of the contract. It names the destination, not a navigation mechanism. Three readings agree: - The card's "Done when" paraphrases it as "the resolved relative path is the destination". - The pinned Console's own `submitRedirect.ts` header restates it as "the destination is that string with its tokens substituted, and nothing else", and places point 3's consumption in that module. - The hand-written `content/docs/ui/forms.mdx:368` (corrected in PR #22651) keeps the sentence word for word and describes the Console's mechanism in a separate paragraph. So: - **Changed (`view.zod.ts:4075`–`:4095`).** The present-tense `FormPage` sentence is now marked as history: "History, as it stood when #7496 was ruled: objectui's `FormPage` handed this value, as authored, to a browser-level navigation …". This matches the pinned `FormPage.tsx` header, which says the redirect "was consumed as a browser-level navigation on the authored string". The `window.location.assign` clause is gone. Point 3 now reads "the destination is this string with its tokens substituted, and nothing else". A new sentence follows it: the ruling fixes the destination, and the navigation mechanism belongs to each consumer. - **Unchanged.** The `url` `.describe()` (point 3, now `:4613`) and the `submitBehavior` `.describe()`. Neither is false, so the generated reference pages do not move, and `check:docs` stays clean (below). - **Why the docblock names no mechanism instead of the current one.** At the pin there are two consumers with two mechanisms. The Console's `FormPage` navigates through React Router. `@object-ui/plugin-form` (`ObjectForm`, `WizardForm`) uses a host-injected navigate when there is one, and `window.location.assign` otherwise (`packages/plugin-form/src/submitRedirectNavigation.ts:109` and `:112`). Naming either would be true of only one consumer. In `view-submit-redirect-url.test.ts`, the header's present-tense "`FormPage` redirects **verbatim**" sentence gets the same correction. Its list of ruled points keeps "verbatim redirect on the resolved path" and adds the gloss "(the destination, not a navigation mechanism)". The section heading, now `:247` ("Ruled bullet 3 — verbatim redirect on the resolved relative path"), quotes the ruling's point 3 and already matches the docblock, so it is unchanged. The comment at `:264` ("would break `FormPage`'s read") is still true at the pin, because `FormPage` hands `behavior.url` to `resolveSubmitRedirect(url: string, …)` as a string. It is unchanged too. There is no contract change: no executable line moved, so the accept set of `submitBehavior.url` is the same. ## Changeset `.changeset/22655-submit-redirect-docblock.md` is a `patch` for `@objectstack/spec`. The edited `.zod.ts` is in the published tarball; the test file is not. ## Verification All readings were taken at HEAD `4dadbc180c`, on base `d8830c2805`. - `pnpm --filter @objectstack/spec build`, run under `os-verify-lock`: `VERDICT command-exit 0`, and `check-dts-emitted` reports 38/38. - `pnpm --filter @objectstack/spec run check:docs`: exit 0, "✅ 225 generated files in sync with packages/spec". - `pnpm --filter @objectstack/spec check:generated`: exit 0, "✓ All 15 generated artifacts are up to date". - `pnpm check:spec-docblock-symbol-anchors`: exit 0, "5567 anchors across 1970 spec sources resolve … 0 NEW line anchors on tracked targets survive". Its present-tense report has 0 hits in either touched file. - `vitest run src/ui/view-submit-redirect-url.test.ts`: 54 passed (54). - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: "Test Files 642 passed (642)" and "Tests 19164 passed | 1 todo (19165)". - `pnpm --filter @objectstack/spec run typecheck`: exit 0 (tsc, the scripts typecheck, and `check:test-typecheck` OK). - `node scripts/pm/dispatch-gates.mjs --commands` derived 85 commands. All 85 ran with exit 0, and `--ran` reconciles them as "85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN". They include: - `check:nul-bytes`: 10706 files, no raw control bytes. - `check:objectui-pin-citations`: 55 citations match `20c6d351a`. - `check:doc-authoring`. - `check:api-surface`: unchanged. - The three changeset gates. - `check:issue-citations`. - `check:dts-closure`: 71 packages, 171/171 declaration files. - `check:lean-entry-closure`, `check:dual-build-cjs-loads` and `check:type-check-debt`: 1 ledger entry re-measured, none above its recorded number. There is no reverse verification or ablation, because the diff changes no executable line and so there is no behaviour to revert. ## Acceptance notes - `@object-ui/plugin-form` still falls back to `window.location.assign` when a host injects no navigate. This is an objectui-side ruling recorded in that file's header, not a defect. It is listed here only because it is why the docblock names no mechanism. Carrier: none. - The released `packages/spec/CHANGELOG.md` entry still says "the renderer still redirects verbatim". That was true when the entry was released, and release-owned text is not edited in a code PR. - `origin/main` moved to `9f159397fb` after this branch was cut. `git diff --stat HEAD...origin/main -- packages/spec content/docs/references` is empty: nothing this diff touches or regenerates moved. The merge ref is for CI to validate. --- _Generated by [Claude Code](https://claude.ai/code/session_016njDy8ozy9B9Ns5Y8kAWEK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent eae3368 commit b6eb25f

3 files changed

Lines changed: 34 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
The `submitBehavior.url` docblock in `src/ui/view.zod.ts` no longer names a navigation mechanism
6+
7+
Clause-②: no
8+
9+
The docblock above the redirect `url` check said objectui's `FormPage` performs a verbatim
10+
redirect on this value, and that the value reaching `window.location.assign` is the string with
11+
its tokens substituted. Neither is how the Console behaves at the objectui pin this package
12+
builds against (`20c6d351ad74`): it substitutes each `{{record.…}}` token, URL-escapes the
13+
value, and navigates in-app through its router. The docblock now states the ruled contract
14+
only: relative paths only, interpolation only from declared record fields with every value
15+
URL-escaped, and the resolved relative path is the destination. It says that the navigation
16+
mechanism belongs to each consumer, and marks the pre-ruling `FormPage` behaviour as history.
17+
Comment text only: no schema, key, type, export or `.describe()` string changes.

‎packages/spec/src/ui/view-submit-redirect-url.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
*
77
* ## Why this file exists at all
88
*
9-
* objectui's `FormPage` redirects **verbatim** on this value, and objectui#4190
9+
* History, as it stood when #7496 was ruled: objectui's `FormPage` handed this
10+
* value, as authored, to a browser-level navigation, and objectui#4190
1011
* dead-ended asking what the value may be: `url` was an unconstrained
1112
* `z.string()`, so `https://example.com`, `javascript:alert(1)` and
1213
* `//evil.example` all parsed, and the consumer had no contract to harden
1314
* against. The maintainer ruled the narrowest shape on 2026-08-11 —
1415
* relative-only, interpolation only from declared record fields, every
15-
* interpolated value URL-escaped, verbatim redirect on the resolved path — and
16-
* this file is where the spec side of that ruling is measurable.
16+
* interpolated value URL-escaped, verbatim redirect on the resolved path (the
17+
* destination, not a navigation mechanism) — and this file is where the spec
18+
* side of that ruling is measurable.
1719
*
1820
* ## What the assertions assert, and why it is the MESSAGE
1921
*

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4072,11 +4072,13 @@ function foldFormGroupsIntoSections<T extends WithFormSectionAlias>(
40724072
*
40734073
* ## What was ruled, and why the schema is where it lands
40744074
*
4075-
* objectui's `FormPage` performs a **verbatim redirect** on this value, and
4076-
* objectui#4190 dead-ended asking what the value may be: nothing in this
4077-
* package said whether it was a path, an address, or a template, so the
4078-
* consumer could not be hardened without inventing the contract. The ruling
4079-
* picked the narrowest shape with measured pull:
4075+
* History, as it stood when #7496 was ruled: objectui's `FormPage` handed this
4076+
* value, as authored, to a browser-level navigation, and objectui#4190
4077+
* dead-ended asking what the value may be: nothing in this package said
4078+
* whether it was a path, an address, or a template, so the consumer could not
4079+
* be hardened without inventing the contract. This paragraph records why the
4080+
* ruling was needed, not how any renderer behaves today. The ruling picked the
4081+
* narrowest shape with measured pull:
40804082
*
40814083
* 1. **relative paths only** — an absolute URL is refused. This is the
40824084
* open-redirect face: the post-submit target is authored metadata, and
@@ -4086,8 +4088,11 @@ function foldFormGroupsIntoSections<T extends WithFormSectionAlias>(
40864088
* 2. **interpolation only from declared record fields**, every interpolated
40874089
* value **URL-escaped** when the redirect is built.
40884090
* 3. a **verbatim redirect on the resolved relative path IS the intended
4089-
* consumption** — so the value that reaches `window.location.assign` is
4090-
* this string with its tokens substituted, and nothing else.
4091+
* consumption** — the destination is this string with its tokens
4092+
* substituted, and nothing else.
4093+
*
4094+
* The ruling fixes the destination, not how a renderer travels to it: the
4095+
* navigation mechanism is each consumer's own, so nothing here names one.
40914096
*
40924097
* Widening (e.g. an allowlist of absolute origins) waits for measured demand.
40934098
*

0 commit comments

Comments
 (0)