Skip to content

fix(runtime): keep a client-dirty mirrored file dirty across a host republish - #21393

Merged
nwparker merged 1 commit into
mainfrom
fix/21392-mirrored-draft-survives-rebuild
Sep 18, 2026
Merged

nwparker merged 1 commit into
mainfrom
fix/21392-mirrored-draft-survives-rebuild

Conversation

@nwparker

@nwparker nwparker commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​238 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​238
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​14 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​11

ELI5

If you edit a file that Orca mirrors from a remote host and the host then republishes its tab list, the client used to forget that your tab had unsaved edits. Closing it afterwards skipped the "save or discard?" prompt and threw your edits away. Now a client draft keeps its tab marked dirty until you save or discard it yourself.

What Changed

  • buildMirroredEditorTabs (web-session-tabs-sync/tab-builders.ts): a rebuilt mirrored file stays dirty when the client's existing file is dirty and the client holds a draft for it (isDirty: tab.isDirty || (existingFile.isDirty && hasLocalDraft(fileId))). With no client draft the host's flag still wins, so a host-side save clears the tab as before.
  • WebSessionTabsSyncState gains optional editorDrafts; apply-preparation-browser.ts passes the draft predicate. Production callers already pass the full store state, so drafts are always available there.
  • Tests: three sync-layer cases (client-dirty survives a host isDirty: false republish; no-draft follows a host save; a lingering draft on a clean tab does not invent dirtiness) and one end-to-end case that applies a host republish to the real store and then runs the tab strip's close command: the close must be routed to the unsaved-changes prompt with the file and draft intact.

Why

The host publishes only its own store's isDirty and never learns about client edits (client→host editor RPCs are only markdown.readTab / markdown.saveTab). buildMirroredEditorTabs spread the existing local file and then overwrote isDirty from the snapshot; sameOpenFiles counts that as a real diff, apply-preparation-browser applies it unconditionally, and editorDrafts is never touched. So a client draft could outlive its own dirty flag, and every consumer of the flag then misbehaved: the tab strip's close gates (getNextQueuedEditorClose, closeEditorIfUnreferenced) closed with no prompt and closeFile deleted the draft, and the external-change reload guards (useEditorPanelExternalContentEvents, useEditorPanelContentReloadTriggers) would reload over it.

Two design questions, settled:

  1. Fix the flag at the rebuild, not the close gate. Gating close on editorDrafts would protect one consumer and leave the reload guards destroying the draft; it would also mis-prompt on "typed then undid", where a draft lingers on a clean tab. Restoring the flag's truth at the single point where it is clobbered protects all consumers at once. A draft is used as evidence of why the local flag is authoritative rather than as the gate itself.
  2. A host-side save never clears a client draft. The draft is unsaved client work; the host saving its own content does not discharge it. Under this change a host save flips tab.isDirty to false but the client tab stays dirty while its draft exists. Dirtiness is not made monotonic: a dirty flag with no draft (it came from an earlier host snapshot) keeps following the host, so a host save does not strand the tab.

Linked Issue

Fixes #21392 (found while reviewing #21375)

Visual Proof

N/A — state reconciliation; no UI change.

Testing

  • pnpm test src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts — 9 passed
  • pnpm tc:web — clean; oxlint, oxfmt --check, audit:anti-slop — clean; changed-code quality gate vs origin/main — passed
  • Mutation (fix reverted to isDirty: tab.isDirty): exactly the two positive tests fail — the sync-layer "keeps a client-dirty mirrored file dirty" case and the end-to-end close case (close request never dispatched; file and draft gone). The no-draft and reverted-draft cases still pass, as they should.

AI Disclosure

Review

Self-reviewed for correctness (both directions of the flag), data safety (no path here closes a tab or touches editorDrafts), SSH/remote behavior (host-owned flag still authoritative absent client evidence), and cross-platform impact (renderer only). No wire change.

Agent skill upstream boundary

  • Not applicable

Notes

The pre-existing debounce window between setEditorDraft and markFileDirty (noted in close-file-action.ts) is unchanged and not mirrored-specific.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • No visual proof is needed for this state fix
  • Self-reviewed for correctness, security, and performance
  • Cross-platform and SSH/remote impact considered
  • Full lint, build, and platform matrix are CI coverage

…epublish

The host publishes only its own store's isDirty and never learns about
client edits, so rebuilding a mirrored OpenFile from the snapshot cleared
the client's flag while editorDrafts still held the draft. The tab strip
then closed the tab with no unsaved-changes prompt and closeFile deleted
the draft; the external-change reload guards would reload over it too.

