fix(runtime): reset dev status on app mount so a remount can't show a stale overlay (#195)
`devState` is a module-global shallowRef that the HMR handlers drive to
{type:"error"} / {type:"update"}; nothing reset it on the create path. createApp()
can run multiple times in one dev process (two apps, unmount + re-create, a UI
restart tool, a test run), so a fresh app would inject the previous app's leftover
state and render its old "Build Error" / "[HMR] updated" overlay instead of its own
content — until the next HMR event happened to reset it.
Add resetDevState() (hmr.ts) and call it from render()'s `__VUE_TUI_DEV__` block,
right after initHmrBridge(), so every newly-mounted dev app starts from a clean
status — consistent with the very first app, which sees the module's initial
{type:"ok"}.
Dev-only (the block is gated behind the cli vite-plugin's `__VUE_TUI_DEV__` define).
TDD: the unit test drives a stale error/update via the real vite:error /
vite:beforeUpdate handlers, then asserts resetDevState() clears it (the per-mount
hook render() now invokes).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// SEQUENTIAL: `devState` and the `initialized` guard are MODULE-GLOBALS in
|
||||
// hmr.ts. Each test re-imports the module via vi.resetModules() so the globals
|
||||
// start fresh; the module cache is process-global, so this must not run
|
||||
// concurrently. Mirrors unit/hmr-bridge-idempotent.sequential.test.ts.
|
||||
import { afterEach, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
// Internal module not in package exports — import via relative source path. The
|
||||
// dynamic import() path must be a string LITERAL so the bundler resolves it
|
||||
// relative to this file. (overlay.ts is NOT imported here: it pulls in .vue SFCs
|
||||
// the unit-test pipeline can't transform. resetDevState lives in hmr.ts, and
|
||||
// render()'s dev block calls it — see render.ts's `__VUE_TUI_DEV__` block.)
|
||||
|
||||
type FakeHot = {
|
||||
on: ReturnType<typeof vi.fn<(event: string, cb: (payload: unknown) => void) => void>>;
|
||||
send: ReturnType<typeof vi.fn<(event: string, data?: unknown) => void>>;
|
||||
handlers: Map<string, (payload: unknown) => void>;
|
||||
};
|
||||
|
||||
function makeFakeHot(): FakeHot {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
const on = vi.fn((event: string, cb: (payload: unknown) => void) => {
|
||||
handlers.set(event, cb);
|
||||
});
|
||||
const send = vi.fn<(event: string, data?: unknown) => void>();
|
||||
return { on, send, handlers };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("a fresh dev app does not inherit a previous app's Build Error", async () => {
|
||||
vi.resetModules();
|
||||
const { initHmrBridge, devState, resetDevState } = await import("../../runtime/src/hmr.ts");
|
||||
const hot = makeFakeHot();
|
||||
initHmrBridge(hot);
|
||||
|
||||
// App A hits a build error (real vite:error handler drives the module-global).
|
||||
hot.handlers.get("vite:error")!({ err: { message: "old build error" } });
|
||||
expect(devState.value).toEqual({ type: "error", error: { message: "old build error" } });
|
||||
|
||||
// App A unmounts; App B mounts. Nothing reset devState before this fix, so App
|
||||
// B's DevOverlay injected the stale {type:"error"} and rendered the old "Build
|
||||
// Error" frame instead of its own content. render()'s dev block now calls
|
||||
// resetDevState() when setting up each new app, so App B starts clean.
|
||||
resetDevState();
|
||||
expect(devState.value).toEqual({ type: "ok" });
|
||||
});
|
||||
|
||||
test("a fresh dev app does not inherit a previous app's transient HMR update status", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.resetModules();
|
||||
const { initHmrBridge, devState, resetDevState } = await import("../../runtime/src/hmr.ts");
|
||||
const hot = makeFakeHot();
|
||||
initHmrBridge(hot);
|
||||
|
||||
// App A had a pending "[HMR] updated: …" status line.
|
||||
hot.handlers.get("vite:beforeUpdate")!({ updates: [{ path: "/src/old.vue" }] });
|
||||
expect(devState.value).toEqual({ type: "update", paths: ["/src/old.vue"] });
|
||||
|
||||
// App B mounts → reset → clean status, regardless of the still-pending timer.
|
||||
resetDevState();
|
||||
expect(devState.value).toEqual({ type: "ok" });
|
||||
|
||||
// And the previous app's pending reset timer firing later must not clobber
|
||||
// App B's now-clean status (it only acts while type === "update").
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(devState.value).toEqual({ type: "ok" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
@@ -92,3 +92,17 @@ export function initHmrBridge(hot: HotContext | undefined = realHot): void {
|
||||
hot.send("vue-tui:request-reload");
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the shared dev status to "ok". `devState` is a module-global that a
|
||||
// PREVIOUS app in the same dev process may have left in an error/update state
|
||||
// (createApp() can run multiple times: two apps, unmount + re-create, a tool
|
||||
// that restarts the UI, a test run). Nothing else clears it on the create path,
|
||||
// so without this a freshly-mounted app injects the stale state and renders the
|
||||
// old "Build Error" / "[HMR] updated" overlay instead of its own content.
|
||||
// render()'s `__VUE_TUI_DEV__` block calls this once per app setup. We don't
|
||||
// touch `pendingResetTimer` here: its firing is guarded on `type === "update"`,
|
||||
// which this reset clears, and the vite:beforeUpdate handler clears any prior
|
||||
// timer before arming a new one — so a stale timer can't clobber a later app.
|
||||
export function resetDevState(): void {
|
||||
devState.value = { type: "ok" };
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type FocusContext,
|
||||
type StdinContext,
|
||||
} from "./context.ts";
|
||||
import { devState, DevStateKey, initHmrBridge } from "./hmr.ts";
|
||||
import { devState, DevStateKey, initHmrBridge, resetDevState } from "./hmr.ts";
|
||||
import { createDevOverlayWrapper } from "./overlay.ts";
|
||||
import { ErrorOverview, messageForNonError } from "./components/error-overview.ts";
|
||||
import { resolveSize } from "./composables/useWindowSize.ts";
|
||||
@@ -531,6 +531,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
);
|
||||
if (typeof __VUE_TUI_DEV__ !== "undefined" && __VUE_TUI_DEV__) {
|
||||
initHmrBridge();
|
||||
// Clear any dev status left in the module-global `devState` by a previous
|
||||
// app in this dev process, so this fresh app never renders a stale Build
|
||||
// Error / HMR-update overlay instead of its own content.
|
||||
resetDevState();
|
||||
root = createDevOverlayWrapper(root, rootProps ?? undefined);
|
||||
rootProps = undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user