Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 8e90779

Browse files
feat(core,react,app-shell): action confirmText, successMessage and errorMessage read the template envelope and fill {{ }} holes (objectui#13367) (#13409)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuT1VQkBzUTyDsEUih9AXFHq)_ Fixes #13367 The renderer half of objectstack-ai/objectstack#24814 (ruling A): an action's `confirmText`, `successMessage` and `errorMessage` read the `template` envelope and fill `{{ }}` holes, before the spec types the three slots as `template` slots. Claim: #13367 comment `6369803045`. ## What changes - **`ActionRunner`** (`@object-ui/core`) reads each key as a string or `{ dialect?: 'template', source? }`: - `confirmText`: `{{ }}` holes over `record` (the record the action runs against; on a list, the clicked row, the same rule as `templateEvaluatorFor`) and `params` (the action's static params object, without the stashed row; the collected values do not exist yet when the confirmation shows); - `successMessage`: `{{ }}` holes over `result` (the payload `${result.*}` reads) and `record`; - `errorMessage`: no root is declared yet, so the text shows as written. - **The engine** is `@objectstack/formula`'s `templateEngine`, the one the flow notifications render with and the one #13183's ruling names. It is exported at this repo's `.objectstack-sha` pin and already a dependency of `@object-ui/core`. No engine and no hole grammar is written here. - **Transition:** a sentence with no `{{ }}` hole keeps today's reading (`${…}` through `ExpressionEvaluator` for `confirmText`, `${result.*}` for `successMessage`), so the platform objects' `${result.email}` sentences keep working until the spec card converts them. - **One dialect per sentence:** a sentence with a `{{ }}` hole goes through the template engine only. A record value that happens to contain `${…}` is never evaluated after it was filled into a hole. - **No object reaches a React child:** an envelope with no string `source` gives `''` to the confirm handler (the declared confirmation still gates the run), the default text to the success toast, and the action's own error to the error toast. - **`useActionTextLocalizer`** (`@object-ui/react`) kept the three keys as `I18nLabel` maps and collapsed them with `pickLocalized`, which turns `{ dialect: 'template', source }` into its first string value: the word `template`. It now hands the envelope on, and a bundle entry replaces its `source` (i18next leaves a missing `{{ }}` variable in place, measured on the installed version). - **Flow actions** (`@object-ui/app-shell`): `flowSuccessCopy` read the invoking action's `successMessage` only as a string, so an envelope fell to the flow's own copy. It now reads it through `actionCopySource`. `FlowRunner`'s `actionSuccessMessage` takes the same two shapes (the type widening made app-shell's type-check red at the two launch sites otherwise). - `@object-ui/core` exports the reader: `ActionCopyTemplate`, `isActionCopyEnvelope`, `actionCopySource`, `hasTemplateHoles`, `fillTemplateHoles`. Clause-②: yes (the claim said `no`; `ActionDef`'s three keys widen and core gains five exports, so this declares the widening) Metadata-protocol: none ## Tests - New `packages/core/src/actions/__tests__/ActionRunner.copyTemplate-13367.test.ts` (28): a plain string and an envelope per key; each root (`record` from the header and from a row, `params` without `_rowRecord`, `result`); a formatter; an undeclared root prints empty; `${…}` still filled for `confirmText` and `successMessage`; one dialect per sentence for both; a refused source shown as written; an envelope with no source for each key; `outcomeMessages` still outranks; an envelope counts as declared copy for a navigation (objectui#12755); the reader helpers. - New `packages/react/src/hooks/__tests__/useActionTextLocalizer.templateEnvelope-13367.test.tsx` (5): nameless and named actions keep the envelope, a bundle entry replaces `source`, a sourceless envelope passes through, strings and maps unchanged. - `packages/app-shell/src/utils/__tests__/flowResponse.test.ts`: 3 new cases for the envelope on a flow action. - Ablation (on `f25bc24`): the localizer's `collapseCopy` put back to `pickLocalized`, and the success rung put back to string-only: 9 failed (5 runner, 4 localizer); restored with `git checkout HEAD -- packages`, `git diff --quiet HEAD` clean. - `pnpm exec vitest run --maxWorkers=2 packages/core/ packages/react/` → 331 files, 5566 passed. `packages/components/src/renderers/action/ packages/app-shell/src/hooks/__tests__/ packages/app-shell/src/utils/ packages/plugin-grid/` → 391 files, 3995 passed. After the app-shell commit: app-shell hooks, utils and flow views → 141 files, 1427 passed. After merging `origin/main` (`889d028`): core actions, react hooks, flowResponse and the objectui#13379 navigator pins → 84 files, 1097 passed. - `NODE_OPTIONS=--max-old-space-size=8192 pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/console` → 37/37. A first run over app-shell, console, components and the plugin packages went red on the two launch sites above, which the second commit fixes. - eslint on the touched files: 0 errors. Gates exit 0: `check:new-line-citations`, `check:control-bytes`, `check:spec-symbols`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:unreferenced-sources`, `check:test-path-roots`, `check:action-forward-parity`, `check:handler-key-reads`, `check:phantom-deps`, `check:self-import`, `check:esm-specifiers`, `check:component-surface-parity`, `check:vi-mock-specifiers`, `check:comment-mask-corpus`, `scripts/check-changeset-presence.mjs`. `check:readme-exports` needs every package built and was left to CI. ## Order This PR → objectstack's `.objectui-sha` moves past it → objectstack-ai/objectstack#24814 types the slots (it carries `Blocked-by: #13367`). ## Serial objectui#13395 (objectui#13384) also edits `ActionRunner.ts`, in other hunks (a refusal inserted just above the confirm read, and the `RUNNER_TEXT` docblock, whose last sentence this PR rewords). Whichever lands second merges main. ## Acceptance notes - `{{ }}` formatters render in `en-US`: the runner has no language (its docblocks say so), so `{{ amount | currency }}` does not follow the user's locale yet. Noted, not filed. - `FlowRunner`'s terminal toast after a paused flow shows the action's copy as written: neither `{{ }}` nor `${result.*}` is filled there, before or after this PR. Noted, not filed. - The Studio action inspector and preview (`ActionDefaultInspector`, `ActionPreview`) read the three keys as `I18nLabel` maps, so an envelope would show there as `template`. Authoring surface, outside the runner; noted, not filed. - #13183 (bulk `confirmText` fill step, `baozhoutao`) can call the same `fillTemplateHoles` over `{ count, … }`; this PR does not touch plugin-grid. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) --- _Generated by [Claude Code](https://claude.ai/code/session_0159TkE3rFbrKwMs3mWhnniW)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0159TkE3rFbrKwMs3mWhnniW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1a4b08 commit 8e90779

10 files changed

Lines changed: 652 additions & 31 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/react': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
An action's `confirmText`, `successMessage` and `errorMessage` read the `template` envelope and fill `{{ }}` holes (objectui#13367).
8+
9+
`@objectstack/spec` is making these three keys `template` slots (objectstack#24814), so a parsed action serves `{ dialect: 'template', source }` where it served a string. Before this change the success toast skipped the envelope and showed the default text, and the error toast showed the generic failure. `ActionRunner` now reads a string or the envelope for all three, and fills `{{ path }}` / `{{ path | formatter }}` holes with `@objectstack/formula`'s `templateEngine`, the engine the flow notifications render with:
10+
11+
- `confirmText`: `record` (the record the action runs against, the clicked row on a list) and `params` (the action's static params);
12+
- `successMessage`: `result` (the payload `${result.*}` reads) and `record`;
13+
- `errorMessage`: no hole is filled; the text shows as written.
14+
15+
A sentence with no `{{ }}` hole keeps today's `${…}` reading, so the platform's own `${result.email}`-style success sentences still work until the spec converts them. A sentence is read in one dialect only: a value filled into a `{{ }}` hole is never read again as `${…}`. An envelope with no text never reaches the dialog or a toast as an object; a `confirmText` envelope with no text still asks for confirmation.
16+
17+
`useActionTextLocalizer` keeps the envelope: it used to read it as a per-locale map and hand the runner the word `template`. A bundle entry replaces the envelope's `source`. On a flow action the console reads the envelope too, so the action's own success copy still outranks the flow's (`flowSuccessCopy`). `@object-ui/core` exports the reader it uses: `ActionCopyTemplate`, `isActionCopyEnvelope`, `actionCopySource`, `hasTemplateHoles` and `fillTemplateHoles`.
18+
19+
Clause-②: yes
20+
Metadata-protocol: none

‎packages/app-shell/src/utils/__tests__/flowResponse.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,3 +525,29 @@ describe("interpretFlowResponse — the flow's own toasts in the active language
525525
expect(interpretFlowResponse(ok, json, 'Flow "case_escalate"')).toMatchObject({ successMessage: 'Case escalated' });
526526
});
527527
});
528+
529+
// objectui#13367 — the invoking action's `successMessage` may arrive as the
530+
// `template` envelope (objectstack#24814). Read as "not a string", it fell to
531+
// the flow's own copy, so the author's sentence never showed on a flow action.
532+
describe("flowSuccessCopy — the action's copy as a template envelope (objectui#13367)", () => {
533+
it("an envelope's source wins over the flow's copy, as written", () => {
534+
expect(flowSuccessCopy({ dialect: 'template', source: 'Escalated {{ record.name }}' }, 'Escalation recorded'))
535+
.toEqual({ text: 'Escalated {{ record.name }}', from: 'action' });
536+
});
537+
538+
it('an envelope with no text counts as none', () => {
539+
expect(flowSuccessCopy({ dialect: 'template' }, 'Escalation recorded'))
540+
.toEqual({ text: 'Escalation recorded', from: 'flow' });
541+
});
542+
543+
it('on a straight-through run the result is unchanged, so the runner speaks the envelope', () => {
544+
const data = { success: true, status: 'completed', successMessage: 'Escalation recorded' };
545+
const out = judgeFlowLaunch(
546+
interpretFlowResponse(ok, { success: true, data }, 'Flow "case_escalate"'),
547+
undefined,
548+
{ dialect: 'template', source: 'Escalated {{ record.name }}' },
549+
);
550+
expect(out.result).toEqual({ success: true, data, reload: true });
551+
expect(out.successToast).toBeUndefined();
552+
});
553+
});

