Skip to content

Commit 77c9d13

Browse files
fix(app-shell): an api action declaring opensInNewTab + newTabUrl opens the tab and sends no request (#26922)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb8jxdtRuJHWbbK8Myjz9XsM)_ Fixes #26747 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`type: 'api'` 动作若声明了 `opensInNewTab` + `newTabUrl`,现在点击后会像 spec 写的那样打开新标签页(地址为 `newTabUrl`,`{recordId}` 已替换),并且不再向 `target` 发请求。记录页头部按钮、头部 ⋯ 更多菜单、列表工具栏都生效。 - **为什么改**:QA(G8)在 `gapdata_item` 上实测:从记录页头部和更多菜单点击,没有新标签页,反而 POST 到了目标。原因是控制台为 `api` 类型注册的两个处理器(列表运行时一份、记录页一份)根本不读这对键;新标签快速通道只存在于 `script` 的服务端动作处理器里。 - **风险与回滚**:低。只有同时声明 `opensInNewTab` 和非空 `newTabUrl` 的 api 动作改走既有快速通道,其余 api 动作行为不变;不动 spec、不动服务端。回滚即 revert 本 PR。 - **先看**:`utils/newTabUrlFastPath.ts`(路由规则);`RecordDetailView.tsx` 与 `useConsoleActionRuntime.tsx` 里新增的 `apiActionHandler`。 - **两分钟验证**:给任一对象加一个 `type: 'api'`、`opensInNewTab: true`、`newTabUrl: '/export/{recordId}'` 的头部动作,打开一条记录点击它,预期新标签页打开到 `/export/<记录id>`,网络面板里没有发往 `target` 的请求。 - **需要你做的**:无,席位落地。 ## What changes The spec documents `newTabUrl` as the zero-roundtrip target of a new-tab action ("When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST"). The console implements that path in the server-action handler built by `createConsoleServerActionHandler`, which is registered for `type: 'script'`. The two handlers the console registers for `type: 'api'` (the shared runtime's `apiHandler` in `useConsoleActionRuntime`, and the record page's own `apiHandler` in `RecordDetailView`) never read the pair: they always sent their request to `target`. That is the link that failed. The runner (`ActionRunner.dispatchCollected`) hands an `api` action to the registered handler as designed, and the surfaces do route through it. - New `utils/newTabUrlFastPath.ts` holds the routing rule: `takesNewTabUrlFastPath(action)` (the fast path's own condition, now also used by `createConsoleServerActionHandler` instead of its inline check) and `withNewTabUrlFastPath(apiHandler, consoleHandler)`, which sends an action taking the fast path to the surface's console server-action handler and every other action to the api handler unchanged. The fast path's early row-count refusal is forwarded only for those actions. - `useConsoleActionRuntime` registers the composed handler for `api` and returns it as `apiHandler`, so `ConsoleShell`'s root provider gets it too. `RecordDetailView` registers it for its own provider, composed with the page's server-action handler, so the record id is this page's record. - No second interpolator: the `{recordId}` substitution, the pre-opened tab and the record-id resolution are the existing fast path's. ## Why Declared ⇒ enforced: an api action declaring the new-tab pair opened no tab and POSTed instead (QA run studio-qa-2026-10-27, id G8). ## Tests - `RecordDetailView.apiNewTabUrl-26747.test.tsx` mounts the real record page and clicks through the real header and ⋯ menu. Header click and more-menu click: `window.open('about:blank', '_blank')` once, the tab's `location.href` is the origin plus `/export/` and the URL-encoded record id, and no request through either fetch reaches the target. CONTROL: without `newTabUrl` the click POSTs to `target`. - `useConsoleActionRuntime.apiNewTabUrl-26747.test.tsx` drives a real `ActionRunner` with the runtime's context and handlers. List toolbar with one row selected: the tab goes to the interpolated URL, nothing is sent. The returned `apiHandler` (what `ConsoleShell` registers) takes the same path. Two rows selected: refused before any tab opens. CONTROL: without `newTabUrl` the api action POSTs to `target`. - Ablation (one-off, not kept): with the fix committed, `node scripts/ablation-replace.mjs` replaced `? consoleHandler(action, context)` with `? apiHandler(action, context)` in `newTabUrlFastPath.ts` (anchor 1 → 0, blob `3ef0dcca` → `087c133d`). The two files ran red: Tests 4 failed, 3 passed (7), exactly the four fast-path cases. Restored: blob equals HEAD, `git diff HEAD` empty. ## Acceptance notes - **The rule lives in its own module on purpose.** Nineteen app-shell test files stub `utils/consoleServerAction` with a factory naming only `createConsoleServerActionHandler`. A first cut that exported the rule from there made all nineteen throw on mount (131 tests); in `newTabUrlFastPath.ts` it is untouched by those stubs. - **Absolute `newTabUrl` is not addressed (open question for the maintainer).** The spec calls `newTabUrl` "A path template", and the fast path prefixes the backend origin (`baseUrl() || window.location.origin`). QA's repro used an absolute `https://example.com/export/{recordId}`, which this PR now routes to a tab, but at the origin followed by the absolute URL. Whether an absolute value should be refused at authoring or navigated verbatim is a spec decision, so it is left unchanged here. - `opensInNewTab` on an api action WITHOUT `newTabUrl` (pre-open, then follow a returned `redirectUrl`) is not wired for `type: 'api'` either; out of this card's scope, noted only. ## Verification All readings at head `1bcc587b` (base `6090bb73`), under `scripts/pm/os-verify-lock.sh`. <details><summary>Touched package: @object-ui/app-shell</summary> - Dependency closure: `turbo run build --filter='@object-ui/app-shell^...'` plus formula, client, sdui-parser, lint: 63 tasks successful, VERDICT command-exit 0. - `pnpm --filter @object-ui/app-shell run type-check` (covers test files): VERDICT command-exit 0. - The two new test files: Test Files 2 passed (2), Tests 7 passed (7). - Narrowed app-shell run: every test file named for `useConsoleActionRuntime`, `consoleServerAction`, `RecordDetailView` or `ConsoleShell`, plus every app-shell test naming `apiHandler` or `newTabUrl` (100 files): Test Files 100 passed (100), Tests 736 passed (736). The rest of app-shell is left to CI's Test UI shards. - ESLint with `ui/eslint.config.js` on the five touched files: 0 errors, no warning on a changed source line (the new tests carry the `no-explicit-any` warnings their sibling harnesses carry). - `pnpm --dir ui run check:new-line-citations`: 0 new cross-file line-address citations. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands for the 7 changed paths; all 34 exit 0, each code captured before any pipe, among them `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:type-check-coverage`, `check:type-check-debt`, `check-sdui-manifest`, `check-empty-changeset` and `check-changeset-no-major`. `pnpm --dir ui exec vitest run scripts/`: Test Files 178 passed, 2 skipped (180), Tests 5065 passed, 2 skipped. - `--ran` reconciled: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: 0 anchored files in the change set. - Families CI runs over the whole tree (type-check lanes, lint, artifact rosters) are CI's; the PR's check-runs are the reading. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSH8FbKjEztmGT1sbB --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSH8FbKjEztmGT1sbB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 354dfcd commit 77c9d13

7 files changed

Lines changed: 546 additions & 6 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a `type: 'api'` action declaring `opensInNewTab` + `newTabUrl` opens the tab and sends no request
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The spec documents `newTabUrl` as the zero-roundtrip target of a new-tab action: on click the console pre-opens a tab and navigates it to `newTabUrl` (with `{recordId}` substituted), and no action request is sent. The console's server-action handler did this, but its `type: 'api'` handlers never read the pair. A `type: 'api'` action declaring both therefore opened no tab and POSTed to its `target` instead, from the record header, its more-menu and the list toolbar.
11+
12+
What changes:
13+
14+
- On the record page (header row and the more-menu) and on every surface the shared console runtime serves (the list toolbar and the console's root action provider), a `type: 'api'` action with `opensInNewTab: true` and a `newTabUrl` now takes that path: the record id resolves the way that surface resolves it, the pre-opened tab goes to `newTabUrl`, and nothing is sent to `target`. A list launch with several rows selected is refused before the tab opens, as for a `script` action.
15+
- A `type: 'api'` action without `newTabUrl` sends its request to `target` as before.
16+
17+
What to do: nothing. An api action that declares the pair now does what it declares.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#26747 — the shared console action runtime (list pages, the
11+
* list toolbar, the console root provider) honours `opensInNewTab` +
12+
* `newTabUrl` on a `type: 'api'` action: the pre-opened tab goes to
13+
* `newTabUrl` with the selected row's id substituted, and no request is sent.
14+
*
15+
* Driven through a real `ActionRunner` carrying the runtime's own context and
16+
* registered handler set, exactly as `ActionProvider` wires them, and through
17+
* the `apiHandler` the hook returns (what `ConsoleShell` registers).
18+
*
19+
* CONTROL: without `newTabUrl` the api action still sends its request to
20+
* `target`. The fast path's row-count refusal is the server-action one: two
21+
* selected rows refuse before any tab opens.
22+
*/
23+
24+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
25+
import { renderHook, act } from '@testing-library/react';
26+
27+
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
28+
29+
const authFetchSpy = vi.fn();
30+
vi.mock('@object-ui/auth', async (importOriginal) => ({
31+
...(await importOriginal<Record<string, unknown>>()),
32+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
33+
createAuthenticatedFetch: () => authFetchSpy,
34+
}));
35+
36+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
37+
...(await importOriginal<Record<string, unknown>>()),
38+
useObjectLabel: () => ({
39+
fieldLabel: (_o: any, _n: any, l: any) => l,
40+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
41+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
42+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
43+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
44+
}),
45+
useObjectTranslation: () => ({
46+
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
47+
}),
48+
}));
49+
50+
vi.mock('../useActionModal', () => ({
51+
useActionModal: () => ({
52+
modalHandler: vi.fn(async () => ({ success: true })),
53+
modalElement: null,
54+
closeModal: () => {},
55+
resolveModalTarget: vi.fn(async () => null),
56+
}),
57+
}));
58+
59+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
60+
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
61+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
62+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
63+
64+
vi.mock('sonner', () => {
65+
const fn: any = vi.fn();
66+
fn.error = vi.fn();
67+
fn.success = vi.fn();
68+
return { toast: fn };
69+
});
70+
71+
import { ActionRunner } from '@object-ui/core';
72+
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';
73+
74+
const TARGET = '/api/v1/export/gapdata_item';
75+
const NEW_TAB_ACTION = {
76+
name: 'export_tab',
77+
label: 'Export',
78+
type: 'api',
79+
target: TARGET,
80+
opensInNewTab: true,
81+
newTabUrl: '/export/{recordId}',
82+
locations: ['list_toolbar'],
83+
};
84+
85+
function stubWindowOpen() {
86+
const tab = {
87+
document: { write: vi.fn(), close: vi.fn() },
88+
location: { href: 'about:blank' },
89+
close: vi.fn(),
90+
};
91+
const open = vi.spyOn(window, 'open').mockImplementation(() => tab as unknown as Window);
92+
return { tab, open };
93+
}
94+
95+
/** A real runner wired to the runtime the way `ActionProvider` wires it. */
96+
function runnerFor(selectedRecords: Array<Record<string, unknown>>) {
97+
const { result } = renderHook(() =>
98+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'gapdata_item' }),
99+
);
100+
const props = result.current.actionProviderProps;
101+
const runner = new ActionRunner({ ...props.context, selectedRecords } as any);
102+
for (const [type, handler] of Object.entries(props.handlers ?? {})) {
103+
runner.registerHandler(type, handler as any);
104+
}
105+
return { runner, runtime: result.current };
106+
}
107+
108+
beforeEach(() => {
109+
authFetchSpy.mockReset();
110+
authFetchSpy.mockImplementation(async () =>
111+
new Response(JSON.stringify({ success: true, data: {} }), {
112+
status: 200,
113+
headers: { 'content-type': 'application/json' },
114+
}),
115+
);
116+
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
117+
});
118+
119+
afterEach(() => {
120+
vi.unstubAllGlobals();
121+
vi.restoreAllMocks();
122+
});
123+
124+
describe('objectstack#26747 — console runtime: an api action with opensInNewTab + newTabUrl opens the tab and sends nothing', () => {
125+
it('list toolbar, one row selected: the pre-opened tab goes to the interpolated newTabUrl, and no request is sent', async () => {
126+
const { runner } = runnerFor([{ id: 'row 7' }]);
127+
const { tab, open } = stubWindowOpen();
128+
129+
let result: any;
130+
await act(async () => {
131+
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
132+
});
133+
134+
expect(result.success).toBe(true);
135+
expect(open).toHaveBeenCalledTimes(1);
136+
expect(open).toHaveBeenCalledWith('about:blank', '_blank');
137+
expect(tab.location.href).toBe(`${window.location.origin}/export/row%207`);
138+
expect(authFetchSpy).not.toHaveBeenCalled();
139+
expect(globalThis.fetch).not.toHaveBeenCalled();
140+
});
141+
142+
it('the apiHandler the hook returns (what ConsoleShell registers) takes the same path', async () => {
143+
const { runtime } = runnerFor([]);
144+
const { tab } = stubWindowOpen();
145+
146+
await act(async () => {
147+
await runtime.apiHandler({ ...NEW_TAB_ACTION, params: { recordId: 'rec-1' } } as any);
148+
});
149+
150+
expect(tab.location.href).toBe(`${window.location.origin}/export/rec-1`);
151+
expect(authFetchSpy).not.toHaveBeenCalled();
152+
});
153+
154+
it('two rows selected: refused before any tab opens, with nothing sent', async () => {
155+
const { runner } = runnerFor([{ id: 'a' }, { id: 'b' }]);
156+
const { open } = stubWindowOpen();
157+
158+
let result: any;
159+
await act(async () => {
160+
result = await runner.execute({ ...NEW_TAB_ACTION } as any);
161+
});
162+
163+
expect(result.success).toBe(false);
164+
expect(open).not.toHaveBeenCalled();
165+
expect(authFetchSpy).not.toHaveBeenCalled();
166+
});
167+
168+
it('CONTROL: without newTabUrl the api action still sends its request to the target', async () => {
169+
const { newTabUrl: _dropped, ...withoutNewTabUrl } = NEW_TAB_ACTION;
170+
const { runner } = runnerFor([{ id: 'row 7' }]);
171+
const { open } = stubWindowOpen();
172+
173+
await act(async () => {
174+
await runner.execute({ ...withoutNewTabUrl } as any);
175+
});
176+
177+
expect(authFetchSpy).toHaveBeenCalledTimes(1);
178+
const [url, init] = authFetchSpy.mock.calls[0] as unknown as [string, RequestInit];
179+
expect(url.endsWith(TARGET)).toBe(true);
180+
expect(init.method).toBe('POST');
181+
expect(open).not.toHaveBeenCalledWith('about:blank', '_blank');
182+
});
183+
});

