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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/5934-retire-onsuccess-callback-channel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
'@object-ui/core': minor
'@object-ui/types': minor
'@object-ui/components': patch
---

BREAKING (`@object-ui/core`): `ActionRunner`'s legacy `ActionDef.onSuccess`
chained-callback channel is retired — `onSuccess` now has exactly the meaning the
contract declares (objectui#5934, maintainer ruling 2026-08-31).

(The bump is `minor` by this repo's release model — objectui's major is pinned to
the `@objectstack` family major, and its own breaking changes ship as `minor` with
the break spelled out here, per `scripts/check-changeset-no-major.mjs`. This
paragraph is that spelling-out: the break below is real and consumer-visible.)

- **What breaks, by specifier**: `import type { ActionDef } from '@object-ui/core'` —
`ActionDef['onSuccess']` was `ActionDef | ActionDef[]` (chained callbacks the runner
dispatched through `executeChain` after a success). It is now derived from the pinned
spec: `ActionSchema.onSuccess`'s closed strict `{ navigate: string, openIn?: 'self' |
'newTab' }` block. Code that assigned a callback `ActionDef` (or an array of them) to
`onSuccess` no longer compiles, and at runtime a callback-shaped value gets NO reading —
no handler dispatch, no navigation, the action's own result untouched. `onFailure` is NOT
changed: the spec declares no such key, so it keeps its one runner-native meaning.
- **Why this is safe to take**: the channel was unreachable from validated metadata —
`@objectstack/spec` (17.2.0 pin) strict-refuses a callback shape inside `onSuccess` at
parse (`invalid_type` on `navigate` + `unrecognized_keys`), so no published/saved
metadata could ever carry one — and a producer census with a positive control found zero
producers outside the channel's own test pins. Migration for an out-of-repo consumer that
drove the channel programmatically: put the follow-up actions in `chain` (the runner's
declared chaining key, unchanged), or author the spec's `onSuccess` navigation block.
- `@object-ui/types` (minor): `UIActionSchema` now declares `onSuccess`, derived from the
spec's `ActionSchema.onSuccess` — the renderer view spells the key the four action
surfaces forward, so the forwards type-check.
- `@object-ui/components` (patch): the four action renderers forward `onSuccess` without
the `as any` casts (no behavior change — same key, same value, now typed).
Original file line numberDiff line numberDiff line change
Expand Up@@ -399,8 +399,9 @@ describe('a declared onSuccess block defers to the runner (objectui#5221)', () =
});

it('a legacy chained-callback onSuccess is NOT mistaken for a declared hop', async () => {
// `{ type: 'notify' }` is the runner's older `ActionDef` callback channel,
// not the spec block. The redirectUrl convention must still run.
// `{ type: 'notify' }` was the runner's older `ActionDef` callback channel
// (retired by objectui#5934), not the spec block — an unparsed row can
// still carry the shape. The redirectUrl convention must still run.
const openSpy = vi.spyOn(window, 'open').mockReturnValue(makeTab() as any);
const navigate = vi.fn();
const { handler } = makeHandler({
Expand Down
10 changes: 7 additions & 3 deletions packages/components/src/renderers/action/action-button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -198,9 +198,13 @@ const ActionButtonRenderer = forwardRef<
// the app's own `navigationHandler`). Dropped here, the action
// succeeded and the declared hop silently never happened —
// objectui#5493, the same shape as `bodyShape` / `resultDialog`
// above. Cast because the key is spec-owned and not spelled on
// `@object-ui/types`' renderer view, exactly as `resultDialog` is.
onSuccess: (schema as any).onSuccess,
// above. Uncast since objectui#5934 retired the runner's legacy
// chained-callback meaning: both ends now derive the spec block
// (`UIActionSchema.onSuccess` on the read side,
// `ActionDef.onSuccess` on the write side), so the forward
// type-checks against the one declared meaning instead of hiding
// behind `as any`.
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-group.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -276,7 +276,8 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
},
[execute],
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-icon.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,8 @@ const ActionIconRenderer = forwardRef<
// See action-button.tsx — the declared post-success hop
// (objectui#5493). The runner reads it off the forwarded def; dropped
// here the action succeeds and the authored navigation never runs.
onSuccess: (schema as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: schema.onSuccess,
};

await execute({ ...forwarded, ...localContext });
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/renderers/action/action-menu.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -256,7 +256,8 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
// (objectui#5493). An overflow action must hop like its inline
// twin, or the `action:bar` `maxVisible` split decides whether the
// declared navigation runs.
onSuccess: (action as any).onSuccess,
// Uncast since objectui#5934 (legacy callback channel retired).
onSuccess: action.onSuccess,
});
} finally {
setLoading(false);
Expand Down
60 changes: 36 additions & 24 deletions packages/core/src/actions/ActionRunner.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -320,8 +320,6 @@ export interface ActionDef {
chain?: ActionDef[];
/** Chain execution mode */
chainMode?: 'sequential' | 'parallel';
/** Callback on success */
onSuccess?: ActionDef | ActionDef[];
/** Callback on failure */
onFailure?: ActionDef | ActionDef[];
/** When true, the runner pre-opens about:blank synchronously on click so the
Expand DownExpand Up@@ -422,6 +420,26 @@ export interface ActionDef {
recordIdParam?: SpecActionInput['recordIdParam'];
/** Auth/tenancy feature the action requires before it is offered. */
requiresFeature?: SpecActionInput['requiresFeature'];
/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0, objectui#5328). Read by `handlePostExecution`
* → `readOnSuccessNavigation` → `navigateOnSuccess`.
*
* This key carried a SECOND, older meaning until objectui#5934: the runner's
* own chained-callback channel, `ActionDef | ActionDef[]`, dispatched through
* `executeChain`. The maintainer retired that channel on 2026-08-31 — the
* spec strict-refuses a callback shape here (`{ type: … }` fails parse with
* `unrecognized_keys`, so no validated metadata could ever reach it), and the
* census found zero producers outside the channel's own pins. `onSuccess`
* now means exactly what the contract declares, nothing else; a callback
* shape gets NO reading (not a fallback, not an error — the same "a shape
* the spec refuses gets no new reading here" rule the discrimination branch
* used to apply, now with nothing left to discriminate). `onFailure`, in the
* runner-native section above, is untouched: the spec declares no such key,
* so it has only ever had its one runner-native meaning.
*/
onSuccess?: SpecActionInput['onSuccess'];
/**
* @deprecated Retired in `@objectstack/spec` 17 as a `retiredKey()` tombstone —
* authoring it is a hard parse rejection, so this resolves to `undefined` and
Expand DownExpand Up@@ -1225,26 +1243,21 @@ export class ActionRunner {
// `type: 'api'` and `type: 'script'` — the two types whose success event
// carries a server response for `${result.*}` to read.
//
// This runner's OWN `ActionDef.onSuccess` predates that key and means
// something else entirely: `ActionDef | ActionDef[]`, chained callbacks.
// The two are told apart by the spec's own declaration — a non-array object
// whose `navigate` is a STRING is the spec block and nothing else can be:
// `navigate` on a callback ActionDef is the deprecated nested navigation
// ENVELOPE (`executeNavigation` reads `navigate.to`), so a string there has
// never been runnable. This is a NARROWING to the declared contract, not a
// lenient fallback: a shape the spec refuses gets no new reading here.
// That declared meaning is the key's ONLY meaning. The runner's older
// chained-callback channel (`onSuccess?: ActionDef | ActionDef[]`,
// dispatched through `executeChain`) was retired by objectui#5934
// (maintainer ruling 2026-08-31): the spec strict-refuses a callback shape
// at parse, so no validated metadata could ever reach it, and the census
// found zero producers outside the channel's own pins.
//
// Before this branch existed, the ruled shape fell into the callback path,
// dispatched `{ navigate: '<string>' }` as an action, and failed inside
// `executeNavigation` with "No URL provided for navigation action" — the
// author got a red toast and no hop.
// `readOnSuccessNavigation` stays as the shape guard, not as a
// discriminator: stored rows are rehydrated UNPARSED (#3903), so the value
// is still read as data, and a shape the spec refuses gets no reading —
// no navigation, no callback dispatch, no lenient fallback.
if (result.success && action.onSuccess) {
const navigation = readOnSuccessNavigation(action.onSuccess);
if (navigation) {
this.navigateOnSuccess(navigation, action, result);
} else {
const callbacks = Array.isArray(action.onSuccess) ? action.onSuccess : [action.onSuccess];
await this.executeChain(callbacks, 'sequential');
}
}
if (!result.success && action.onFailure) {
Expand DownExpand Up@@ -1999,15 +2012,14 @@ export interface OnSuccessNavigation {
}

/**
* Is this `onSuccess` the SPEC's navigation block, or this runner's older
* chained-callback channel (`ActionDef | ActionDef[]`)?
* Is this `onSuccess` the spec's navigation block?
*
* The test IS the spec's declaration: a non-array object carrying a STRING
* `navigate`. Nothing else can produce that shape — the spec object is strict
* with `navigate: z.string()` required, and on a callback `ActionDef`,
* `navigate` is the deprecated nested navigation ENVELOPE that
* `executeNavigation` reads `to`/`target`/`redirect` off, so a bare string
* there has never been runnable.
* `navigate`. Stored rows are rehydrated UNPARSED (#3903), so the runner reads
* the value as data and anything else gets NO reading — since objectui#5934
* retired the legacy chained-callback channel (`ActionDef | ActionDef[]`),
* there is no other channel for an off-contract shape to fall into. This is a
* shape GUARD on unparsed data, not a discriminator between two meanings.
*/
export function readOnSuccessNavigation(value: unknown): OnSuccessNavigation | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,23 +253,30 @@ describe('ActionSchema.onSuccess — the two openIn spellings stay apart', () =>
});
});

describe('ActionSchema.onSuccess — the legacy chained-callback channel is untouched', () => {
it('still runs an ActionDef callback, and does not treat it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predates the spec key and
// is a RUNTIME channel: `@objectstack/spec` strict-refuses `{ type: … }`
// inside `onSuccess`, so no validated metadata can reach it. Retiring it is
// its own card; this pins that implementing the spec key did not silently
// take it away.
describe('ActionSchema.onSuccess — the retired chained-callback channel gets no reading', () => {
it('neither dispatches a callback-shaped onSuccess nor treats it as navigation', async () => {
// `ActionDef.onSuccess?: ActionDef | ActionDef[]` predated the spec key as
// the runner's own chained-callback channel. objectui#5934 (maintainer
// ruling 2026-08-31) retired it: the spec strict-refuses `{ type: … }`
// inside `onSuccess` at parse, so no validated metadata could ever reach
// it, and the census found zero producers outside the channel's own pins.
// Stored rows rehydrate UNPARSED (#3903), so this pins the RUNTIME half of
// the retirement — the shape still reaches the runner as data, and gets NO
// reading: no handler dispatch, no navigation, and the action's own result
// is untouched. (`as never` is the test reaching around the compile-time
// half: the declared type now derives the spec block and refuses this
// shape at the authoring site.)
const { runner, nav } = makeRunner({ id: 'rec_42' });
const cb = vi.fn(async () => ({ success: true }));
runner.registerHandler('notify', cb as never);

await runner.execute({
const result = await runner.execute({
type: 'api', name: 'clone_record', target: '/api/v1/records/clone',
onSuccess: { type: 'notify', name: 'ping' },
} as never);

expect(cb).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
expect(cb).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
});
31 changes: 22 additions & 9 deletions packages/core/src/actions/__tests__/ActionRunner.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1019,17 +1019,29 @@ describe('ActionRunner', () => {
// ==========================================================================

describe('callbacks', () => {
it('should execute onSuccess callback after success', async () => {
// The `onSuccess` chained-callback channel (`ActionDef | ActionDef[]`) was
// retired by objectui#5934 (maintainer ruling 2026-08-31): the spec
// strict-refuses a callback shape inside `onSuccess` at parse, and the
// census found zero producers outside this file's own pins. The two tests
// that used to pin the channel now pin its ABSENCE — stored rows rehydrate
// UNPARSED (#3903), so the shapes still reach the runner as data, and must
// get no reading. `onFailure` is untouched: the spec declares no such key,
// so it keeps its one runner-native meaning.
it('a callback-shaped onSuccess is not dispatched — the channel is retired', async () => {
const successHandler = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('notify', successHandler);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
// `as never`: since #5934 the declared type derives the spec's
// `{ navigate, openIn }` block, so the compiler refuses this shape at
// the authoring site — the cast reaches around it to pin the runtime.
onSuccess: { type: 'notify', params: { msg: 'ok' } },
toast: { showOnSuccess: false },
});
} as never);

expect(successHandler).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(successHandler).not.toHaveBeenCalled();
});

it('should execute onFailure callback after failure', async () => {
Expand All@@ -1045,20 +1057,21 @@ describe('ActionRunner', () => {
expect(failureHandler).toHaveBeenCalledOnce();
});

it('should support array of onSuccess callbacks', async () => {
it('an array of callback-shaped onSuccess entries is not dispatched either', async () => {
const h1 = vi.fn().mockResolvedValue({ success: true });
const h2 = vi.fn().mockResolvedValue({ success: true });
runner.registerHandler('cb1', h1);
runner.registerHandler('cb2', h2);

await runner.execute({
const result = await runner.execute({
onClick: vi.fn(),
onSuccess: [{ type: 'cb1' }, { type: 'cb2' }],
toast: { showOnSuccess: false },
});
} as never);

expect(h1).toHaveBeenCalledOnce();
expect(h2).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
expect(h1).not.toHaveBeenCalled();
expect(h2).not.toHaveBeenCalled();
});
});

Expand Down
22 changes: 12 additions & 10 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,10 +75,12 @@
* rejection" into a compile error at no cost. Hand-copying would have quietly
* re-legitimized two dead keys — which is why the types are derived.
*
* 17 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* 16 keys `ActionDef` declares that the spec does not own — `actionType`, `api`,
* `chain`, `chainMode`, `close`, `condition`, `confirm`, `endpoint`, `modal`,
* `navigate`, `onClick`, `onFailure`, `onSuccess`, `redirect`, `reload`, `toast`,
* `actionParams`. Step 2 marked `@deprecated`, with the spec spelling to use
* `navigate`, `onClick`, `onFailure`, `redirect`, `reload`, `toast`,
* `actionParams`. (`onSuccess` was the 17th until objectui#5934 retired the
* runner's chained-callback meaning; the key is now spec-owned and derived,
* like the 18 below.) Step 2 marked `@deprecated`, with the spec spelling to use
* instead, ONLY the four the runner itself proves are aliases: `actionType` (→
* `type`), `api` and `endpoint` (→ `target`; `executeAPI` resolves
* `api || endpoint || target`), and `navigate` (→ flat `target`/`openIn`;
Expand DownExpand Up@@ -156,7 +158,6 @@ export const ACTION_DEF_KEYS = [
'modal',
'chain',
'chainMode',
'onSuccess',
'onFailure',
'opensInNewTab',
'newTabUrl',
Expand All@@ -181,6 +182,10 @@ export const ACTION_DEF_KEYS = [
'recordIdField',
'recordIdParam',
'requiresFeature',
// Moved from the runner-native cluster above by objectui#5934: the legacy
// chained-callback meaning is retired and the key's type now derives the
// spec's `{ navigate, openIn }` block.
'onSuccess',
'shortcut',
'bulkEnabled',
] as const;
Expand DownExpand Up@@ -240,12 +245,9 @@ export const SPEC_ACTION_KEYS = [
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
// All four declared action surfaces forward it since objectui#5493/#6304, and
// `ActionDef` derives its type from the spec since objectui#5934 retired the
// runner's legacy chained-callback meaning for the same key.
'onSuccess',
'openIn',
'opensInNewTab',
Expand Down
20 changes: 20 additions & 0 deletions packages/types/src/ui-action.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -451,6 +451,26 @@ export interface UIActionSchema {
*/
openIn?: 'self' | 'new-tab';

/**
* Declared post-success navigation — the spec's closed strict
* `{ navigate, openIn }` block (`ActionSchema.onSuccess`, authorable since
* `@objectstack/spec` 17.1.0). All four declared action renderers forward it
* to the runner (objectui#5493/#6304), which performs the hop through the
* app's own `navigationHandler`.
*
* DERIVED from the spec, never hand-copied — a hand-written duplicate of a
* spec shape is a second contract that drifts silently. Declared on the
* renderer view since objectui#5934 retired `ActionRunner`'s legacy
* chained-callback meaning for the same key: with the spec block as the
* key's only meaning, the forward sites type-check without an `as any` cast.
*
* Note the inner `openIn` spelling is `'self' | 'newTab'` — NOT the
* top-level {@link openIn}'s `'self' | 'new-tab'`. The spec refuses each
* crossover spelling; the derivation keeps the two from ever being merged
* by hand.
*/
onSuccess?: SpecAction['onSuccess'];

/** API endpoint (for type: 'api') */
endpoint?: string;

Expand Down
Loading