‎packages/app-shell/src/utils/flowResponse.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
* own failure prose (`error`) is passed through untouched — it has no key.
187187
*/
188188

189-
import { actionErrorDetail } from '@object-ui/core';
189+
import { actionCopySource, actionErrorDetail } from '@object-ui/core';
190190
import type { ActionResult } from '@object-ui/core';
191191
import { errorCodeIs } from '@object-ui/types';
192192
import type { AutomationResult } from '@objectstack/spec/contracts';
@@ -457,14 +457,19 @@ function usableCopy(copy: unknown): copy is string {
457457
* neither is usable; the caller then speaks its own default sentence.
458458
*
459459
* `actionMessage` is the action's copy exactly as it reached the host's flow
460-
* handler, so already localized by the surface that dispatched it.
460+
* handler, so already localized by the surface that dispatched it. It is a
461+
* string or the `template` envelope (objectui#13367), read through core's
462+
* `actionCopySource` as the action runner reads it, so an envelope still
463+
* outranks the flow's own copy. The text is answered as written: on the
464+
* straight-through run the runner speaks it and fills its holes.
461465
* `flowMessage` is the flow's own `successMessage` as `interpretFlowResponse`
462466
* answered it — the served `AutomationResult.successMessage`, translated
463467
* against `flows.FLOW.successMessage` where the bundle carries one
464468
* (objectui#12075).
465469
*/
466470
export function flowSuccessCopy(actionMessage: unknown, flowMessage: unknown): FlowSuccessCopy | undefined {
467-
if (usableCopy(actionMessage)) return { text: actionMessage, from: 'action' };
471+
const actionText = actionCopySource(actionMessage);
472+
if (usableCopy(actionText)) return { text: actionText, from: 'action' };
468473
if (usableCopy(flowMessage)) return { text: flowMessage, from: 'flow' };
469474
return undefined;
470475
}

‎packages/app-shell/src/views/FlowRunner.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ import {
221221
cn,
222222
} from '@object-ui/components';
223223
import { notifyDataChanged, useObjectTranslation } from '@object-ui/react';
224+
import type { ActionCopyTemplate } from '@object-ui/core';
224225
import {
225226
FLOW_SCREEN_FIELD_COPY_KEYS,
226227
resolveFlowScreenFieldOptions,
@@ -353,8 +354,10 @@ export interface ScreenFlowState extends Pick<AutomationResult, 'flowLabel'> {
353354
* toasts when the run completes without pausing (objectui#12234 — see the
354355
* header). Absent when no action launched the run or the action declares
355356
* none; the runner then keeps the flow-completed toast it always had.
357+
* A string or the `template` envelope (objectui#13367), read by
358+
* `flowSuccessCopy`.
356359
*/
357-
actionSuccessMessage?: string;
360+
actionSuccessMessage?: ActionCopyTemplate;
358361
}
359362

360363
/**

‎packages/core/src/actions/ActionRunner.ts‎

Lines changed: 107 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import type { ActionContext, ActionResult, RunnableActionType, UIActionSchema } from '@object-ui/types';
2525
import type { Action as SpecActionInput } from '@objectstack/spec/ui';
2626
import { ExpressionEvaluator } from '../evaluator/ExpressionEvaluator.js';
27+
import { actionCopySource, fillTemplateHoles, hasTemplateHoles, type ActionCopyTemplate } from './actionCopyTemplate.js';
2728
import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';
2829
import { globalUndoManager } from './UndoManager.js';
2930
import { warnOnDeprecatedObjectParams, warnOnUnknownActionKeys } from './actionKeys.js';
@@ -154,8 +155,13 @@ export interface ActionDef {
154155
* confirm spelling: `@objectstack/spec`'s action surface and the
155156
* TranslationBundle key (`{ns}.objects.{obj}._actions.{name}.confirmText`)
156157
* both stand on it.
158+
*
159+
* A string or the `template` envelope (objectui#13367, the renderer half of
160+
* objectstack#24814): its `{{ }}` holes are filled over `record` and
161+
* `params`, and a sentence with none keeps the transitional `${…}` reading.
162+
* See `confirmMessage`.
157163
*/
158-
confirmText?: string;
164+
confirmText?: ActionCopyTemplate;
159165
/**
160166
* RETIRED (objectui#4314): the structured confirm object used to sit here
161167
* and its `message` OUTRANKED `confirmText` — untranslated, since no bundle
@@ -269,10 +275,14 @@ export interface ActionDef {
269275
/** Toast configuration */
270276
toast?: { showOnSuccess?: boolean; showOnError?: boolean; duration?: number };
271277
/** Success message (from UIActionSchema). The success toast's second rung,
272-
* after {@link outcomeMessages}; may interpolate `${result.*}`. */
273-
successMessage?: string;
274-
/** Error message (from UIActionSchema) */
275-
errorMessage?: string;
278+
* after {@link outcomeMessages}. A string or the `template` envelope
279+
* (objectui#13367): `{{ }}` holes over `result` and `record`, or, in a
280+
* sentence with none, the transitional `${result.*}` tokens. */
281+
successMessage?: ActionCopyTemplate;
282+
/** Error message (from UIActionSchema). A string or the `template`
283+
* envelope (objectui#13367); shown as written — no scope is declared for
284+
* its holes yet. */
285+
errorMessage?: ActionCopyTemplate;
276286
/** Whether to refresh data after execution (from UIActionSchema) */
277287
refreshAfter?: boolean;
278288
/** Single-record update actions: offer an Undo affordance on success. */
@@ -597,7 +607,8 @@ export type ToastHandler = (message: string, options?: {
597607
*
598608
* An author's `outcomeMessages` / `successMessage` / `errorMessage` and an
599609
* action's own error are never in this table: the host localizes authored copy
600-
* before dispatch, and the runner only fills in its `${result.*}` tokens.
610+
* before dispatch, and the runner only fills in their holes (`{{ }}` or the
611+
* transitional `${result.*}`).
601612
*/
602613
const RUNNER_TEXT = {
603614
completedSuccessfully: {
@@ -1068,13 +1079,28 @@ function isUpdateOperationAction(action: ActionDef): boolean {
10681079
return action.operation === 'update';
10691080
}
10701081

1082+
/**
1083+
* The row a dispatch carries — the clicked row a row surface stashes under
1084+
* `params._rowRecord` — or `undefined` when it carries none. The record its
1085+
* copy templates bind as `record` (see `ActionRunner.templateEvaluatorFor`).
1086+
*/
1087+
function dispatchRow(action: ActionDef): Record<string, unknown> | undefined {
1088+
const params = action.params;
1089+
const row = params && typeof params === 'object' && !Array.isArray(params)
1090+
? (params as Record<string, unknown>)._rowRecord
1091+
: undefined;
1092+
return row && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : undefined;
1093+
}
1094+
10711095
/**
10721096
* Whether the action declares its own success copy, read the way
1073-
* `composeSuccessMessage` reads its `successMessage` rung: a non-empty string.
1074-
* A navigation toasts only then (objectui#12755).
1097+
* `composeSuccessMessage` reads its `successMessage` rung: a non-empty string,
1098+
* or a `template` envelope whose `source` is one (objectui#13367). A
1099+
* navigation toasts only then (objectui#12755).
10751100
*/
10761101
function declaresSuccessMessage(action: ActionDef): boolean {
1077-
return typeof action.successMessage === 'string' && action.successMessage.trim() !== '';
1102+
const text = actionCopySource(action.successMessage);
1103+
return text !== undefined && text.trim() !== '';
10781104
}
10791105

10801106
/**
@@ -1466,10 +1492,9 @@ export class ActionRunner {
14661492
// `${record.*}` hole the record header fills was shown raw from a list
14671493
// row (objectui#12967).
14681494
const templates = this.templateEvaluatorFor(action);
1469-
if (action.confirmText) {
1470-
const confirmed = await this.confirmHandler(
1471-
templates.evaluate(action.confirmText) as string,
1472-
);
1495+
const confirmMessage = this.confirmMessage(action, templates);
1496+
if (confirmMessage !== undefined) {
1497+
const confirmed = await this.confirmHandler(confirmMessage);
14731498
if (!confirmed) {
14741499
return { success: false, error: 'Action cancelled by user' };
14751500
}
@@ -1693,13 +1718,22 @@ export class ActionRunner {
16931718
* sink differs: a toast is text, so a value is inserted as-is, where a URL
16941719
* position percent-encodes it.
16951720
*
1721+
* Rung 2 is also a `template` slot (objectui#13367, the renderer half of
1722+
* objectstack#24814): a string or the `{ dialect: 'template', source }`
1723+
* envelope. A sentence with a `{{ }}` hole is filled by the template engine
1724+
* over `result` (the same payload) and `record` (the record the action ran
1725+
* against), and is then not read for `${…}` tokens; one without keeps the
1726+
* `${result.*}` reading above until the spec card converts the platform's
1727+
* own `${result.email}`-style sentences.
1728+
*
16961729
* ⛔ `result.data.message` is not a rung. It used to outrank both rungs of
16971730
* author copy, which put whatever language the server happened to write in
16981731
* front of the user's locale; the contract names no server-message rung, and
16991732
* an author who wants the server's sentence can still write
17001733
* `${result.message}` as the copy.
17011734
*
1702-
* A rung counts only when it is a non-empty string. An `I18nLabel` map that
1735+
* A rung counts only when it is a non-empty string (for rung 2, or an
1736+
* envelope whose `source` is one). An `I18nLabel` map that
17031737
* reached the runner without passing through a localizer has no language to
17041738
* resolve against here, so it is skipped rather than handed to the toast as
17051739
* an object — the React #31 crash the error branch below guards against.
@@ -1715,14 +1749,62 @@ export class ActionRunner {
17151749
&& Object.prototype.hasOwnProperty.call(outcomeMessages, outcome)
17161750
? (outcomeMessages as Record<string, unknown>)[outcome]
17171751
: undefined;
1718-
for (const copy of [outcomeCopy, action.successMessage]) {
1719-
if (typeof copy === 'string' && copy.trim() !== '') {
1720-
return substituteScopeTokens(copy, { result: payload }, (value) => value);
1721-
}
1752+
if (typeof outcomeCopy === 'string' && outcomeCopy.trim() !== '') {
1753+
return substituteScopeTokens(outcomeCopy, { result: payload }, (value) => value);
1754+
}
1755+
const successCopy = actionCopySource(action.successMessage);
1756+
if (successCopy !== undefined && successCopy.trim() !== '') {
1757+
return hasTemplateHoles(successCopy)
1758+
? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) })
1759+
: substituteScopeTokens(successCopy, { result: payload }, (value) => value);
17221760
}
17231761
return this.runnerText(RUNNER_TEXT.completedSuccessfully);
17241762
}
17251763

1764+
/**
1765+
* The confirm dialog's body, or `undefined` when the action asks for no
1766+
* confirmation (objectui#13367).
1767+
*
1768+
* `confirmText` is a string or the `template` envelope. A sentence with a
1769+
* `{{ }}` hole is filled by the template engine over two roots: `record` —
1770+
* the record the action runs against, the same one `templateEvaluatorFor`
1771+
* binds — and `params`, the action's static params object when it has one
1772+
* (the dialog's collected values do not exist yet: the confirmation comes
1773+
* first). A sentence with no `{{ }}` hole keeps the transitional `${…}`
1774+
* reading through `templates`, so the platform's own `${…}` copy works
1775+
* until the spec card converts it.
1776+
*
1777+
* A declared value that carries no text — an envelope with no string
1778+
* `source` — still gates the run: the author declared a confirmation, and
1779+
* dropping it would let a destructive action run unasked. The dialog then
1780+
* shows no body text, never the object, which as a React child crashes the
1781+
* page. Every answer is a string for the same reason.
1782+
*/
1783+
private confirmMessage(action: ActionDef, templates: ExpressionEvaluator): string | undefined {
1784+
const declared: unknown = action.confirmText;
1785+
if (!declared) return undefined;
1786+
const text = actionCopySource(declared);
1787+
if (text === undefined) return '';
1788+
if (!hasTemplateHoles(text)) {
1789+
const evaluated: unknown = templates.evaluate(text);
1790+
return typeof evaluated === 'string' ? evaluated : evaluated == null ? '' : String(evaluated);
1791+
}
1792+
const params = action.params;
1793+
const staticParams = params && typeof params === 'object' && !Array.isArray(params)
1794+
? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord'))
1795+
: undefined;
1796+
return fillTemplateHoles(text, { record: this.dispatchRecord(action), params: staticParams });
1797+
}
1798+
1799+
/**
1800+
* The record a dispatch runs against, for its copy templates: the row the
1801+
* dispatch carries (`params._rowRecord`), else the context's record — the
1802+
* one rule `templateEvaluatorFor` applies (objectui#12967).
1803+
*/
1804+
private dispatchRecord(action: ActionDef): unknown {
1805+
return dispatchRow(action) ?? this.context.record;
1806+
}
1807+
17261808
/**
17271809
* Post-execution: emit toast notifications, handle chaining, callbacks.
17281810
*/
@@ -1764,7 +1846,11 @@ export class ActionRunner {
17641846
// through here — and a non-string toast payload is rendered as a React
17651847
// child, crashing the page (React #31). Coerce defensively at this
17661848
// single sink so no handler can take the whole page down.
1767-
const raw: unknown = action.errorMessage || result.error;
1849+
// An authored `errorMessage` is shown as written — a string, or the
1850+
// `template` envelope's `source` (objectui#13367); no scope is declared
1851+
// for its holes yet. An envelope with no text falls to the error.
1852+
const authored = actionCopySource(action.errorMessage);
1853+
const raw: unknown = authored || result.error;
17681854
const message = typeof raw === 'string'
17691855
? raw
17701856
: (raw && typeof (raw as { message?: unknown }).message === 'string')
@@ -2750,11 +2836,8 @@ export class ActionRunner {
27502836
* empty.
27512837
*/
27522838
private templateEvaluatorFor(action: ActionDef): ExpressionEvaluator {
2753-
const params = action.params;
2754-
const row = params && typeof params === 'object' && !Array.isArray(params)
2755-
? (params as Record<string, unknown>)._rowRecord
2756-
: undefined;
2757-
if (!row || typeof row !== 'object' || Array.isArray(row)) return this.evaluator;
2839+
const row = dispatchRow(action);
2840+
if (!row) return this.evaluator;
27582841
return new ExpressionEvaluator({ ...this.context, record: row });
27592842
}
27602843

0 commit comments

Comments
 (0)