Skip to content

Commit 1623faf

Browse files
feat(spec)!: a page action row's onSuccess is refused beside a runner built-in other than api / script, and beside no executor (#23331)
Fixes #23168 Clause-②: no (narrowing) The `onSuccess` half of #23168, under triage's fork answer **C** (comment `6148462268`). The ruled half (the name leg of the url-target judgment) landed as `b529a5c23` (PR #23289, `Part of #23168`); this PR closes the card. ## What changes **A page action row's `onSuccess` is judged beside the executor the console's action runner runs.** The rows are `action:button` and `action:icon` (`properties`) and each `action:group` / `action:menu` member (`properties.actions[]`). The rule reads the rows' effective executor through `effectiveRowExecutor` (landed in `b529a5c23`): the executor key, or the row's `name` when that key is omitted or empty, with the runner's own `||`. - Refused beside the runner built-ins `url`, `navigation`, `modal`, `flow` and `form`. - Refused on a row with no effective executor (no executor key, or an empty one, and no `name`). - Accepted beside `api` and `script`. - ⛔ A name outside the runner's built-in table is a host-registered handler and stays open (`actionType: 'run'`, `name: 'clone_task'`, …). The runner hops after every host handler, and only the host knows whether its handler navigates. The accepted trade-off stands: a host handler that itself navigates, plus `onSuccess`, still navigates twice. **`ActionSchema`'s `onSuccess` refusal now gives a true reason.** Its reach is unchanged: `type` is the closed `ActionType` enum with a `script` default, so `url`, `modal`, `flow` and `form` are refused, as before. The old text said the key "would parse clean and never run" and that "no renderer reads a post-success hop today". Both were false, because the runner performs the hop after every executor that succeeds. The action and the rows now print **one refusal text** from one module, `packages/spec/src/ui/action-on-success-executor.ts` (relative import only, outside the `ui` barrel, for the reason `action-target-aliases.ts` gives). The subject names the action's `type`, or the row and the spelling that resolved its executor. The reason is stated per executor: | executor | the reason the text gives | prescription | | --- | --- | --- | | `url`, `navigation` | the action is itself the navigation, so the hop navigates a second time | the destination in `target`, the tab in `openIn`; drop `onSuccess` | | `form` | the action routes to the form view's `/forms/` page, so the hop leaves the form before it is filled in | drop `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` | | `modal` | the dialog's success is whatever its host answers, a result the contract does not declare | drop `onSuccess`, or move the hop onto an `api` / `script` action | | `flow` | the console answers success when the run starts, even for a run paused at its first screen or ended refused, so the hop fires while that screen is open | the same | | no executor (rows only) | the row names none | declare `actionType: 'api'` / `'script'` (`type` on a member), or drop `onSuccess` | Every refusal's second sentence reads: "`onSuccess` is declared beside `api` and `script` only, whose success carries the result `${result.*}` reads: the call's response, the handler's return." **The describes' wording nit** from the landing record: the `name` and `target` describes said "no `actionType`" (or "no `type`"), while the refinement also covers an empty key. They now say "no or an empty". The three `onSuccess` describes state the executor scope. `content/docs/references/ui/component.mdx` is regenerated by `gen:docs`. **Where it lives.** It is a sibling refinement, `refuseRowOnSuccessBesideExecutor`, chained after the url-target and alias refinements on all four rows. Its predicate `rowOnSuccessRefused` sits beside `effectiveRowExecutor`. The built-in table is `ActionType.options` plus `ROW_URL_NAVIGATOR_EXECUTORS`, which carries the `navigation` alias, so the vocabulary is read from the contract and never re-spelled. The issue lands at `onSuccess`. `os validate`'s props gate reports it at `properties.onSuccess` / `properties.actions.N.onSuccess` with no change to `packages/lint`. **ADR-0087.** The D3 entry `ui-page-action-row-on-success-beside-executor-refused` is a new file under `migrations/entries/semantic/`. The changeset is `minor`, BREAKING (accept-set narrowing), and carries the FROM → TO table and the marker. ## Measured before writing it (dispatch Zone 2) 1. **The built-in table.** At the `.objectui-sha` pin `1fb1ced36243`, `ActionRunner.builtinExecutors` (`core/src/actions/ActionRunner.ts:1140-1152`) is exactly `script`, `url`, `modal`, `flow`, `api`, `form`, `navigation`. The file is byte-identical at objectui `main` `72594f29`. That is `ActionType` plus `navigation`, with nothing missing and nothing extra. **Holds.** 2. **One resolution.** The rule calls `effectiveRowExecutor` once per row: `||`, the empty key included. "No effective executor" means it resolves to `undefined`. **Holds.** 3. **Where it lives.** The url-target judgment is a factory chained per row, not one shared refinement object. The `onSuccess` rule is a sibling factory, chained identically on all four rows. The props gate reports it with no lint change, as the lint pins below show. **Holds, with that shape.** 4. **The message.** One text for both, with a true reason and a prescription. **Partly corrected by measurement:** - `modal` does not hop "a second time" in the console. `useActionModal` resolves when the dialog closes: a saved form answers with the record, an OK with nothing, a dismissal as a failure. So the hop *follows* the dialog's answer, which the contract does not declare. With no host modal handler, the runner answers `{ success: true, modal }` at once. - `flow` is answered at the run's start. `judgeFlowLaunch` (app-shell `utils/flowResponse.ts`) returns `{ success: true, silent: true }` for a paused screen run and for a refused end. - The text says what each executor does, not one blanket reason. 5. **Producers.** - Zero in this repository: examples, `packages/qa`, `packages/cli` and docs have no row `onSuccess` (the `onSuccess` hits there are React callbacks). - Zero in hotcrm at `0f84c9c`. - objectui (at the pin and at `main` `72594f29`): one site goes red, named below. `check-adr-0087-registration` asked for the D3 marker, so a NEW entry file was added. ## objectui sites this turns red (no objectui edit here; the seat files the companion) - `packages/types/src/__tests__/held-public-block-arms-10872.test.ts:224`: `judge({ type, properties: { onSuccess: { navigate: '/tasks' } } }).success` is asserted `true` for `action:button` and `action:icon`, on both faces (`FACES`). Both faces reach the spec row by reference (`public-blocks.zod.ts`). Remedy, one line: give the row `actionType: 'api'` (and a `target`), as triage named. - Measured NOT red: - `action-button-icon-inputs-11168.test.tsx:474-490` (`actionType: 'run'`, a host handler: open, and not parsed through the spec). - `action-onSuccess-forward.test.tsx` (`type: 'api'`, not parsed through the spec). - `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`: its object-arm and member probes (`{}`, `{ en, zh-CN }`, `{ __objectui_member_probe__: … }`) all fail inside the `onSuccess` block first, so the row's object-level check never runs, and the verdicts are unchanged. Simulated against this branch's rows. ## Tests Read at `592e1bd8a` (the branch merged with `origin/main` `404fc477e`) unless stated. Heavy runs went through `os-verify-lock.sh`; the verdicts quoted are its VERDICT lines. - `packages/spec` full `vitest run --project local` at `a2bfa8b7a`: **669 files, 20370 passed, 1 todo**, VERDICT command-exit 0. The merge brought nothing into `packages/spec`. - `pnpm --filter @objectstack/spec typecheck` at `a2bfa8b7a`: exit 0 (tsc, scripts typecheck, test-typecheck held 52 files / 246 / 135). - `@objectstack/lint` full at `a2bfa8b7a`: **138 files, 6694 passed, 5 skipped**; `typecheck` exit 0. `validate-component-props-row-on-success.test.ts`: 32 tests (16 before). - `check:generated`: "All 14 generated artifacts are up to date". The dropped-refinements ledger is unmoved at 759 / 234. `check:api-surface` is unmoved (the new module is not exported). - New pins in `component-action-row-on-success-executor.test.ts` (section 3), on all four rows, each with a negative control: - each refused built-in through the key; - `navigation` (alias refusal plus `onSuccess` refusal); - the name leg (`name: 'form'`); - no executor, and an empty key; - accepted: `api`, `script`, host `run`, name-leg `api` / `script` / `clone_task`, and a declared `api` key outranking `name: 'url'`; - **ONE TEXT**: each row's text after its subject equals `ActionSchema`'s for the same executor. `action-on-success.test.ts` now asserts code, path and subject in place of `toContain("'api'")`. - **Ablation** at `a2bfa8b7a` (one-shot, through `scripts/ablation-replace.mjs`, WRAP mode with a trap restore). `rowOnSuccessRefused`'s body was replaced by `return executor === 'ablated-23168-onsuccess-row';`. - Mutation on disk: anchor 1 → 0, blob `42ffdc3eb2c1` → `b98144cf5270`. - spec src leg: **52 failed / 65 passed**, exactly section 3's refusal pins. Every accept and control pin passed. - spec rebuilt; `ablation-dist-preflight` found the marker in 14 dist files (exit 0). - lint dist leg: **8 failed / 24 passed** (the two refusal pins × four rows). - Restore: blob == HEAD `42ffdc3eb2c1`, `git diff HEAD` empty. Rebuild, then `--absent`: the marker is absent from all 232 dist files and the tree is clean. Lint pins 32 / 32. - **Gates** at `592e1bd8a`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 111 commands. Each exit code was recorded before any pipe. - Reconcile: `Run reconciliation — 111 derived, 110 run, 1 NOT-MEASURED, 0 UNRUN.` (exit 0). Every one of the 110 exits 0. - Six first read `PREREQUISITE NOT MET` (exit 3) on an unbuilt workspace: lint `check:doc-formula-expressions` and `check:doc-security-posture`, spec `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. All six exit 0 after `turbo run build` (74 / 74). - **NOT MEASURED:** `pnpm check:type-check-debt`. My own 900 s per-command timeout killed it (exit 124) while `--re-measure` re-ran the DEBT ledger's one entry, the workspace root program (`scripts/` and the top-level configs). This diff touches nothing in that program, and no tsconfig, typecheck script or ledger entry. - Notable lines: - `check-adr-0087-registration`: registered `ui-page-action-row-on-success-beside-executor-refused` (new here); - `check:spec-changes`: 126 converted / 442 migrated; - `check:objectui-pin-citations`: 55 asserting citations match `1fb1ced36`; - `check:nul-bytes`: exit 0, and a raw control-byte scan of the touched files finds 0 lines. - eslint, narrowed: - population: the 7 TS files of the diff (the `.md` and `.mdx` resolve to no config; `--print-config` prints `undefined`); - `--format json`: 7 files, 0 errors, 0 warnings; - invariance: no type-aware linting (no `parserOptions.project`; `eslint.config.mjs:327-329`), so this diff cannot move a verdict on an untouched file. ## Acceptance notes (observations, not filed) - **`operation: 'update'` rows.** The runner reads `operation` before any executor (`ActionRunner.ts:1384-1388`). So a row with `operation: 'update'` and no executor key runs its declarative write and then hops. Under C it is refused as a no-executor row. The prescribed `actionType: 'script'` changes nothing at run time, and `ActionSchema` itself materialises `type: 'script'` for such an action. Conversely, `ActionSchema` refuses `onSuccess` beside `operation: 'update'` on any `type`, while the rows (whose `operation` is `z.unknown()`) never carried that rule. That is outside this card's reach. Carrier: none. - `component.zod.ts`'s rows-wide "VALUE posture" docblock still lists `onSuccess` among the `z.unknown()` keys, which has been stale since the rows took the block by reference. Carrier: the next PR to touch that docblock. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14a13bc commit 1623faf