Keep the client's flag when the client's file is dirty and it holds a
draft; with no draft the host's flag still wins so a host-side save does
not strand the tab as dirty. A host-side save never clears a client draft.

Fixes #21392
@pullfrog

pullfrog Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

This run was cancelled 🛑

The workflow was cancelled before completion. Please check the link below for details.

Pullfrog  | View workflow run | via Pullfrog | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

📝 Walkthrough

Walkthrough

The change adds editorDrafts to mirrored session state and passes draft presence into buildMirroredEditorTabs. Mirrored files preserve local isDirty only when they are already dirty and have a local draft. Tests cover host republishing, clean-state updates, reverted drafts, and close requests that preserve the file and draft.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to 602c0

The new regression test may not run, leaving the draft-preserving close behavior unprotected. Hoist the mock initialization before merging.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving client-dirty state when a host republishes a mirrored file.
Description check ✅ Passed The description is complete and aligned with the repository template. It explains the user impact, implementation, rationale, linked issue, testing, visual-proof exception, review considerations, and …
Linked Issues check ✅ Passed The PR satisfies the coding requirements in #21392. buildMirroredEditorTabs preserves existingFile.isDirty only when the file has a local draft, and otherwise applies the host tab.isDirty value.…
Out of Scope Changes check ✅ Passed The changed production files and tests stay within #21392. The WebSessionTabsSyncState type change supports access to editorDrafts. The browser wiring, mirrored-tab logic, synchronization tests, a…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: f94c438e-c1bc-4880-9709-8bb2e9a53ed9

📥 Commits

Reviewing files that changed from the base of the PR and between 1fa6fac and 602c083.

📒 Files selected for processing (5)
  • src/renderer/src/runtime/web-session-tabs-sync-editor-tabs.test.ts
  • src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts
  • src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts
  • src/renderer/src/runtime/web-session-tabs-sync/state.ts
  • src/renderer/src/runtime/web-session-tabs-sync/tab-builders.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Comment on lines +7 to +10
const closeWebRuntimeSessionTabMock = vi.fn(async (_args: unknown) => 'applied' as const)

