mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-11-03 12:01:23 +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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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