mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-04 04:21:21 +00:00
widget: make an unmounted-widget teardown loud, not a silent hang
Removing <bitlogin-auth> from the DOM terminates its crypto worker. A host that re-renders surrounding markup (framework diff, htmx swap, innerHTML reset) can do this incidentally, after which signing calls fail -- and the failure was hard to diagnose. Two guardrails: - disconnectedCallback logs a console.error when the element is removed while a session (BitLogin account or NIP-07/46) is active, naming the unmount as the cause and pointing at the README "Integration gotcha". - WorkerClient.terminate(reason) records the reason; both in-flight calls (via failAll) and any later call (via the dead-flag path) reject with it, so an unmounted widget produces one legible error message instead of a bare or mismatched one. The dead flag already prevented the infinite hang; this makes the cause visible and unifies the wording. Adds worker/workerClient.test.ts (fake Worker) covering both rejection paths. Updates the README gotcha paragraphs from "hangs forever with no error" to the new loud-rejection behavior. Addresses the "unmount guardrail" item of the Tessera integration feedback (docs/TODO.md). Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
This commit is contained in:
1 parent
c9791b9840
commit
86d7ee0114
5 files changed
+111
-15
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { WorkerClient } from "./workerClient.js";
|
||||
|
||||
/** Minimal stand-in for a real Worker: records posted messages but never
|
||||
* answers, so a call stays pending until the client tears it down -- exactly
|
||||
* the state an unmounted <bitlogin-auth> leaves behind. */
|
||||
class FakeWorker {
|
||||
static instances: FakeWorker[] = [];
|
||||
listeners: Record<string, ((event: unknown) => void)[]> = {};
|
||||
posted: unknown[] = [];
|
||||
terminated = false;
|
||||
constructor(_url: URL, _opts?: unknown) {
|
||||
FakeWorker.instances.push(this);
|
||||
}
|
||||
addEventListener(type: string, cb: (event: unknown) => void): void {
|
||||
(this.listeners[type] ??= []).push(cb);
|
||||
}
|
||||
postMessage(msg: unknown): void {
|
||||
this.posted.push(msg);
|
||||
}
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe("WorkerClient teardown", () => {
|
||||
beforeEach(() => {
|
||||
FakeWorker.instances = [];
|
||||
(globalThis as unknown as { Worker: unknown }).Worker = FakeWorker;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { Worker?: unknown }).Worker;
|
||||
});
|
||||
|
||||
it("rejects an in-flight call with the teardown reason instead of hanging", async () => {
|
||||
const client = new WorkerClient();
|
||||
const pending = client.getPublicKey();
|
||||
client.terminate("widget was unmounted");
|
||||
await expect(pending).rejects.toThrow("widget was unmounted");
|
||||
expect(FakeWorker.instances[0]?.terminated).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects calls made after teardown immediately, with the same reason", async () => {
|
||||
const client = new WorkerClient();
|
||||
client.terminate("widget was unmounted");
|
||||
await expect(client.getPublicKey()).rejects.toThrow("widget was unmounted");
|
||||
});
|
||||
|
||||
it("falls back to a clear default reason when terminate() is given none", async () => {
|
||||
const client = new WorkerClient();
|
||||
const pending = client.signEvent({ kind: 1, content: "hi", tags: [] } as never);
|
||||
client.terminate();
|
||||
await expect(pending).rejects.toThrow(/torn down|reload the page/i);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,10 @@ export class WorkerClient {
|
||||
>();
|
||||
private counter = 0;
|
||||
private dead = false;
|
||||
/** Why the worker is gone, surfaced on every post-teardown rejection so a
|
||||
* developer sees the CAUSE (usually an unmounted element), not just a bare
|
||||
* failure. Overridable via terminate(reason). */
|
||||
private deadReason = "This BitLogin session was torn down; reload the page to sign in again.";
|
||||
/** Set by the element to receive unsolicited worker frames (auth_url). */
|
||||
onNotification: ((notification: WorkerNotification) => void) | null = null;
|
||||
|
||||
@@ -120,7 +124,7 @@ export class WorkerClient {
|
||||
timeoutMs = CALL_TIMEOUT_MS
|
||||
): Promise<WorkerActionMap[A][1]> {
|
||||
if (this.dead) {
|
||||
return Promise.reject(new Error("This BitLogin session was torn down; reload the page to sign in again."));
|
||||
return Promise.reject(new Error(this.deadReason));
|
||||
}
|
||||
const id = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -248,11 +252,14 @@ export class WorkerClient {
|
||||
return this.call("logout", {});
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
terminate(reason?: string): void {
|
||||
this.dead = true;
|
||||
if (reason) this.deadReason = reason;
|
||||
this.worker.terminate();
|
||||
// terminate() silently kills in-flight work; without this every caller
|
||||
// waits forever on a worker that no longer exists.
|
||||
this.failAll("The BitLogin crypto worker was terminated.");
|
||||
// waits forever on a worker that no longer exists. Reject with the same
|
||||
// reason later calls get, so an in-flight signEvent and a subsequent one
|
||||
// fail identically and legibly.
|
||||
this.failAll(this.deadReason);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user