Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,12 @@ function App({ config: configOverrides }: { config?: Partial<WriteConfig> } = {}
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,
Expand Down
59 changes: 56 additions & 3 deletions app/src/embed.test.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<unknown>>(),
override: null as (() => Promise<DeviceIdentity>) | null,
}));

vi.mock("./lib/identity", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/identity")>();
return {
...actual,
loadIdentity: (...args: Parameters<typeof actual.loadIdentity>) => {
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();
});
Expand All @@ -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();
});

Expand All @@ -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<DeviceIdentity>((_, 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);
}
});
});
Loading