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
35 changes: 35 additions & 0 deletions .changeset/23168-page-action-row-on-success-beside-executor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
---

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

Clause-②: no (narrowing)

<!-- adr-0087: registered ui-page-action-row-on-success-beside-executor-refused -->

**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.

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):

- 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`, 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.

`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.

### FROM → TO

| before | what to write instead |
| --- | --- |
| `{ 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 |
| the same with `navigation` | the same, with `actionType: 'url'` (the alias is refused at the executor key as well) |
| `{ actionType: 'form', target: 'task_form', onSuccess: … }` | no `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` |
| `{ actionType: 'modal', … , onSuccess: … }` or `{ actionType: 'flow', … , onSuccess: … }` | no `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads |
| `{ 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` |

**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.

**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.

**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.
24 changes: 12 additions & 12 deletions content/docs/references/ui/component.mdx

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions packages/lint/src/validate-component-props-row-on-success.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,35 @@ describe("the props gate refuses `'navigation'` at a page action row's executor
expect(validateComponentProps(stackWith(node({ [key]: 'url', target: '/apps/crm/tasks' })))).toEqual([]);
});
});

// `onSuccess` beside the row's effective executor: `ActionSchema`'s "only beside
// `api` / `script`", carried to the rows on the runner's built-in vocabulary.
// Refused beside `url`, `navigation`, `modal`, `flow` and `form` and beside a
// row with no executor; a host-registered handler name stays open.

/** How a refusal on a row's own key opens, read off the row's `navigation` subject. */
const ownerOf = (subject: string) => subject.slice(0, subject.lastIndexOf(' `'));