vi.mock('./web-runtime-session', () => ({
closeWebRuntimeSessionTab: (args: unknown) => closeWebRuntimeSessionTabMock(args)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,155p' src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts
rg -n '"test:.*renderer|vitest|web-session-tabs-sync-mirrored-draft-close"' package.json src/renderer/package.json

Repository: stablyai/orca

Length of output: 5237


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- package and vitest config ---'
sed -n '1,70p' package.json
fd -i 'vitest*.config.*' config . 2>/dev/null | head -20
printf '%s\n' '--- candidate local Vitest installation ---'
if [ -d node_modules/vitest ]; then
  printf 'node_modules/vitest present\n'
  find node_modules/vitest -maxdepth 2 -type f | head -30
else
  printf 'node_modules/vitest absent\n'
fi
printf '%s\n' '--- focused test imports and bound module references ---'
rg -n --context 4 "from ['\\\"]\\./web-runtime-session['\\\"]|web-runtime-session|closeWebRuntimeSessionTab|from ['\\\"]\\.\\./store['\\\"]|from ['\\\"]\\./web-session-tabs-sync['\\\"]" src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts src/renderer/src/runtime/web-session-tabs-sync.ts src/renderer/src/runtime/web-runtime-session.ts 2>/dev/null
printf '%s\n' '--- test file metadata ---'
wc -l src/renderer/src/runtime/web-session-tabs-sync-mirrored-draft-close.test.ts

Repository: stablyai/orca

Length of output: 13282


🌐 Web query:

site:vitest.dev/api/vi vi.mock hoisted factory vi.hoisted top-level variables evaluation order

💡 Result:

<search_synthesis>
In Vitest, the execution and evaluation order of vi.mock and vi.hoisted is designed to handle the fact that standard ES module import statements are hoisted to the top of the file and evaluated before any other code [1][2]. 1. vi.mock Hoisting and Evaluation vi.mock calls are automatically moved (hoisted) by Vitest to the top of the file [1][3]. This ensures that vi.mock is executed before all module imports [1][4]. Consequently, any factory function passed to vi.mock is also executed during this early phase, before the actual modules are imported or the rest of your file code runs [1][5]. 2. vi.hoisted Purpose and Evaluation Because vi.mock is hoisted, it cannot reference variables defined in the standard scope of your file, as those variables have not been initialized yet [1][2]. vi.hoisted is specifically designed to solve this limitation [1][3]. Code passed to vi.hoisted is also executed before module imports, allowing you to define variables that need to be accessible within a vi.mock factory [1][4]. 3. Correct Usage Pattern To use local variables within a vi.mock factory, you must define them inside vi.hoisted before the vi.mock call [1][5]. Vitest executes these in the order they appear in the file (while still remaining hoisted above imports): // Variables here are accessible in the mock factory const { myMockValue } = vi.hoisted( => ({ myMockValue: &#39;some-value&#39; })); vi.mock(&#39;./my-module&#39;, => ({ getValue: => myMockValue, })); By following this pattern, the vi.hoisted factory runs, providing the necessary values, followed by the vi.mock factory, which can then successfully reference those values before the import statements are evaluated [1][3][2].
</search_synthesis>

<source_evidence>

<title>Vi | Vitest</title> https://vitest.dev/api/vi Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn&`#39`;t matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside `vi.hoisted` and reference them inside `vi.mock`. ... It is recommended to use `vi.mock` or `vi.hoisted` only inside test files. If Vite&`#39`;s module runner is disabled, they will not be hoisted. This is a performance optimisation to avoid ready unnecessary files. ... In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates that `vi` that was not directly imported from the `vitest` package (for example, from some utility file) cannot be used. Use `vi.mock` with `vi` imported from `vitest`, or enable `globals` config option. ... If the `factory` function is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until `vi.unmock` or `vi.doUnmock` is called. ... `vi.mock` is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside `beforeEach` or `test`), it will actually be called before that. ... This also means that you cannot use any variables inside the factory that are defined outside the factory. ... If you need to use variables inside the factory, try `vi.doMock`. It works the same way but isn&`#39`;t hoisted. Beware that it only mocks subsequent imports. ... You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`: ... const mocks = vi.hoisted(() => { return { namedExport: vi.fn(), } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { namedExport: mocks.namedExport, } }) vi.mocked(namedExport).mockReturnValue(100) expect(namedExport()).toBe(100) expect(namedExport).toBe(mocks.namedExport) ... The same as `vi.mock`, but it&`#39`;s not hoisted to the top of the file, so you can reference variables in the global file scope. The next dynamic import of the module will be mocked. ... return the original ... This call is hoisted to ... top of the file ... defined in `setupFiles`, for example. ... ### vi.hoisted ​ ... ``` function hoisted<T>(factory: () => T): T ... All static `import` statements in ES modules are hoisted to the top of the file, so any code that is defined before the imports will actually be executed after imports are evaluated. ... When running `vitest`, you can do this automatically by using `vi.hoisted` method. Under the hood, Vitest will convert static imports into dynamic ones with preserved live-bindings. ... Running code before the imports means that you cannot access imported variables because they are not defined yet: ... If you need to access a variable from another module inside of `vi.hoisted`, use dynamic import: ... However, it is discourage to import anything inside of `vi.hoisted` because imports are already hoisted - if you need to execute something before the tests are running, just execute it in the imported module itself. ... This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need easy access to locally defined variables: ... ``` import { expect, vi } from &`#39`;vitest&`#39`; ... import { originalMethod } from &`#39`;./path/to/module.js&`#39`; ... const { mockedMethod } = vi.hoisted(() => { return { mockedMethod: vi.fn() } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { originalMethod: mockedMethod } }) mockedMethod.mockReturnValue(100) expect(originalMethod()).toBe(100) ... -level await <title>Vi | Vitest v0.34</title> https://v0.vitest.dev/api/vi ## vi.hoisted ​ ... - Type: ` (factory: () => T) => T` ... Version: Since Vitest 0.31.0 ... All static `import` statements in ES modules are hoisted to top of the file, so any code that is define before the imports will actually be executed after imports are evaluated. ... vitest`, you can do ... using `vi.hoisted` method ... This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need an easy access to locally defined variables: ... ## vi.mock ​ ... - Type: `(path: string, factory?: () => unknown) => void` ... Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn&`#39`;t matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside `vi.hoisted` and reference them inside `vi.mock`. ... Vitest statically analyzes your files to hoist `vi.mock`. It means that you cannot use `vi` that was not imported directly from `vitest` package (for example, from some utility file). To fix this, always use `vi.mock` with `vi` imported from `vitest`, or enable `globals` config option. ... If `factory` is defined, all imports will return its result. Vitest calls factory only once and caches result for all subsequent imports until `vi.unmock` or `vi.doUnmock` is called. ... `vi.mock` is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside `beforeEach` or `test`), it will actually be called before that. ... This also means that you cannot use any variables inside the factory that are defined outside the factory. ... If you need to use variables inside the factory, try `vi.doMock`. It works the same way but isn&`#39`;t hoisted. Beware that it only mocks subsequent imports. ... You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`: ... The same as `vi.mock`, but it&`#39`;s not hoisted at the top of the file, so you can reference variables in the global file scope. The next dynamic import of the module will be mocked. This will not mock modules that were imported before this was called. <title>Vi | Vitest</title> https://main.vitest.dev/api/vi Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn&`#39`;t matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside `vi.hoisted` and reference them inside `vi.mock`. ... It is recommended to use `vi.mock` or `vi.hoisted` only inside test files. If Vite&`#39`;s module runner is disabled, they will not be hoisted. This is a performance optimisation to avoid ready unnecessary files. ... In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates that `vi` that was not directly imported from the `vitest` package (for example, from some utility file) cannot be used. Use `vi.mock` with `vi` imported from `vitest`, or enable `globals` config option. ... If the `factory` function is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until `vi.unmock` or `vi.doUnmock` is called. ... `vi.mock` is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside `beforeEach` or `test`), it will actually be called before that. ... This also means that you cannot use any variables inside the factory that are defined outside the factory. ... If you need to use variables inside the factory, try `vi.doMock`. It works the same way but isn&`#39`;t hoisted. Beware that it only mocks subsequent imports. ... You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`: ... ``` import { namedExport } from ... const mocks = vi.hoisted(() => { return { namedExport: vi.fn(), } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { namedExport: mocks.namedExport, } }) vi.mocked(namedExport).mockReturnValue(100) expect(namedExport()).toBe(100) expect(namedExport).toBe(mocks.namedExport) ... The same as `vi.mock`, but it&`#39`;s not hoisted to the top of the file, so you can reference variables in the global file scope. The next dynamic import of the module will be mocked. ... this was called ... &`#39`;t forget ... all static imports in ... are always hoisted, so putting this before static import will ... force it to be called before the import ... Removes module from the mocked registry. All calls to import will return the original module even if it was mocked before. This call is hoisted to the top of the file, so it will only unmock modules that were defined in `setupFiles`, for example. ... the original module instead of the mock. ... ### vi.hoisted ​ ... function hoisted<T>(factory: () => T): T ... All static `import` statements in ES modules are hoisted to the top of the file, so any code that is defined before the imports will actually be executed after imports are evaluated. ... When running `vitest`, you can do this automatically by using `vi.hoisted` method. Under the hood, Vitest will convert static imports into dynamic ones with preserved live-bindings. ... Running code before the imports means that you cannot access imported variables because they are not defined yet: ... If you need to access a variable from another module inside of `vi.hoisted`, use dynamic import: ... However, it is discourage to import anything inside of `vi.hoisted` because imports are already hoisted - if you need to execute something before the tests are running, just execute it in the imported module itself. ... This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need easy access to locally defined variables: ... ``` import { expect, vi } from &`#39`;vitest&`#39`; import { originalMethod } from &`#39`;./path/to/module.js&`#39`; ... const { mockedMethod } = vi.hoisted(() => { return { mockedMethod: vi.fn() } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { originalMethod: mo…[truncated] <title>Vi | Vitest</title> https://v3.vitest.dev/api/vi Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn&`#39`;t matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside `vi.hoisted` and reference them inside `vi.mock`. ... In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates that `vi` that was not directly imported from the `vitest` package (for example, from some utility file) cannot be used. Use `vi.mock` with `vi` imported from `vitest`, or enable `globals` config option. ... If the `factory` function is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until `vi.unmock` or `vi.doUnmock` is called. ... `vi.mock` is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside `beforeEach` or `test`), it will actually be called before that. ... This also means that you cannot use any variables inside the factory that are defined outside the factory. ... If you need to use variables inside the factory, try `vi.doMock`. It works the same way but isn&`#39`;t hoisted. Beware that it only mocks subsequent imports. ... You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`: ... const mocks = vi.hoisted(() => { return { namedExport: vi.fn(), } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { namedExport: mocks.namedExport, } }) ... vi.mocked(namedExport).mockReturnValue(100) expect(namedExport()).toBe(100) expect(namedExport).toBe(mocks.namedExport) ... The same as `vi.mock`, but it&`#39`;s not hoisted to the top of the file, so you can reference variables in the global file scope. The next dynamic import of the module will be mocked. ... to the top ... for example. ... ### vi.hoisted ​ ... - Type: ` (factory: () => T) => T` ... All static `import` statements in ES modules are hoisted to the top of the file, so any code that is defined before the imports will actually be executed after imports are evaluated. ... When running `vitest`, you can do this automatically by using `vi.hoisted` method. Under the hood, Vitest will convert static imports into dynamic ones with preserved live-bindings. ... Running code before the imports means that you cannot access imported variables because they are not defined yet: ... If you need to access a variable from another module inside of `vi.hoisted`, use dynamic import: ... However, it is discourage to import anything inside of `vi.hoisted` because imports are already hoisted - if you need to execute something before the tests are running, just execute it in the imported module itself. ... This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need easy access to locally defined variables: ... ``` import { expect, vi } from &`#39`;vitest&`#39`; import { originalMethod } from &`#39`;./path/to/module.js&`#39`; ... const { mockedMethod } = vi.hoisted(() => { return { mockedMethod: vi.fn() } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { originalMethod: mockedMethod } }) mockedMethod.mockReturnValue(100) expect(originalMethod()).toBe(100) ... top-level await <title>Vi | Vitest</title> https://v2.vitest.dev/api/vi Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn&`#39`;t matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can define them inside `vi.hoisted` and reference them inside `vi.mock`. ... In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates that `vi` that was not directly imported from the `vitest` package (for example, from some utility file) cannot be used. Use `vi.mock` with `vi` imported from `vitest`, or enable `globals` config option. ... If the `factory` function is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until `vi.unmock` or `vi.doUnmock` is called. ... `vi.mock` is hoisted (in other words, moved) to top of the file. It means that whenever you write it (be it inside `beforeEach` or `test`), it will actually be called before that. ... This also means that you cannot use any variables inside the factory that are defined outside the factory. ... If you need to use variables inside the factory, try `vi.doMock`. It works the same way but isn&`#39`;t hoisted. Beware that it only mocks subsequent imports. ... You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`: ... const mocks = vi.hoisted(() => { return { namedExport: vi.fn(), } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { namedExport: mocks.namedExport, } }) vi.mocked(namedExport).mockReturnValue(100) expect(namedExport()).toBe(100) expect(namedExport).toBe(mocks.namedExport) ... The same as `vi.mock`, but it&`#39`;s not hoisted to the top of the file, so you can reference variables in the global file scope. The next dynamic import of the module will be mocked. ... This will not mock modules that were imported before this was called. Don&`#39`;t forget that all static imports ... are always hoisted, so putting this before static import will not force it to be called before the import: ... Removes module from the mocked registry. All calls to import will return the original module even if it was mocked before. This call is hoisted to the top of the file, so it will only unmock modules that were defined in `setupFiles`, for example. ... ### vi.hoisted ​ ... - Type: ` (factory: () => T) => T` ... All static `import` statements in ES modules are hoisted to the top of the file, so any code that is defined before the imports will actually be executed after imports are evaluated. ... When running `vitest`, you can do this automatically by using `vi.hoisted` method. ... This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need easy access to locally defined variables: ... ``` import { expect, vi } from &`#39`;vitest&`#39`; import { originalMethod } from &`#39`;./path/to/module.js&`#39`; ... const { mockedMethod } = vi.hoisted(() => { return { mockedMethod: vi.fn() } }) ... vi.mock(&`#39`;./path/to/module.js&`#39`;, () => { return { originalMethod: mockedMethod } }) mockedMethod.mockReturnValue(100) expect(originalMethod()).toBe(100)

Citations:


Create the mock with vi.hoisted.

Vitest hoists vi.mock and evaluates its factory before normal top-level initialization. The factory can therefore access closeWebRuntimeSessionTabMock while it is still in the temporal dead zone and fail during module loading.

Proposed fix
-const closeWebRuntimeSessionTabMock = vi.fn(async (_args: unknown) => 'applied' as const)
+const { closeWebRuntimeSessionTabMock } = vi.hoisted(() => ({
+  closeWebRuntimeSessionTabMock: vi.fn(async (_args: unknown) => 'applied' as const)
+}))

 vi.mock('./web-runtime-session', () => ({
   closeWebRuntimeSessionTab: (args: unknown) => closeWebRuntimeSessionTabMock(args)
 }))

@nwparker
nwparker merged commit 06a8ca5 into main Sep 18, 2026
34 of 35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mirrored editor tab rebuild overwrites local isDirty, so a client draft can be discarded without a prompt

1 participant