In Vue 3.3+, when can `app.runWithContext()` replace a host component for testing a composable, and when can it not?
answer
- an app context without a component
- only the injection lookup is lent
- synchronous callback only
- hooks still need an instance
basics
~20 sapp.runWithContext() lends an app's provides to inject() during a synchronous callback, so an inject-only composable can be tested on an app that is never mounted. It supplies no component instance, so composables with lifecycle hooks still need a host component.
solid answer
~40 s`app.runWithContext(fn)`, added in Vue 3.3, runs `fn` immediately with that app as the **injection context**: during the synchronous call, `inject()` finds values from `app.provide()` even though no component exists. For a composable whose only instance dependency is `inject`, that is lighter than a host: `createApp({})`, `app.provide(...)`, then `app.runWithContext(() => useApi())`, with no mounting at all. It does **not** create a component instance, so `onMounted` and other hooks still warn and are dropped; and it does not create an effect scope, so watchers created inside still need an `effectScope()` to be stopped. It also covers only the synchronous part of the call: an `inject` after an `await` runs outside the context.
code
ts · 15 linesimport { createApp, effectScope } from 'vue'
import { ApiKey, useApi } from './useApi'
test('useApi uses the injected client', async () => {
const client = { get: vi.fn(async () => ({ id: 1 })) }
const app = createApp({})
app.provide(ApiKey, client)
const scope = effectScope()
const api = scope.run(() => app.runWithContext(() => useApi()))!
await api.loadUser(1)
expect(client.get).toHaveBeenCalledWith('/users/1')
scope.stop()
})go deeper
Recall that app.runWithContext runs a function with an app's injections available, which lets inject work without a component.
Explain the boundary: it lends provides for a synchronous call only, creates no component instance and no effect scope, and needs no mount.
Choose the lightest harness per composable, combining runWithContext with effectScope for injected watchers, and spot async composables that inject after an await.
Argue for composable conventions, such as injecting synchronously at the top, that keep harnesses light and make runWithContext usable across the codebase.
## What `runWithContext` is `app.runWithContext(fn)` is a method on a Vue application instance, available since **Vue 3.3**. It calls `fn` immediately and returns its result. For the duration of that synchronous call, Vue treats the app as the **current app**, and `inject()` falls back to the app's own provides when there is no current component instance. Its intended use in production code is running logic that needs injections outside components, such as a router guard or a plugin hook. In tests it offers a lighter harness for a specific kind of composable. ## What it gives a test Suppose `useApi()` calls `inject(ApiKey)` and returns a few functions built on the injected client. A host component works, but the whole mount is only there to satisfy one `inject`. With `runWithContext`: ```ts import { createApp } from 'vue' const app = createApp({}) app.provide(ApiKey, fakeClient) const api = app.runWithContext(() => useApi()) ``` Notes on this pattern: - the app is **never mounted**; `createApp` plus `provide` is enough, because injection only needs the app's context; - `hasInjectionContext()` returns `true` inside the callback, so composables that check for a context before injecting behave as they would in a component; - no DOM element is involved, so the test is fast and has nothing to unmount. ## What it does not give | Needed by the composable | Supplied by `runWithContext`? | Consequence | |---|---|---| | `inject()` lookups | yes, from the app's provides | works during the synchronous call | | a current component instance | no | `onMounted`, `onUnmounted` warn and are dropped | | an effect scope | no | watchers created inside are not collected for stopping | | context after an `await` | no | an `inject` after the first `await` runs with no context | The second row is the important boundary. Lifecycle hooks attach to a component instance, and `runWithContext` sets only the current app. So a composable that injects **and** uses hooks still needs a real host component, for example a `withSetup` helper or Vue Test Utils' `mount`. ## Combining it with `effectScope` When an inject-only composable also creates watchers, stopping them is the test's job. Nest the two: ```ts const scope = effectScope() const api = scope.run(() => app.runWithContext(() => useApi())) // ... assertions ... scope.stop() ``` `scope.run` makes the scope active, so watchers created inside `useApi` are collected by it; `runWithContext` lends the injections. `scope.stop()` then disposes of every watcher and runs any `onScopeDispose` callbacks the composable registered. ## Choosing the harness 1. **Only reactivity APIs**: call it directly, inside an `effectScope` if it creates watchers. 2. **Reactivity plus `inject`**: `runWithContext` on an unmounted app, plus a scope if needed. 3. **Anything with lifecycle hooks**: a mounted host component. ## Why the context ends at the first `await` Internally, `runWithContext` saves the previous current app, sets its own, calls `fn`, and restores the previous value in a `finally` block. An `async` callback returns a promise the moment it reaches its first `await`, so the `finally` runs right then. Code after the `await` resumes later, when the current app is already restored. That is why a composable should call `inject` **synchronously at the top**, before any asynchronous work, and why a test that wraps an async composable in `runWithContext` still sees `undefined` for any late injection. ## Common mistakes - mounting the app first, which adds cost and a DOM dependency for nothing; - expecting `onMounted` or `onUnmounted` inside the callback to fire; - forgetting that watchers created inside are not stopped by `runWithContext` returning. The benefit of picking the lightest harness is not only speed. A test that does not mount anything makes it clear that the composable's contract is purely about its inputs and injections, which is useful information for the next person who changes it.
- Why does an `inject` placed after `await` inside a composable fail even within `runWithContext`?`runWithContext` sets the current app only for the synchronous part of the callback and restores the previous value in a `finally` when the callback returns. An async composable returns its promise at the first `await`, so code after it resumes later with no injection context. Composables should call `inject` synchronously, before any `await`.
- Does the app used for `runWithContext` need `app.mount()`?No. Injection lookups read the app context's provides, which exist as soon as `createApp` returns and `app.provide` is called. Mounting would only matter if something needed a component instance or the DOM, and in that case `runWithContext` is the wrong tool anyway.
saying these in an interview costs you the question
- runWithContext creates a temporary component, so onMounted works inside it.
- The app must be mounted before runWithContext can resolve injections.
- runWithContext keeps the injection context alive across awaits in the callback.
- Watchers created inside runWithContext are stopped when the callback returns.
- runWithContext has existed since Vue 3.0.