describe("the props gate refuses a page action row's `onSuccess` beside an executor it cannot follow", () => {
it.each(ROWS)('REFUSAL: %s with `form` and `onSuccess` is one finding at `onSuccess`', (_row, node, at, key, subject) => {
const findings = validateComponentProps(stackWith(node({
[key]: 'form', target: 'task_form', onSuccess: { navigate: '/tasks' },
})));
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
expect(findings[0].message).toContain(`${ownerOf(subject)} \`onSuccess\` (\`${key}: 'form'\`)`);
});

it.each(ROWS)('REFUSAL: %s with no executor key and no `name` is one finding at `onSuccess`', (_row, node, at, key, subject) => {
const findings = validateComponentProps(stackWith(node({ onSuccess: { navigate: '/tasks' } })));
expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess`]]);
expect(findings[0].message).toContain(`${ownerOf(subject)} \`onSuccess\` (no \`${key}\` and no \`name\`)`);
});

it.each(ROWS)('ACCEPTED CONTROL: %s with a host-registered handler and `onSuccess` reports nothing', (_row, node, _at, key) => {
expect(validateComponentProps(stackWith(node({ [key]: 'run', onSuccess: { navigate: '/tasks' } })))).toEqual([]);
});

it.each(ROWS)('ACCEPTED CONTROL: %s with `form` and no `onSuccess` reports nothing', (_row, node, _at, key) => {
expect(validateComponentProps(stackWith(node({ [key]: 'form', target: 'task_form' })))).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

export const entry: SemanticMigration = {
id: 'ui-page-action-row-on-success-beside-executor-refused',
surface: 'the `onSuccess` of a page action row (`properties.onSuccess` on an `action:button` or `action:icon`, '
+ 'and `onSuccess` on each `action:group` / `action:menu` member of `properties.actions`) whose effective '
+ 'executor is a console runner built-in other than `api` / `script`: `url`, `navigation`, `modal`, `flow` or '
+ '`form`, spelled by the executor key (`actionType` on a node, `type` on a member) or, with that key omitted '
+ 'or empty, by the row\'s `name`. Also the `onSuccess` of a row with no effective executor: no (or an empty) '
+ 'executor key and no `name`',
replacement: 'the hop on an `api` or `script` row, whose success carries the result `${result.*}` reads; or no '
+ '`onSuccess`. On a `url` row, the destination is the row\'s `target` (with the tab in its `openIn`); on a '
+ '`form` row, where a submitted form goes is the form view\'s `submitBehavior`. A row with no executor '
+ 'declares the one whose success the hop follows (`actionType: \'api\'` or `\'script\'`, `type` on a '
+ 'member). A handler a host registers under another name keeps its `onSuccess`. ⛔ NOT mechanically '
+ 'convertible: whether the hop belongs on another action, in the target, or nowhere is the author\'s call.',
reason:
'The console\'s action runner performs the post-success hop after every executor that succeeds, so the rows\' '
+ '`onSuccess` was read beside every executor and judged beside none: `url`, `navigation` and `form` are '
+ 'themselves navigations, so the hop navigated a second time; a `modal` hop followed whatever the dialog\'s '
+ 'host answered and a `flow` hop the run\'s start, a run paused at its first screen included; and a row with '
+ 'no executor dispatches on nothing. `ActionSchema` already refuses `onSuccess` beside any `type` but `api` / '
+ '`script`; the rows now carry that rule on the vocabulary the action\'s closed `ActionType` can see, with '
+ 'the same refusal text. A name outside the runner\'s built-in table is a host-registered handler and stays '
+ 'open: the runner hops after it too, and only the host knows whether its handler navigates. A page\'s '
+ '`properties` is not parsed on the metadata save or load path; `os validate`\'s props gate parses it against '
+ 'these same rows and reports the refusal at the row\'s `onSuccess`. Measured producers: none in this '
+ 'repository or in hotcrm; objectui\'s one is a test row with no executor asserted accepted.',
acceptanceCriteria:
'Every `action:button` / `action:icon` `properties.onSuccess` and every `action:group` / `action:menu` member '
+ '`onSuccess` sits on a row whose effective executor is `api`, `script` or a name outside the runner\'s '
+ 'built-in table; `os validate` reports no `component-props-invalid` finding at `properties.onSuccess` or '
+ '`properties.actions.N.onSuccess` for the executor, and every such row parses as before.',
};
115 changes: 115 additions & 0 deletions packages/spec/src/ui/action-on-success-executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Which executor an action's post-success hop, `onSuccess`, may sit beside:
* ONE pair and ONE refusal text, read by `ActionSchema` (`action.zod.ts`) and
* by the page action rows (`component.zod.ts`: `action:button`, `action:icon`
* and each `action:group` / `action:menu` member).
*
* The console's action runner performs the hop after EVERY executor that
* succeeds. Each branch of `ActionRunner.execute` (a host-registered handler,
* a built-in, the declarative `operation: 'update'`) ends in
* `handlePostExecution`, which hands `onSuccess` to `readOnSuccessNavigation`
* and `navigateOnSuccess` (`core/src/actions/ActionRunner.ts:1384-1422` and
* `:1639-1642` at the `.objectui-sha` pin `1fb1ced36243`; the file is
* byte-identical at objectui `main` `72594f29`). So the rule is not that the
* other executors leave the hop unread. None does. The rule is about what the
* hop would follow:
*
* - `api` and `script` succeed with a result, the call's response or the
* handler's return, and `${result.*}` reads it. The hop is declared for
* these two ({@link ON_SUCCESS_EXECUTORS}).
* - `url`, its runner alias `navigation`, and `form` are themselves
* navigations: `executeUrl` and `executeNavigation` hand off to
* `navigateTo`, and `executeForm` routes to the form view's `/forms/NAME`
* page. The hop is a second navigation, away from where the action went.
* - `modal` opens a dialog, and its success is whatever the dialog's host
* answers. The console's dialog (app-shell `useActionModal`) answers when it
* closes: a saved form with the record, an OK with nothing, a dismissal as a
* failure. With no host the runner answers at once. None of it is a result
* the contract declares.
* - `flow` starts a run, and the console's `flow` handler answers success when
* the run starts. A run paused at its first screen, or one that ended
* refused, answers `{ success: true, silent: true }` (`judgeFlowLaunch`,
* app-shell `utils/flowResponse.ts`), so the hop fires while that screen or
* the refusal notice is still open.
*
* A page action row adds one case `ActionSchema` cannot have, because the
* action's `type` defaults to `script`: a row that names no executor. The
* runner dispatches it on nothing, so no executor's success is there for the
* hop to follow.
*
* A name outside the runner's built-in table is a host-registered handler. The
* runner hops after it too, and only the host knows whether its handler
* navigates, so the rows leave it open, like the executor key itself. That
* half lives in `component.zod.ts`, beside the rows' effective executor.
*
* Why a module of its own: `action-target-aliases.ts`'s reason. The two files
* must read the SAME pair and the same text; `component.zod.ts` already imports
* `action.zod.ts`, so neither can live in the rows' file; and exported from
* `action.zod.ts` they would ride the `ui` barrel into the published API.
* Reached by relative import only.
*/

/** The two executors the post-success hop is declared beside. */
export const ON_SUCCESS_EXECUTORS: ReadonlySet<string> = new Set(['api', 'script']);

/** What the hop is declared beside, and why: the second sentence of every refusal below. */
const DECLARED_BESIDE =
'`onSuccess` is declared beside `api` and `script` only, whose success carries the result `${result.*}` '
+ "reads: the call's response, the handler's return.";

/** The runtime fact every refusal below rests on. */
const HOPS_AFTER_EVERY_EXECUTOR =
"the console's action runner performs the post-success hop after every executor that succeeds";

/** The prescription for an executor whose success carries no declared result. */
const MOVE_THE_HOP =
'Drop `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads.';

/**
* The refusal of an `onSuccess` beside `executor`: a built-in other than `api`
* / `script`, or `undefined` for a page action row that names no executor.
* `subject` opens it ("A `type: 'url'` action's `onSuccess`"), and
* `executorKey` spells the executor the no-executor prescription asks for
* (`actionType` on a page node, `type` on a member).
*/
export function onSuccessBesideExecutorRefusal(
subject: string,
executor: string | undefined,
executorKey: 'type' | 'actionType',
): string {
switch (executor) {
case 'url':
case 'navigation': {
const itself = executor === 'url'
? 'a `url` action is itself the navigation'
: "`navigation`, the runner's alias of `url`, is itself the navigation";
return `${subject} would navigate a second time: ${itself}, and ${HOPS_AFTER_EVERY_EXECUTOR}. `
+ `${DECLARED_BESIDE} Put the destination in \`target\`, with the tab in the top-level \`openIn\`, `
+ 'and drop `onSuccess`.';
}
case 'form':
return `${subject} would navigate a second time: a \`form\` action is itself a navigation, to the form `
+ `view's \`/forms/\` page, and ${HOPS_AFTER_EVERY_EXECUTOR}, so it would leave the form before it is `
+ `filled in. ${DECLARED_BESIDE} Drop \`onSuccess\`, and declare where a submitted form goes in the form `
+ "view's `submitBehavior`.";
case 'modal':
return `${subject} would follow a result the contract does not declare: a \`modal\` action opens a `
+ `dialog, whose success is whatever the dialog's host answers, and ${HOPS_AFTER_EVERY_EXECUTOR}. `
+ `${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
case 'flow':
return `${subject} would follow the flow's start, not a result: the console answers a \`flow\` action's `
+ 'success when the run starts, even for a run paused at its first screen or one that ended refused, and '
+ `${HOPS_AFTER_EVERY_EXECUTOR}, so the hop fires while that screen or the refusal notice is still open. `
+ `${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
case undefined:
return `${subject} has no executor to follow: the row names none, and the console's action runner `
+ 'performs the post-success hop after the executor it runs succeeds. '
+ `${DECLARED_BESIDE} Declare the executor whose success the hop follows, \`${executorKey}: 'api'\` or `
+ `\`${executorKey}: 'script'\`, or drop \`onSuccess\`.`;
default:
return `${subject} would follow a result the contract does not declare: a \`${executor}\` action's `
+ `success carries none, and ${HOPS_AFTER_EVERY_EXECUTOR}. ${DECLARED_BESIDE} ${MOVE_THE_HOP}`;
}
}
14 changes: 10 additions & 4 deletions packages/spec/src/ui/action-on-success.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,16 @@ describe('ActionSchema.onSuccess — one closed post-success navigation key for
onSuccess: { navigate: '/x' },
});
expect(r.success).toBe(false);
const msg = r.error!.issues.map((i) => i.message).join('\n');
expect(msg).toContain('onSuccess');
expect(msg).toContain("'api'");
expect(msg).toContain("'script'");
const issues = r.error!.issues;
expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', ['onSuccess']]]);
// The subject names the action's type; the text is the one the page
// action rows print (`action-on-success-executor.ts`), pinned there.
expect(issues[0].message.startsWith(`A \`type: '${type}'\` action's \`onSuccess\``)).toBe(true);
});

it.each(['api', 'script'] as const)('NEGATIVE CONTROL: accepts onSuccess on a type:%s action', (type) => {
const r = ActionSchema.safeParse({ ...base, type, target: type === 'api' ? '/api/v1/x' : 'fn', onSuccess: { navigate: '/x' } });
expect(r.success, JSON.stringify(r.error?.issues)).toBe(true);
});
});

Expand Down
Loading
Loading