fix(startup): scope the deferred emoji loader to the projects that own it

TS6307: the composite web project lists src/main/ipc/worktree-logic.ts, which
now imports the deferred dataset loader, and the shared lazy test reached into
src/main from a project that has no src/main files. Add the loader to
tsconfig.tc.web.json and move the cross-project case into a src/main test.

Also close the last two review gaps: gate the runtime-RPC startup failure
dialog (the only launch-phase translateMain reader) on a published i18n
barrier so a concurrent i18n phase cannot leave a non-English user with the
English fallback, and let the fetch call-site audit match `net.fetch (url)`.
This commit is contained in:
Neil
2026-09-03 20:46:14 -07:00
parent 5d4e150873
commit 2ed433c58c
9 changed files with 66 additions and 26 deletions
+1
View File
@@ -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",
@@ -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
)
})
})
@@ -24,10 +24,12 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map<string, number>([
])
// `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 = /(?<![.\w$])net\.(?:fetch|request)\(/g
const NET_REQUEST_CALL = /(?<![.\w$])net\.(?:fetch|request)\s*\(/g
// Matches `{ session: x }` and the `{ url, session }` shorthand both `net.request` overloads take.
const SESSION_SCOPED_OPTION = /(?:^|[{,\s])(?:session|partition)\s*[:,}]/
@@ -66,7 +66,12 @@ describe('startup ordering', () => {
)
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:'"
@@ -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(')
+4 -4
View File
@@ -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)])
}
@@ -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
+4
View File
@@ -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).
@@ -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
)
})
})