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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-08-11 19:25:59 -04:00
1 parent c9791b9840
commit 86d7ee0114
5 files changed
+111 -15

No files matched your search

+22 -1
View File
@@ -238,9 +238,30 @@ export class BitLoginAuthElement extends HTMLElement {
disconnectedCallback(): void {
if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer);
// Captured before clearActiveSession() below wipes the signal.
const hadActiveSession = Boolean(this.session || this.altSigner);
clearActiveSession(this);
this.releaseSigner();
this.worker.terminate();
// Removing <bitlogin-auth> from the DOM tears down its crypto worker. That's
// correct on a genuine teardown, but a host that re-renders its surrounding
// markup (a framework diff, an htmx swap, an innerHTML assignment) can remove
// and re-insert the element incidentally -- and then every later
// signEvent/getPublicKey call rejects, which is baffling without the cause.
// Say so loudly rather than letting the app hit silent rejections. The fix is
// to mount the element into a container the host's re-renders never replace
// (README "Integration gotcha").
if (hadActiveSession) {
console.error(
"[BitLogin] <bitlogin-auth> was removed from the DOM while a session was active. " +
"Its crypto worker is now torn down, so later signEvent/getPublicKey calls will reject. " +
"If this was an incidental re-render, mount the element in a stable container your re-renders " +
"don't replace; see the BitLogin README, \"Integration gotcha\"."
);
}
this.worker.terminate(
"BitLogin's <bitlogin-auth> element was removed from the DOM, tearing down its signer. " +
"Re-mount the widget or reload the page to sign in again."
);
}
// ---- Public API mirroring window.nostr, scoped to this element instance ----
@@ -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);
});
});
+11 -4
View File
@@ -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);
}
}