# ADR 012 — Browser-Mode Test Mocking Strategy **Status:** Accepted **Date:** 2026-05-11 (revised 2026-05-12) **Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553) --- ## Context Vitest browser-mode tests (the `client` project, run with `@vitest/browser-playwright` / Chromium) use a different module resolution path than Node-environment tests. When a spec calls `vi.mock('some-module', factory)`, vitest registers a `ManualMockedModule`. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over **birpc** (`resolveManualMock`) to evaluate the factory and return the module body. This is safe for modules that are imported **statically** at spec module-eval time (e.g. `$app/navigation`, `$env/static/public`): those requests resolve before the first test runs and well before any teardown occurs. It is **unsafe** for modules that are imported **dynamically** (e.g. inside an `async onMount`, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing: ``` Error: [birpc] rpc is closed, cannot call "resolveManualMock" ❯ ManualMockedModule.factory node_modules/@vitest/browser/dist/index.js:3221:34 ``` This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green. `pdfjs-dist` and `pdfjs-dist/build/pdf.worker.min.mjs?url` are loaded via `await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')])` inside `usePdfRenderer.svelte.ts::init()`, which is called from `onMount`. These dynamic imports triggered the race. --- ## Decision **Prefer prop injection over `vi.mock(module, factory)` for any module that is loaded dynamically in browser-mode specs.** ### The libLoader pattern (for external rendering libraries) When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default: ```typescript // usePdfRenderer.svelte.ts type LibLoader = () => Promise; const defaultLibLoader: LibLoader = () => Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]); export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... } ``` The component threads the loader as an optional prop: ```svelte let { url, ..., libLoader = undefined } = $props(); const renderer = untrack(() => createPdfRenderer(libLoader)); ``` Tests supply a synchronous fake — no `vi.mock` needed: ```typescript const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} }; const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const); render(PdfViewer, { url: '...', libLoader: fakeLoader }); ``` ### The test-host pattern (for component behaviour) For components that fetch data or call services, the `*.test-host.svelte` pattern threads the dependency as a prop rather than mocking the module. See `PersonMentionEditor.test-host.svelte` for the canonical example. --- ## Binding invariant: factory bodies must be synchronous (#553) The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for SvelteKit/Vite virtual modules on the argument that their consumer imports were resolved at static-import time. **That reasoning is wrong.** What matters is what the **factory body** does, not where the mocked module is consumed. `EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed. **Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.** If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist: ```ts const { mockNavigating } = vi.hoisted(() => ({ mockNavigating: { type: null as string | null } })); vi.mock('$app/state', () => ({ get navigating() { return mockNavigating; } })); ``` The getter defers the read until consumption time; `vi.hoisted` guarantees the reference is initialised before the (also hoisted) `vi.mock` factory runs. See `DropZone.svelte.spec.ts:9`, `NotificationBell.svelte.spec.ts:6-10`, and `EnrichmentBlock.svelte.spec.ts` for canonical examples. ### Architectural follow-on: prefer `$app/state` over `$app/stores` `$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec. **Pattern note:** When an overlay or dropdown triggers a navigation action, use `