diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 56253527c69..2caf2149f73 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -19,6 +19,7 @@ "../src/preload/usage-provider-api.ts", "../src/shared/**/*", "../src/main/gitlab/mappers.ts", + "../src/main/ipc/deferred-emoji-shortcode-dataset.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-display-name.ts", diff --git a/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts new file mode 100644 index 00000000000..a4d510650d6 --- /dev/null +++ b/src/main/ipc/deferred-emoji-shortcode-dataset.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import emojiShortcodes from 'emojibase-data/en/shortcodes/emojibase.json' +import { requireEmojiShortcodeDataset } from './deferred-emoji-shortcode-dataset' + +// Lives under src/main (not next to the shared catalog) so the shared tsconfig projects stay +// free of a src/main import — the boundary emoji-shortcode-catalog.lazy.test.ts asserts on. +describe('deferred emoji shortcode dataset', () => { + it('loads the main-side dataset synchronously into an identical catalog', async () => { + vi.resetModules() + const eager = await import('../../shared/emoji-shortcode-catalog.js') + eager.setEmojiShortcodeDatasetLoader(() => emojiShortcodes) + const eagerEntries = eager.getStandardEmojiShortcodeEntries() + const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}') + + vi.resetModules() + const deferred = await import('../../shared/emoji-shortcode-catalog.js') + deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) + + // No await between registration and first use: the require path keeps the sync contract. + expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) + expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( + eagerTransform + ) + }) +}) diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index ab13e610fa3..cf9e1f60609 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -24,10 +24,12 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map([ ]) // `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. -const FETCH_CALL = /\.fetch\(/g +// `\s*` before `(`: the formatter never emits `net.fetch (url)`, but an unformatted call must not +// be a hole in a guard whose whole job is to fail on the call nobody reviewed. +const FETCH_CALL = /\.fetch\s*\(/g const RECEIVER_IDENTIFIER = /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*$/ const DEFAULT_SESSION_RECEIVERS = new Set(['net', 'globalThis', 'global']) -const NET_REQUEST_CALL = /(? { ) expect(desktopStartup).toContain('recordRuntimeRpcStartFailure(') // Why: `void`, not `await` — awaiting the dialog would park the rest of startup behind a modal. - expect(desktopStartup).toMatch(/void showRuntimeRpcStartupFailureDialog\(\s*win,/) + // It chains off the i18n barrier (published before this phase starts) so the translated strings + // it reads are loaded, which is a wait on i18n only, never on the dialog itself. + expect(desktopStartup).toMatch( + /void state\.mainProcessI18nReady\.then\(\(\) =>\s*showRuntimeRpcStartupFailureDialog\(\s*win,/ + ) + expect(desktopStartup).not.toMatch(/await[^\n]*showRuntimeRpcStartupFailureDialog\(/) // Why (#11025): a bare console.error here is exactly what left the CLI dead but the app healthy. expect(desktopStartup).not.toContain( "console.error('[runtime] Failed to start local RPC transport:'" diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts index 3fc30ba359c..749eb56cb0f 100644 --- a/src/main/startup/main-process-ready-phase-ordering.test.ts +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -120,6 +120,22 @@ describe('initial proxy application ordering', () => { expect(relayIndex).toBeGreaterThan(proxyIndex) }) + it('waits for i18n before the only launch-phase dialog that reads a translated string', () => { + const ready = readStartupSource('main-process-ready.ts') + const launch = readStartupSource('main-process-runtime-launch.ts') + + // Published before the launch phase starts, or the barrier the dialog awaits is still the + // default resolved promise. + const publishIndex = ready.indexOf('state.mainProcessI18nReady = ') + expect(publishIndex).toBeGreaterThanOrEqual(0) + expect(ready.indexOf('initializeMainProcessRuntimeLaunch(options)')).toBeGreaterThan( + publishIndex + ) + expect(launch).toMatch( + /state\.mainProcessI18nReady\.then\(\(\) =>\s*\n?\s*showRuntimeRpcStartupFailureDialog\(/ + ) + }) + it('keeps headless serve strictly ordered behind the proxy apply', () => { const launch = readStartupSource('main-process-runtime-launch.ts') const serveStart = launch.indexOf('async function launchServeMode(') diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts index c89e2aee23d..835c1f1dd13 100644 --- a/src/main/startup/main-process-ready.ts +++ b/src/main/startup/main-process-ready.ts @@ -1,4 +1,5 @@ import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { mainProcessState as state } from './main-process-state' import { initializeReadyFoundation } from './main-process-ready-foundation' import { initializeReadyRuntimeServices } from './main-process-ready-runtime' import { @@ -15,8 +16,7 @@ export async function initializeMainProcessReady( // Why concurrent: window creation reads no translated string and no menu item, and both the // native menu and the tray only become reachable once the window shows — so serializing them // ahead of openMainWindow only delayed the renderer (8 ms in English, more for a lazy locale). - await Promise.all([ - initializeMainProcessI18nAndMenu(), - initializeMainProcessRuntimeLaunch(options) - ]) + const i18nAndMenuReady = initializeMainProcessI18nAndMenu() + state.mainProcessI18nReady = i18nAndMenuReady.catch(() => {}) + await Promise.all([i18nAndMenuReady, initializeMainProcessRuntimeLaunch(options)]) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 0e73547c133..9390fd23497 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -229,7 +229,12 @@ async function launchDesktopMode( ) ]) if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + // Why gated: this dialog is the only launch-phase text read through translateMain, and i18n + // now settles alongside this phase — without the wait a non-English user could get the + // English defaultValue fallback. Still off the renderer's path (it is failure-only). + void state.mainProcessI18nReady.then(() => + showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + ) } // Why after the window and not before it: the default-session request guard already holds every // fetcher until the persisted proxy lands, so this only has to keep the launch phase itself diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index f467adc3f4d..c88d5a66c48 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -103,6 +103,10 @@ export const mainProcessState = { // Why published: the default-session proxy must be applied before the first app-owned fetcher, // but window creation has no reason to queue behind it (the request guard already fences it). initialProxyApplicationReady: Promise.resolve(), + // Why published: i18n/menu init no longer precedes the launch phase, so the one launch-phase + // path that reads a translated string (the runtime-RPC startup failure dialog) waits on this. + // Never rejects: the phase's own failure is surfaced by initializeMainProcessReady. + mainProcessI18nReady: Promise.resolve(), managedWslCliReconciliationReady: Promise.resolve(), managedWslCliStartupBarrierReady: Promise.resolve(), // Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). diff --git a/src/shared/emoji-shortcode-catalog.lazy.test.ts b/src/shared/emoji-shortcode-catalog.lazy.test.ts index 4c2ce505e24..dc05160696b 100644 --- a/src/shared/emoji-shortcode-catalog.lazy.test.ts +++ b/src/shared/emoji-shortcode-catalog.lazy.test.ts @@ -62,22 +62,4 @@ describe('emoji shortcode catalog laziness', () => { expect(sharedSource).not.toMatch(staticDatasetImport) expect(worktreeLogic).not.toMatch(staticDatasetImport) }) - - it('loads the main-side dataset synchronously into an identical catalog', async () => { - const { requireEmojiShortcodeDataset } = - await import('../main/ipc/deferred-emoji-shortcode-dataset.js') - const eager = await importConfiguredCatalog() - const eagerEntries = eager.getStandardEmojiShortcodeEntries() - const eagerTransform = eager.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}') - - vi.resetModules() - const deferred = await import('./emoji-shortcode-catalog.js') - deferred.setEmojiShortcodeDatasetLoader(requireEmojiShortcodeDataset) - - // No await between registration and first use: the require path keeps the sync contract. - expect(deferred.getStandardEmojiShortcodeEntries()).toEqual(eagerEntries) - expect(deferred.replaceKnownEmojiWithShortcodes('ship \u{1F389} \u{1F44D}')).toBe( - eagerTransform - ) - }) })