9 files changed

Lines changed: 482 additions & 56 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a page action row's `onSuccess` is refused beside a console runner built-in other than `api` / `script`, and beside a row with no executor; `ActionSchema`'s `onSuccess` refusal now states its true reason
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered ui-page-action-row-on-success-beside-executor-refused -->
10+
11+
**BREAKING**: an accept-set narrowing on one key of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards `onSuccess` to the console's action runner, which performs the post-success hop after every executor that succeeds. `ActionSchema` refuses `onSuccess` beside any `type` but `api` and `script`; the rows judged it beside no executor at all. They now carry the action's rule on the vocabulary the action's closed `ActionType` can see, read against the row's effective executor (the executor key, or the row's `name` when that key is omitted or empty, as the runner resolves it):
14+
15+
- refused beside the runner built-ins `url`, `navigation`, `modal`, `flow` and `form`;
16+
- refused on a row with no effective executor (no executor key, or an empty one, and no `name`);
17+
- accepted beside `api` and `script`, and beside any other name: that is a handler a host registers, the runner hops after it too, and only the host knows whether its handler navigates.
18+
19+
`ActionSchema`'s own reach is unchanged (`url`, `modal`, `flow` and `form` are refused, as before). Its message used to say that on those types the key "would parse clean and never run" and that "no renderer reads a post-success hop today". Both were false: the runner hops after every executor. The action and the rows now print one refusal text with the true reason for each executor.
20+
21+
### FROM → TO
22+
23+
| before | what to write instead |
24+
| --- | --- |
25+
| `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` (or `type: 'url'` on a member, or `name: 'url'` with no executor key) | the destination in `target`, the tab in `openIn`, and no `onSuccess`: the url action is itself the navigation, so the hop navigated a second time |
26+
| the same with `navigation` | the same, with `actionType: 'url'` (the alias is refused at the executor key as well) |
27+
| `{ actionType: 'form', target: 'task_form', onSuccess: … }` | no `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` |
28+
| `{ actionType: 'modal', … , onSuccess: … }` or `{ actionType: 'flow', … , onSuccess: … }` | no `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads |
29+
| `{ label: 'Go', onSuccess: { navigate: '/tasks' } }` (no executor key and no `name`) | declare the executor whose success the hop follows, `actionType: 'api'` or `actionType: 'script'` (`type` on a member), or drop `onSuccess` |
30+
31+
**The one-line fix: keep `onSuccess` only on a page action row whose executor is `api`, `script` or a host-registered handler, and declare that executor.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-on-success-beside-executor-refused`; the rewrite is by hand, because where the hop belongs is the author's call.
32+
33+
**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `onSuccess` (`properties.onSuccess`, or `properties.actions.N.onSuccess` for a member). For example, `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`actionType: 'url'`) would navigate a second time: a `url` action is itself the navigation, and the console's action runner performs the post-success hop after every executor that succeeds.``, followed by what the hop is declared beside and the prescription. The same row without `onSuccess` parses unchanged.
34+
35+
**Measured producers.** None in this repository or in hotcrm (at `0f84c9c`). In objectui (at its pin `1fb1ced36243` and its main `72594f29`), one test row with no executor and no `name` carries `onSuccess` and is asserted accepted; its remedy is to declare `actionType: 'api'` on that row. A host-handler row (`actionType: 'run'`) with `onSuccess` stays accepted.

‎content/docs/references/ui/component.mdx‎

Lines changed: 12 additions & 12 deletions
Large diffs are not rendered by default.

‎packages/lint/src/validate-component-props-row-on-success.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,35 @@ describe("the props gate refuses `'navigation'` at a page action row's executor
7676
expect(validateComponentProps(stackWith(node({ [key]: 'url', target: '/apps/crm/tasks' })))).toEqual([]);
7777
});
7878
});
79+
80+
// `onSuccess` beside the row's effective executor: `ActionSchema`'s "only beside
81+
// `api` / `script`", carried to the rows on the runner's built-in vocabulary.
82+
// Refused beside `url`, `navigation`, `modal`, `flow` and `form` and beside a
83+
// row with no executor; a host-registered handler name stays open.
84+
85+
/** How a refusal on a row's own key opens, read off the row's `navigation` subject. */
86+
const ownerOf = (subject: string) => subject.slice(0, subject.lastIndexOf(' `'));
87+
88+
describe("the props gate refuses a page action row's `onSuccess` beside an executor it cannot follow", () => {
89+
it.each(ROWS)('REFUSAL: %s with `form` and `onSuccess` is one finding at `onSuccess`', (_row, node, at, key, subject) => {
90+
const findings = validateComponentProps(stackWith(node({
91+
[key]: 'form', target: 'task_form', onSuccess: { navigate: '/tasks' },
92+
})));
93+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
94+
expect(findings[0].message).toContain(`${ownerOf(subject)} \`onSuccess\` (\`${key}: 'form'\`)`);
95+
});
96+
97+
it.each(ROWS)('REFUSAL: %s with no executor key and no `name` is one finding at `onSuccess`', (_row, node, at, key, subject) => {
98+
const findings = validateComponentProps(stackWith(node({ onSuccess: { navigate: '/tasks' } })));
99+
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
100+
expect(findings[0].message).toContain(`${ownerOf(subject)} \`onSuccess\` (no \`${key}\` and no \`name\`)`);
101+
});
102+
103+
it.each(ROWS)('ACCEPTED CONTROL: %s with a host-registered handler and `onSuccess` reports nothing', (_row, node, _at, key) => {
104+
expect(validateComponentProps(stackWith(node({ [key]: 'run', onSuccess: { navigate: '/tasks' } })))).toEqual([]);
105+
});
106+
107+
it.each(ROWS)('ACCEPTED CONTROL: %s with `form` and no `onSuccess` reports nothing', (_row, node, _at, key) => {
108+
expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]);
109+
});
110+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-page-action-row-on-success-beside-executor-refused',
7+
surface: 'the `onSuccess` of a page action row (`properties.onSuccess` on an `action:button` or `action:icon`, '
8+
+ 'and `onSuccess` on each `action:group` / `action:menu` member of `properties.actions`) whose effective '
9+
+ 'executor is a console runner built-in other than `api` / `script`: `url`, `navigation`, `modal`, `flow` or '
10+
+ '`form`, spelled by the executor key (`actionType` on a node, `type` on a member) or, with that key omitted '
11+
+ 'or empty, by the row\'s `name`. Also the `onSuccess` of a row with no effective executor: no (or an empty) '
12+
+ 'executor key and no `name`',
13+
replacement: 'the hop on an `api` or `script` row, whose success carries the result `${result.*}` reads; or no '
14+
+ '`onSuccess`. On a `url` row, the destination is the row\'s `target` (with the tab in its `openIn`); on a '
15+
+ '`form` row, where a submitted form goes is the form view\'s `submitBehavior`. A row with no executor '
16+
+ 'declares the one whose success the hop follows (`actionType: \'api\'` or `\'script\'`, `type` on a '
17+
+ 'member). A handler a host registers under another name keeps its `onSuccess`. ⛔ NOT mechanically '
18+
+ 'convertible: whether the hop belongs on another action, in the target, or nowhere is the author\'s call.',
19+
reason:
20+
'The console\'s action runner performs the post-success hop after every executor that succeeds, so the rows\' '
21+
+ '`onSuccess` was read beside every executor and judged beside none: `url`, `navigation` and `form` are '
22+
+ 'themselves navigations, so the hop navigated a second time; a `modal` hop followed whatever the dialog\'s '
23+
+ 'host answered and a `flow` hop the run\'s start, a run paused at its first screen included; and a row with '
24+
+ 'no executor dispatches on nothing. `ActionSchema` already refuses `onSuccess` beside any `type` but `api` / '
25+
+ '`script`; the rows now carry that rule on the vocabulary the action\'s closed `ActionType` can see, with '
26+
+ 'the same refusal text. A name outside the runner\'s built-in table is a host-registered handler and stays '
27+
+ 'open: the runner hops after it too, and only the host knows whether its handler navigates. A page\'s '
28+
+ '`properties` is not parsed on the metadata save or load path; `os validate`\'s props gate parses it against '
29+
+ 'these same rows and reports the refusal at the row\'s `onSuccess`. Measured producers: none in this '
30+
+ 'repository or in hotcrm; objectui\'s one is a test row with no executor asserted accepted.',
31+
acceptanceCriteria:
32+
'Every `action:button` / `action:icon` `properties.onSuccess` and every `action:group` / `action:menu` member '
33+
+ '`onSuccess` sits on a row whose effective executor is `api`, `script` or a name outside the runner\'s '
34+
+ 'built-in table; `os validate` reports no `component-props-invalid` finding at `properties.onSuccess` or '
35+
+ '`properties.actions.N.onSuccess` for the executor, and every such row parses as before.',
36+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which executor an action's post-success hop, `onSuccess`, may sit beside:
5+
* ONE pair and ONE refusal text, read by `ActionSchema` (`action.zod.ts`) and
6+
* by the page action rows (`component.zod.ts`: `action:button`, `action:icon`
7+
* and each `action:group` / `action:menu` member).
8+
*
9+
* The console's action runner performs the hop after EVERY executor that
10+
* succeeds. Each branch of `ActionRunner.execute` (a host-registered handler,
11+
* a built-in, the declarative `operation: 'update'`) ends in
12+
* `handlePostExecution`, which hands `onSuccess` to `readOnSuccessNavigation`
13+
* and `navigateOnSuccess` (`core/src/actions/ActionRunner.ts:1384-1422` and
14+
* `:1639-1642` at the `.objectui-sha` pin `1fb1ced36243`; the file is
15+
* byte-identical at objectui `main` `72594f29`). So the rule is not that the
16+
* other executors leave the hop unread. None does. The rule is about what the
17+
* hop would follow:
18+
*
19+
* - `api` and `script` succeed with a result, the call's response or the
20+
* handler's return, and `${result.*}` reads it. The hop is declared for
21+
* these two ({@link ON_SUCCESS_EXECUTORS}).
22+
* - `url`, its runner alias `navigation`, and `form` are themselves
23+
* navigations: `executeUrl` and `executeNavigation` hand off to
24+
* `navigateTo`, and `executeForm` routes to the form view's `/forms/NAME`
25+
* page. The hop is a second navigation, away from where the action went.
26+
* - `modal` opens a dialog, and its success is whatever the dialog's host
27+
* answers. The console's dialog (app-shell `useActionModal`) answers when it
28+
* closes: a saved form with the record, an OK with nothing, a dismissal as a
29+
* failure. With no host the runner answers at once. None of it is a result
30+
* the contract declares.
31+
* - `flow` starts a run, and the console's `flow` handler answers success when
32+
* the run starts. A run paused at its first screen, or one that ended
33+
* refused, answers `{ success: true, silent: true }` (`judgeFlowLaunch`,
34+
* app-shell `utils/flowResponse.ts`), so the hop fires while that screen or
35+
* the refusal notice is still open.
36+
*
37+
* A page action row adds one case `ActionSchema` cannot have, because the
38+
* action's `type` defaults to `script`: a row that names no executor. The
39+
* runner dispatches it on nothing, so no executor's success is there for the
40+
* hop to follow.
41+
*
42+
* A name outside the runner's built-in table is a host-registered handler. The
43+
* runner hops after it too, and only the host knows whether its handler
44+
* navigates, so the rows leave it open, like the executor key itself. That
45+
* half lives in `component.zod.ts`, beside the rows' effective executor.
46+
*
47+
* Why a module of its own: `action-target-aliases.ts`'s reason. The two files
48+
* must read the SAME pair and the same text; `component.zod.ts` already imports
49+
* `action.zod.ts`, so neither can live in the rows' file; and exported from
50+
* `action.zod.ts` they would ride the `ui` barrel into the published API.
51+
* Reached by relative import only.
52+
*/
53+
54+
/** The two executors the post-success hop is declared beside. */
55+
export const ON_SUCCESS_EXECUTORS: ReadonlySet<string> = new Set(['api', 'script']);
56+
57+
/** What the hop is declared beside, and why: the second sentence of every refusal below. */
58+
const DECLARED_BESIDE =
59+
'`onSuccess` is declared beside `api` and `script` only, whose success carries the result `${result.*}` '
60+
+ "reads: the call's response, the handler's return.";
61+
62+
/** The runtime fact every refusal below rests on. */
63+
const HOPS_AFTER_EVERY_EXECUTOR =
64+
"the console's action runner performs the post-success hop after every executor that succeeds";
65+
66+
/** The prescription for an executor whose success carries no declared result. */
67+
const MOVE_THE_HOP =
68+
'Drop `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads.';
69+
70+
/**
71+
* The refusal of an `onSuccess` beside `executor`: a built-in other than `api`
72+
* / `script`, or `undefined` for a page action row that names no executor.
73+
* `subject` opens it ("A `type: 'url'` action's `onSuccess`"), and
74+
* `executorKey` spells the executor the no-executor prescription asks for
75+
* (`actionType` on a page node, `type` on a member).
76+
*/
77+
export function onSuccessBesideExecutorRefusal(
78+
subject: string,
79+
executor: string | undefined,
80+
executorKey: 'type' | 'actionType',
81+
): string {
82+
switch (executor) {
83+
case 'url':
84+
case 'navigation': {
85+
const itself = executor === 'url'
86+
? 'a `url` action is itself the navigation'
87+
: "`navigation`, the runner's alias of `url`, is itself the navigation";
88+
return `${subject} would navigate a second time: ${itself}, and ${HOPS_AFTER_EVERY_EXECUTOR}. `
89+
+ `${DECLARED_BESIDE} Put the destination in \`target\`, with the tab in the top-level \`openIn\`, `
90+
+ 'and drop `onSuccess`.';
91+
}
92+
case 'form':
93+
return `${subject} would navigate a second time: a \`form\` action is itself a navigation, to the form `
94+
+ `view's \`/forms/\` page, and ${HOPS_AFTER_EVERY_EXECUTOR}, so it would leave the form before it is `
95+
+ `filled in. ${DECLARED_BESIDE} Drop \`onSuccess\`, and declare where a submitted form goes in the form `
96+
+ "view's `submitBehavior`.";
97+
case 'modal':
98+
return `${subject} would follow a result the contract does not declare: a \`modal\` action opens a `
99+
+ `dialog, whose success is whatever the dialog's host answers, and ${HOPS_AFTER_EVERY_EXECUTOR}. `
100+
+ `${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
101+
case 'flow':
102+
return `${subject} would follow the flow's start, not a result: the console answers a \`flow\` action's `
103+
+ 'success when the run starts, even for a run paused at its first screen or one that ended refused, and '
104+
+ `${HOPS_AFTER_EVERY_EXECUTOR}, so the hop fires while that screen or the refusal notice is still open. `
105+
+ `${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
106+
case undefined:
107+
return `${subject} has no executor to follow: the row names none, and the console's action runner `
108+
+ 'performs the post-success hop after the executor it runs succeeds. '
109+
+ `${DECLARED_BESIDE} Declare the executor whose success the hop follows, \`${executorKey}: 'api'\` or `
110+
+ `\`${executorKey}: 'script'\`, or drop \`onSuccess\`.`;
111+
default:
112+
return `${subject} would follow a result the contract does not declare: a \`${executor}\` action's `
113+
+ `success carries none, and ${HOPS_AFTER_EVERY_EXECUTOR}. ${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
114+
}
115+
}

‎packages/spec/src/ui/action-on-success.test.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,16 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for
147147
onSuccess: { navigate: '/x' },
148148
});
149149
expect(r.success).toBe(false);
150-
const msg = r.error!.issues.map((i) => i.message).join('\n');
151-
expect(msg).toContain('onSuccess');
152-
expect(msg).toContain("'api'");
153-
expect(msg).toContain("'script'");
150+
const issues = r.error!.issues;
151+
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]);
152+
// The subject names the action's type; the text is the one the page
153+
// action rows print (`action-on-success-executor.ts`), pinned there.
154+
expect(issues[0].message.startsWith(`A \`type: '${type}'\` action's \`onSuccess\``)).toBe(true);
155+
});
156+
157+
it.each(['api', 'script'] as const)('NEGATIVE CONTROL: accepts onSuccess on a type:%s action', (type) => {
158+
const r = ActionSchema.safeParse({ ...base, type, target: type === 'api' ? '/api/v1/x' : 'fn', onSuccess: { navigate: '/x' } });
159+
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
154160
});
155161
});
156162

0 commit comments

Comments
 (0)