From 8cb1a8030f777032dc846f43dd5dfc76542d59a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:53:46 +0000 Subject: [PATCH 1/2] fix(app-shell): localize popup-blocked toast and SSO spinner tab copy via i18n (#3321) The opensInNewTab choreography in consoleServerAction.ts shipped hard-coded bilingual Chinese/English copy, violating AGENTS.md Commandment #-1. The four strings (spinner tab title + body, popup-blocked toast title/description/ action label) now route through new console.serverAction.* keys in @object-ui/i18n (all eleven locale packs, key parity kept), injected via an optional i18next-style t option on createConsoleServerActionHandler from the two hook-context call sites; English defaults apply when t is omitted. Locale strings are HTML-escaped before entering the spinner document. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- .changeset/popup-copy-english-i18n.md | 19 +++++ .../src/hooks/useConsoleActionRuntime.tsx | 8 +- .../__tests__/consoleServerAction.test.tsx | 74 +++++++++++++++++++ .../src/utils/consoleServerAction.ts | 47 +++++++++--- .../app-shell/src/views/RecordDetailView.tsx | 8 +- packages/i18n/src/locales/ar.ts | 7 ++ packages/i18n/src/locales/de.ts | 7 ++ packages/i18n/src/locales/en.ts | 9 +++ packages/i18n/src/locales/es.ts | 7 ++ packages/i18n/src/locales/fr.ts | 7 ++ packages/i18n/src/locales/ja.ts | 7 ++ packages/i18n/src/locales/ko.ts | 7 ++ packages/i18n/src/locales/pt.ts | 7 ++ packages/i18n/src/locales/ru.ts | 7 ++ packages/i18n/src/locales/zh.ts | 7 ++ 15 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 .changeset/popup-copy-english-i18n.md diff --git a/.changeset/popup-copy-english-i18n.md b/.changeset/popup-copy-english-i18n.md new file mode 100644 index 000000000..3f98bf4eb --- /dev/null +++ b/.changeset/popup-copy-english-i18n.md @@ -0,0 +1,19 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +The console server-action wrapper's `opensInNewTab` choreography no longer +ships hard-coded bilingual Chinese/English copy (objectui#3321, AGENTS.md +Commandment #-1): the pre-opened SSO spinner tab (title + body) and the +popup-blocked toast (title, description, action label) are now localized +through new `console.serverAction.*` keys in `@object-ui/i18n`, added at full +parity across all eleven locale packs. + +`createConsoleServerActionHandler` gains an optional i18next-style `t` option +(`t(key, englishDefault)`) — the wrapper is a plain function, so the translate +function is injected from the two hook-context call sites +(`useConsoleActionRuntime`, `RecordDetailView`) via `useObjectTranslation`. +When omitted (tests / standalone), every string falls back to its English +default; no non-English copy remains in code. Locale strings are HTML-escaped +before being written into the spinner document. diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 582901da1..1a1441e58 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -507,14 +507,18 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // memoized once) while the config thunks read the latest object scope and // refresh callback — the factory's in-flight guard only spans invocations of // the same instance. - const serverActionEnvRef = useRef({ objApiName, refresh }); - serverActionEnvRef.current = { objApiName, refresh }; + const serverActionEnvRef = useRef({ objApiName, refresh, t }); + serverActionEnvRef.current = { objApiName, refresh, t }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, baseUrl: () => import.meta.env.VITE_SERVER_URL || '', resolveObject: () => serverActionEnvRef.current.objApiName, onRefresh: () => serverActionEnvRef.current.refresh(), + // Read through the env ref so the spinner-tab / popup-blocked copy + // follows a language switch without invalidating the handler instance + // (objectui#3321). + t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)), }), [authFetch], ); diff --git a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx index 9a1aabae0..bd8e0fe87 100644 --- a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx +++ b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx @@ -160,6 +160,11 @@ describe('redirectUrl convention', () => { expect(openSpy).toHaveBeenCalledWith('https://example.test/sso', '_blank'); expect(toast).toHaveBeenCalledTimes(1); // popup blocked → one-click fallback + // Default (no injected `t`) copy is the English fallback (objectui#3321). + expect(toast).toHaveBeenCalledWith('Popup blocked', expect.objectContaining({ + description: 'Your browser blocked the new tab from opening.', + action: expect.objectContaining({ label: 'Open in new tab' }), + })); }); it('closes the optimistically pre-opened tab when the handler returns no redirectUrl', async () => { @@ -174,6 +179,75 @@ describe('redirectUrl convention', () => { }); }); +describe('user-facing copy (objectui#3321)', () => { + it('the spinner tab defaults to English-only copy — no CJK in code (Commandment #-1)', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler(); + + await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, + newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' }, + } as any); + + expect(tab.document.write).toHaveBeenCalledTimes(1); + const html = (tab.document.write as any).mock.calls[0][0] as string; + expect(html).toContain('Opening…'); + expect(html).toContain('Opening… this may take a moment.'); + // The commandment pin: the copy shipped from CODE carries no CJK. Chinese + // lives in `@object-ui/i18n`'s zh locale pack and arrives via `t`. + expect(html).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/); + }); + + it('an injected `t` localizes the spinner tab via console.serverAction.* keys', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ t: (key: string) => `x:${key}` }); + + await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, + newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' }, + } as any); + + const html = (tab.document.write as any).mock.calls[0][0] as string; + expect(html).toContain('x:console.serverAction.openingTitle'); + expect(html).toContain('x:console.serverAction.openingBody'); + }); + + it('an injected `t` localizes the popup-blocked toast via console.serverAction.* keys', async () => { + vi.spyOn(window, 'open').mockReturnValue(null); + const { handler } = makeHandler({ + t: (key: string) => `x:${key}`, + fetch: okFetch({ + success: true, + data: { success: true, data: { redirectUrl: 'https://example.test/sso' } }, + }) as any, + }); + + await handler({ type: 'script', name: 'open_env' } as any); + + expect(toast).toHaveBeenCalledWith('x:console.serverAction.popupBlockedTitle', expect.objectContaining({ + description: 'x:console.serverAction.popupBlockedDescription', + action: expect.objectContaining({ label: 'x:console.serverAction.popupBlockedAction' }), + })); + }); + + it('locale strings are HTML-escaped before entering the spinner document', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ t: (_key: string, englishDefault: string) => `&${englishDefault}` }); + + await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, + newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' }, + } as any); + + const html = (tab.document.write as any).mock.calls[0][0] as string; + expect(html).toContain('<b>&Opening…'); + expect(html).not.toContain('&Opening…'); + }); +}); + describe('failure paths close the pre-opened tab', () => { it('on a failed dispatch', async () => { const tab = makeTab(); diff --git a/packages/app-shell/src/utils/consoleServerAction.ts b/packages/app-shell/src/utils/consoleServerAction.ts index ab5ba5af7..c5a9f6541 100644 --- a/packages/app-shell/src/utils/consoleServerAction.ts +++ b/packages/app-shell/src/utils/consoleServerAction.ts @@ -48,6 +48,17 @@ import { type ServerActionRecordIdResolver, } from '@object-ui/core'; +/** + * i18next-style translate function: `t(key, englishDefault)` returns the + * translation for `key` in the active locale, or `englishDefault` when the + * key is missing. Injected from a hook context (`useObjectTranslation().t`) + * because this wrapper is a plain function and cannot call hooks itself. + */ +export type ConsoleServerActionTranslate = (key: string, englishDefault: string) => string; + +/** No-`t` fallback (tests / standalone): every string is its English default. */ +const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault) => englishDefault; + export interface ConsoleServerActionOptions { /** Authenticated fetch wrapper (Bearer + tenant + cookies). */ fetch: ServerActionFetch; @@ -63,6 +74,14 @@ export interface ConsoleServerActionOptions { resolveRecordId?: ServerActionRecordIdResolver; /** Data invalidation, invoked per the action's `refreshAfter` semantics. */ onRefresh: () => void; + /** + * Localizes the user-facing copy this wrapper owns (the SSO spinner tab, + * the popup-blocked toast) via the `console.serverAction.*` keys in + * `@object-ui/i18n`. Optional: when omitted the English defaults apply — + * per AGENTS.md Commandment #-1, no non-English copy lives in code + * (objectui#3321). + */ + t?: ConsoleServerActionTranslate; } /** @@ -75,11 +94,18 @@ export interface ConsoleServerActionOptions { * fallback would fire, and the CURRENT tab would navigate to the now-consumed * SSO URL (the double-navigation bug). */ -function preOpenSpinnerTab(): Window | null { +/** Minimal HTML escape for locale strings interpolated into the spinner document. */ +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function preOpenSpinnerTab(t: ConsoleServerActionTranslate): Window | null { try { const tab = window.open('about:blank', '_blank'); if (tab) { - tab.document.write('正在打开… Opening…
正在为你打开环境…
'); + const title = escapeHtml(t('console.serverAction.openingTitle', 'Opening…')); + const body = escapeHtml(t('console.serverAction.openingBody', 'Opening… this may take a moment.')); + tab.document.write(`${title}
${body}
`); tab.document.close(); } return tab; @@ -99,7 +125,7 @@ function closeTab(tab: Window | null): void { * and, when the popup blocker eats it, offer a one-click toast instead of * silently hijacking the current tab. */ -function openInTab(preOpenedTab: Window | null, url: string): void { +function openInTab(preOpenedTab: Window | null, url: string, t: ConsoleServerActionTranslate): void { if (preOpenedTab) { try { preOpenedTab.location.href = url; @@ -114,9 +140,9 @@ function openInTab(preOpenedTab: Window | null, url: string): void { // null return would always trip the toast fallback. try { popup = window.open(url, '_blank'); } catch { popup = null; } if (!popup) { - toast('浏览器拦截了弹窗 / Popup blocked', { - description: '点击在新标签页打开环境', - action: { label: '打开环境', onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } }, + toast(t('console.serverAction.popupBlockedTitle', 'Popup blocked'), { + description: t('console.serverAction.popupBlockedDescription', 'Your browser blocked the new tab from opening.'), + action: { label: t('console.serverAction.popupBlockedAction', 'Open in new tab'), onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } }, duration: 10000, }); } @@ -131,6 +157,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption onRefresh: opts.onRefresh, }); const resolveRecordId = opts.resolveRecordId ?? resolveServerActionRecordId; + const t = opts.t ?? defaultTranslate; return async (action: ActionDef, context?: ActionContext) => { // ── Zero-roundtrip fast path ──────────────────────────────────────── @@ -151,18 +178,18 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption if (recordId == null) { return { success: false, error: 'This action runs on a single record — no record id available.' }; } - const preOpenedTab = preOpenSpinnerTab(); + const preOpenedTab = preOpenSpinnerTab(t); // Absolute URL required: the pre-opened tab is an about:blank document, // so a bare-relative href has no reliable resolution base. const directUrl = `${opts.baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(recordId)))}`; - openInTab(preOpenedTab, directUrl); + openInTab(preOpenedTab, directUrl, t); if (action.refreshAfter === true) opts.onRefresh(); return { success: true }; } // Popup-blocker workaround: pre-open about:blank synchronously before the // await so the user-gesture context is preserved. - const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab() : null; + const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab(t) : null; try { const result = await dispatch(action, context); if (!result.success) { @@ -182,7 +209,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption ? (payload as { redirectUrl: string }).redirectUrl : null; if (redirectUrl) { - openInTab(preOpenedTab, redirectUrl); + openInTab(preOpenedTab, redirectUrl, t); } else { // Handler didn't return a redirectUrl — close the empty tab we // optimistically pre-opened so the user isn't left with about:blank. diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index fa21e281c..5eb52954b 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -814,8 +814,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // `action.recordId`; header/more actions carry none and use this page's id. // The env ref keeps the handler instance stable across renders (authFetch is // memoized once) while the thunks read the live record/object. - const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged }); - serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged }; + const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t }); + serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, @@ -825,6 +825,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri recordId: (action as { recordId?: unknown }).recordId ?? serverActionEnvRef.current.pureRecordId ?? undefined, }), onRefresh: () => serverActionEnvRef.current.notifyRecordChanged(), + // Read through the env ref so the spinner-tab / popup-blocked copy + // follows a language switch without invalidating the handler instance + // (objectui#3321). + t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)), }), [authFetch], ); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index fc16ce393..bd2676b1f 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1419,6 +1419,13 @@ const ar = { retry: "إعادة المحاولة", retrying: "جاري إعادة المحاولة…", }, + serverAction: { + openingTitle: "جارٍ الفتح…", + openingBody: "جارٍ الفتح… قد يستغرق ذلك لحظة.", + popupBlockedTitle: "تم حظر النافذة المنبثقة", + popupBlockedDescription: "حظر المتصفح فتح علامة التبويب الجديدة.", + popupBlockedAction: "فتح في علامة تبويب جديدة", + }, shortcuts: { title: "اختصارات لوحة المفاتيح", description: "مرجع سريع لجميع اختصارات لوحة المفاتيح المتاحة.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cd0f2b1ad..a5ad98059 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1419,6 +1419,13 @@ const de = { retry: "Erneut versuchen", retrying: "Wird wiederholt…", }, + serverAction: { + openingTitle: "Wird geöffnet…", + openingBody: "Wird geöffnet… das kann einen Moment dauern.", + popupBlockedTitle: "Pop-up blockiert", + popupBlockedDescription: "Der Browser hat das Öffnen des neuen Tabs blockiert.", + popupBlockedAction: "In neuem Tab öffnen", + }, shortcuts: { title: "Tastenkürzel", description: "Kurzreferenz für alle verfügbaren Tastenkürzel.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 006506be3..47c8ca9db 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1414,6 +1414,15 @@ const en = { retry: 'Retry', retrying: 'Retrying…', }, + // Copy owned by the console server-action wrapper (`consoleServerAction.ts`, + // objectui#3321): the pre-opened SSO spinner tab and the popup-blocked toast. + serverAction: { + openingTitle: 'Opening…', + openingBody: 'Opening… this may take a moment.', + popupBlockedTitle: 'Popup blocked', + popupBlockedDescription: 'Your browser blocked the new tab from opening.', + popupBlockedAction: 'Open in new tab', + }, shortcuts: { title: 'Keyboard Shortcuts', description: 'Quick reference for all available keyboard shortcuts.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2ca12106d..04ad5c6cb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1419,6 +1419,13 @@ const es = { retry: "Reintentar", retrying: "Reintentando…", }, + serverAction: { + openingTitle: "Abriendo…", + openingBody: "Abriendo… esto puede tardar un momento.", + popupBlockedTitle: "Ventana emergente bloqueada", + popupBlockedDescription: "El navegador bloqueó la apertura de la nueva pestaña.", + popupBlockedAction: "Abrir en una pestaña nueva", + }, shortcuts: { title: "Atajos de teclado", description: "Referencia rápida de todos los atajos de teclado disponibles.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d89034537..f6ded0857 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1419,6 +1419,13 @@ const fr = { retry: "Réessayer", retrying: "Nouvelle tentative…", }, + serverAction: { + openingTitle: "Ouverture…", + openingBody: "Ouverture… cela peut prendre un moment.", + popupBlockedTitle: "Fenêtre contextuelle bloquée", + popupBlockedDescription: "Le navigateur a bloqué l'ouverture du nouvel onglet.", + popupBlockedAction: "Ouvrir dans un nouvel onglet", + }, shortcuts: { title: "Raccourcis clavier", description: "Référence rapide de tous les raccourcis clavier disponibles.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 3d6dda287..acd0bfbce 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1419,6 +1419,13 @@ const ja = { retry: "再試行", retrying: "再試行中…", }, + serverAction: { + openingTitle: "開いています…", + openingBody: "開いています…しばらくお待ちください。", + popupBlockedTitle: "ポップアップがブロックされました", + popupBlockedDescription: "ブラウザが新しいタブを開くのをブロックしました。", + popupBlockedAction: "新しいタブで開く", + }, shortcuts: { title: "キーボードショートカット", description: "利用可能なすべてのキーボードショートカットのクイックリファレンス。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2bcdddc1a..b436817ba 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1419,6 +1419,13 @@ const ko = { retry: "재시도", retrying: "재시도 중…", }, + serverAction: { + openingTitle: "여는 중…", + openingBody: "여는 중… 잠시 기다려 주세요.", + popupBlockedTitle: "팝업이 차단되었습니다", + popupBlockedDescription: "브라우저가 새 탭 열기를 차단했습니다.", + popupBlockedAction: "새 탭에서 열기", + }, shortcuts: { title: "키보드 단축키", description: "사용 가능한 모든 키보드 단축키 빠른 참조.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index e6e2d6c0b..8b6cac503 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1419,6 +1419,13 @@ const pt = { retry: "Tentar novamente", retrying: "Tentando novamente…", }, + serverAction: { + openingTitle: "Abrindo…", + openingBody: "Abrindo… isso pode levar um momento.", + popupBlockedTitle: "Pop-up bloqueado", + popupBlockedDescription: "O navegador bloqueou a abertura da nova aba.", + popupBlockedAction: "Abrir em nova aba", + }, shortcuts: { title: "Atalhos de teclado", description: "Referência rápida de todos os atalhos de teclado disponíveis.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index cb65afaaf..49c913dec 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1419,6 +1419,13 @@ const ru = { retry: "Повторить", retrying: "Повтор…", }, + serverAction: { + openingTitle: "Открытие…", + openingBody: "Открытие… это может занять некоторое время.", + popupBlockedTitle: "Всплывающее окно заблокировано", + popupBlockedDescription: "Браузер заблокировал открытие новой вкладки.", + popupBlockedAction: "Открыть в новой вкладке", + }, shortcuts: { title: "Горячие клавиши", description: "Краткий справочник по всем доступным горячим клавишам.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a18d651a3..e944f4105 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1417,6 +1417,13 @@ const zh = { retry: '重试', retrying: '正在重试…', }, + serverAction: { + openingTitle: '正在打开…', + openingBody: '正在打开…可能需要一点时间。', + popupBlockedTitle: '浏览器拦截了弹窗', + popupBlockedDescription: '浏览器阻止了新标签页的打开。', + popupBlockedAction: '在新标签页打开', + }, shortcuts: { title: '键盘快捷键', description: '所有可用键盘快捷键的快速参考。', From 0afb31dd73b36382e2f7d06eebd608493e7e6b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:55:10 +0000 Subject: [PATCH 2/2] docs(app-shell): keep preOpenSpinnerTab doc comment attached to its function Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- packages/app-shell/src/utils/consoleServerAction.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app-shell/src/utils/consoleServerAction.ts b/packages/app-shell/src/utils/consoleServerAction.ts index c5a9f6541..d437900d2 100644 --- a/packages/app-shell/src/utils/consoleServerAction.ts +++ b/packages/app-shell/src/utils/consoleServerAction.ts @@ -84,6 +84,11 @@ export interface ConsoleServerActionOptions { t?: ConsoleServerActionTranslate; } +/** Minimal HTML escape for locale strings interpolated into the spinner document. */ +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + /** * Pre-open `about:blank` synchronously (user-gesture context) and paint * progress immediately so the tab isn't blank/frozen during the (slow) @@ -94,11 +99,6 @@ export interface ConsoleServerActionOptions { * fallback would fire, and the CURRENT tab would navigate to the now-consumed * SSO URL (the double-navigation bug). */ -/** Minimal HTML escape for locale strings interpolated into the spinner document. */ -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - function preOpenSpinnerTab(t: ConsoleServerActionTranslate): Window | null { try { const tab = window.open('about:blank', '_blank');