‎ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { entitlementDialogFromError, type EntitlementDialogSpec } from '../envir
5353
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
5454
import { launchConsoleFlow } from '../utils/flowLaunch.js';
5555
import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js';
56+
import { withNewTabUrlFastPath } from '../utils/newTabUrlFastPath.js';
5657
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
5758
import { actionContextOrg } from '../utils/actionContextOrg.js';
5859
import { resolveHostAppSegment } from '../utils/appRoute.js';
@@ -803,6 +804,14 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
803804
[flowDispatch, serverActionHandler],
804805
);
805806

807+
// The registered `api` handler: {@link apiHandler}, except that an action
808+
// declaring `opensInNewTab` + `newTabUrl` takes the server-action handler's
809+
// zero-roundtrip new-tab path, which sends no request (objectstack#26747).
810+
const apiActionHandler = useMemo(
811+
() => withNewTabUrlFastPath(apiHandler, serverActionHandler),
812+
[apiHandler, serverActionHandler],
813+
);
814+
806815
// Client-side modal transport, shared with RecordDetailView so a
807816
// `type: 'modal'` action behaves the SAME on a list page, an SDUI page, a
808817
// declared-actions bar and a record page. Before this, only RecordDetailView
@@ -883,10 +892,10 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
883892
onParamCollection: paramCollectionHandler,
884893
onResultDialog: resultDialogHandler,
885894
onModal: modalHandler,
886-
handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },
895+
handlers: { api: apiActionHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },
887896
}), [
888897
objectName, objectDef, currentUser, activeOrganization, confirmHandler, toastHandler,
889-
navigateHandler, paramCollectionHandler, resultDialogHandler, apiHandler,
898+
navigateHandler, paramCollectionHandler, resultDialogHandler, apiActionHandler,
890899
flowHandler, serverActionHandler, modalHandler, modalActionHandler,
891900
]);
892901

