diff --git a/app/src/App.tsx b/app/src/App.tsx index cc94ce5..abc8824 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -347,7 +347,12 @@ function App({ config: configOverrides }: { config?: Partial } = {} let active = true; let opened: LocalProject | null = null; void (async () => { - const loadedIdentity = await loadIdentity(config); + // Identity loading can't be cancelled, so ignore its result (or failure) after unmount. + const loadedIdentity = await loadIdentity(config).catch((error) => { + if (active) throw error; + return null; + }); + if (!active || !loadedIdentity) return; const loadedProject = await LocalProject.open( loadedIdentity, config, diff --git a/app/src/embed.test.ts b/app/src/embed.test.ts index 0ca3bdc..a5e7a09 100644 --- a/app/src/embed.test.ts +++ b/app/src/embed.test.ts @@ -1,9 +1,34 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { defineCustomElement, mount } from "./embed"; +import type { DeviceIdentity } from "./lib/identity"; +import { LocalProject } from "./lib/project"; + +// Track every identity load App starts so each test can wait for them to settle; +// otherwise they outlive the test and touch jsdom globals after teardown. +const identityLoads = vi.hoisted(() => ({ + pending: new Set>(), + override: null as (() => Promise) | null, +})); + +vi.mock("./lib/identity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadIdentity: (...args: Parameters) => { + const load = identityLoads.override?.() ?? actual.loadIdentity(...args); + identityLoads.pending.add(load); + return load; + }, + }; +}); const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); -afterEach(() => { +afterEach(async () => { + await Promise.allSettled(identityLoads.pending); + identityLoads.pending.clear(); + identityLoads.override = null; + vi.restoreAllMocks(); document.body.replaceChildren(); document.getElementById("subscript-write-styles")?.remove(); }); @@ -21,11 +46,12 @@ describe("embeddable library entry", () => { const otherHost = document.createElement("div"); document.body.append(otherHost); - mount(otherHost); + const otherHandle = mount(otherHost); await flush(); expect(document.querySelectorAll("#subscript-write-styles")).toHaveLength(1); handle.unmount(); + otherHandle.unmount(); expect(host.querySelector(".subscript-write")).toBeNull(); }); @@ -40,4 +66,31 @@ describe("embeddable library entry", () => { element.remove(); expect(element.querySelector(".subscript-write")).toBeNull(); }); + + it("ignores identity loading that settles after unmount", async () => { + let failLoad: (error: Error) => void = () => undefined; + identityLoads.override = () => + new Promise((_, reject) => { + failLoad = reject; + }); + const open = vi.spyOn(LocalProject, "open"); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + try { + const host = document.createElement("div"); + document.body.append(host); + const handle = mount(host); + await flush(); + handle.unmount(); + + failLoad(new ReferenceError("localStorage is not defined")); + await flush(); + + expect(open).not.toHaveBeenCalled(); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off("unhandledRejection", unhandled); + } + }); });