@@ -982,7 +991,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
982991
navigateHandler,
983992
paramCollectionHandler,
984993
resultDialogHandler,
985-
apiHandler,
994+
// The composed handler, so every host that registers it (ConsoleShell's
995+
// root provider included) honours the `newTabUrl` fast path.
996+
apiHandler: apiActionHandler,
986997
flowHandler,
987998
serverActionHandler,
988999
modalActionHandler,

‎ui/packages/app-shell/src/utils/consoleServerAction.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
type ServerActionHandler,
5454
type ServerActionRecordIdResolver,
5555
} from '@object-ui/core';
56+
import { takesNewTabUrlFastPath } from './newTabUrlFastPath.js';
5657

5758
/**
5859
* i18next-style translate function: `t(key, englishDefault)` returns the
@@ -185,8 +186,8 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
185186
// until the (possibly slow) endpoint commits its redirect. Record-id
186187
// resolution runs BEFORE the pre-open so a blocked resolution
187188
// (multi-select, empty selection) never flashes a tab.
188-
const newTabUrl = typeof action.newTabUrl === 'string' ? action.newTabUrl : '';
189-
if (action.opensInNewTab && newTabUrl) {
189+
if (takesNewTabUrlFastPath(action)) {
190+
const newTabUrl = action.newTabUrl;
190191
// Both refusals below are shown in the session's language
191192
// (objectui#13397): the resolver's through the pack key it names
192193
// (objectui#13384), this wrapper's own through `console.serverAction.*`.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The `newTabUrl` fast path's routing rule, shared by the console server-action
11+
* handler (`consoleServerAction`) and the hosts that register a `type: 'api'`
12+
* handler beside it (`useConsoleActionRuntime`, `RecordDetailView`).
13+
*
14+
* It lives apart from `consoleServerAction` on purpose: the hosts' tests stub
15+
* that module with a factory, and a rule kept there would vanish under every
16+
* such stub. Kept here, an api action is routed by the same rule under test as
17+
* in the console.
18+
*/
19+
20+
import type { ActionContext, ActionDef, ActionResult, ServerActionHandler } from '@object-ui/core';
21+
22+
/**
23+
* Whether `action` takes the zero-roundtrip `newTabUrl` fast path:
24+
* `opensInNewTab` plus a non-empty `newTabUrl`. The spec gives that pair one
25+
* meaning whatever the action's `type` — navigate the pre-opened tab, no
26+
* request — so a host's `type: 'api'` handler asks this and hands such an
27+
* action to the handler `createConsoleServerActionHandler` builds,
28+
* instead of sending its own request to `target`.
29+
*/
30+
export function takesNewTabUrlFastPath(action: ActionDef): action is ActionDef & { newTabUrl: string } {
31+
return !!action.opensInNewTab && typeof action.newTabUrl === 'string' && action.newTabUrl !== '';
32+
}
33+
34+
/**
35+
* A host's registered `type: 'api'` handler, made to honour the `newTabUrl`
36+
* fast path: an action that {@link takesNewTabUrlFastPath} goes to
37+
* `consoleHandler` (the handler `createConsoleServerActionHandler`
38+
* built for the same surface, so the record id resolves the way that surface
39+
* resolves it), and every other action to `apiHandler` unchanged.
40+
*
41+
* Without it the console's api handlers sent their request to `target` and
42+
* never read the pair, so a `type: 'api'` action declaring `opensInNewTab` +
43+
* `newTabUrl` opened no tab and POSTed instead, from the record header, its
44+
* more-menu and the list toolbar alike (objectstack#26747). The pair's early
45+
* row-count refusal is the fast path's own, forwarded only for those actions;
46+
* an ordinary api action keeps having none.
47+
*/
48+
export function withNewTabUrlFastPath(
49+
apiHandler: (action: ActionDef, context?: ActionContext) => Promise<ActionResult>,
50+
consoleHandler: ServerActionHandler,
51+
): ServerActionHandler {
52+
return Object.assign(
53+
(action: ActionDef, context?: ActionContext) => (takesNewTabUrlFastPath(action)
54+
? consoleHandler(action, context)
55+
: apiHandler(action, context)),
56+
{
57+
refuseBeforeInput: (action: ActionDef, context?: ActionContext) => (takesNewTabUrlFastPath(action)
58+
? consoleHandler.refuseBeforeInput?.(action, context)
59+
: undefined),
60+
},
61+
);
62+
}

0 commit comments

Comments
 (0)