diff --git a/CONTEXT.md b/CONTEXT.md
index 2ac7efed..ce956b6f 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -5,9 +5,9 @@ project vocabulary, not implementation structure.
## Embedding
-**Widget Instance**: A mounted StakeKit Widget within a browser document. A
-document may contain one Widget Instance at a time; sequential instances are
-supported.
+**Widget Instance**: A mounted StakeKit Widget within a browser document. The
+Widget is designed for one instance at a time, though concurrent mounts are not
+blocked at runtime; sequential instances are fully supported.
**Application Runtime Generation**: The continuous application-state lifetime
from a Widget Instance's mount through its unmount.
diff --git a/docs/adr/0001-one-widget-instance-per-browser-document.md b/docs/adr/0001-one-widget-instance-per-browser-document.md
index 462762ed..cbf03c75 100644
--- a/docs/adr/0001-one-widget-instance-per-browser-document.md
+++ b/docs/adr/0001-one-widget-instance-per-browser-document.md
@@ -1,6 +1,9 @@
# One Widget Instance per browser document
-The Widget supports at most one concurrently mounted Widget Instance in a
-browser document; unmounting and later mounting another instance is supported.
-Document-global wallet discovery, translation, and host integrations make safe
-concurrent isolation disproportionately complex.
+The Widget is designed and tested for a single mounted instance per browser
+document. Multiple concurrent instances are not officially supported because
+document-global wallet discovery, translation, styling, and host integrations
+can conflict.
+
+Runtime claims that previously blocked concurrent mounting have been removed,
+allowing embedding hosts to mount multiple instances at their own discretion.
diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md
index aa38c3bc..c01ca97f 100644
--- a/packages/widget/ARCHITECTURE.md
+++ b/packages/widget/ARCHITECTURE.md
@@ -153,7 +153,8 @@ composition preserves registry-scoped Layer memoization; do not reconstruct
Layers from built Effect contexts or use `Layer.fresh` without an explicit need
for separate service instances.
-At most one Widget Instance may be mounted per browser document. Unmounting and
+The Widget is designed and tested for one mounted Widget Instance per browser
+document, though runtime claims no longer block concurrent mounts. Unmounting and
later mounting a new instance creates a fresh generation. See
[ADR 0001](../../docs/adr/0001-one-widget-instance-per-browser-document.md).
diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx
index 746c32cf..2777e481 100644
--- a/packages/widget/src/App.tsx
+++ b/packages/widget/src/App.tsx
@@ -8,8 +8,6 @@ import { RouterProvider } from "react-router/dom";
import { ApplicationRouteContentProvider } from "./app/composition/application-route-content";
import { Providers } from "./app/composition/providers";
import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime";
-import { acquireWidgetInstanceClaim } from "./app/embedding/widget-instance-claim";
-import { WidgetInstanceReactBoundary } from "./app/embedding/widget-instance-react-boundary";
import { applicationRoutes } from "./app/routes/application-routes";
import { useApplicationRouteEffects } from "./app/routes/react/use-application-route-effects";
import { ClassicRoutes } from "./app/routes/ui/classic-routes";
@@ -93,55 +91,41 @@ const SKAppProductionContent = ({
};
export const SKApp = ({ children, ...hostConfiguration }: SKAppProps) => (
-
-
- {children}
-
-
+
+ {children}
+
);
-const BundledSKWidget = (props: BundledSKWidgetProps) => (
-
-);
+export interface RenderedSKWidget {
+ rerender: (newProps: BundledSKWidgetProps) => void;
+ unmount: () => void;
+}
export const renderSKWidget = ({
container,
...rest
}: BundledSKWidgetProps & {
- container: Parameters[0];
-}) => {
- const releaseClaim = acquireWidgetInstanceClaim(
- container.ownerDocument ?? document
- );
- let root: ReturnType;
-
- try {
- root = ReactDOM.createRoot(container);
- let currentProps = rest;
- let unmounted = false;
- const render = () => root.render();
-
- render();
-
- return {
- rerender: (newProps: BundledSKWidgetProps) => {
- if (unmounted) return;
- currentProps = newProps;
- render();
- },
- unmount: () => {
- if (unmounted) return;
-
- unmounted = true;
- try {
- root.unmount();
- } finally {
- releaseClaim();
- }
- },
- };
- } catch (error) {
- releaseClaim();
- throw error;
- }
+ readonly container: Parameters[0];
+}): RenderedSKWidget => {
+ const root = ReactDOM.createRoot(container);
+ let currentProps = rest;
+ let unmounted = false;
+ const render = () =>
+ root.render();
+
+ render();
+
+ return {
+ rerender: (newProps: BundledSKWidgetProps) => {
+ if (unmounted) return;
+ currentProps = newProps;
+ render();
+ },
+ unmount: () => {
+ if (unmounted) return;
+
+ unmounted = true;
+ root.unmount();
+ },
+ };
};
diff --git a/packages/widget/src/app/embedding/widget-instance-claim.ts b/packages/widget/src/app/embedding/widget-instance-claim.ts
deleted file mode 100644
index 22dc2dd7..00000000
--- a/packages/widget/src/app/embedding/widget-instance-claim.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-const widgetInstanceClaimKey = Symbol.for(
- "@stakekit/widget/widget-instance-claim"
-);
-
-const alreadyMountedMessage =
- "Only one StakeKit Widget may be mounted in a browser document at a time.";
-
-class StakeKitWidgetInstanceAlreadyMountedError extends Error {
- override readonly name = "StakeKitWidgetInstanceAlreadyMountedError";
-
- constructor() {
- super(alreadyMountedMessage);
- }
-}
-
-export const acquireWidgetInstanceClaim = (
- browserDocument: Document
-): (() => void) => {
- if (Reflect.has(browserDocument, widgetInstanceClaimKey)) {
- throw new StakeKitWidgetInstanceAlreadyMountedError();
- }
-
- const claimOwnerToken = {};
- Reflect.set(browserDocument, widgetInstanceClaimKey, claimOwnerToken);
-
- return () => {
- if (
- Reflect.get(browserDocument, widgetInstanceClaimKey) === claimOwnerToken
- ) {
- Reflect.deleteProperty(browserDocument, widgetInstanceClaimKey);
- }
- };
-};
diff --git a/packages/widget/src/app/embedding/widget-instance-react-boundary.tsx b/packages/widget/src/app/embedding/widget-instance-react-boundary.tsx
deleted file mode 100644
index 723ff1dc..00000000
--- a/packages/widget/src/app/embedding/widget-instance-react-boundary.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import {
- Fragment,
- type PropsWithChildren,
- useCallback,
- useRef,
- useState,
-} from "react";
-import { acquireWidgetInstanceClaim } from "./widget-instance-claim";
-
-/**
- * The non-rendering template discovers the actual mounting document before
- * application providers render.
- */
-export const WidgetInstanceReactBoundary = ({
- children,
-}: PropsWithChildren) => {
- const [claimAcquired, setClaimAcquired] = useState(false);
- const releaseClaimRef = useRef<(() => void) | null>(null);
-
- const claimBoundaryRef = useCallback(
- (element: HTMLTemplateElement | null) => {
- if (element) {
- releaseClaimRef.current = acquireWidgetInstanceClaim(
- element.ownerDocument
- );
- setClaimAcquired(true);
- return;
- }
-
- releaseClaimRef.current?.();
- releaseClaimRef.current = null;
- },
- []
- );
-
- return (
- <>
- {claimAcquired ? (
- {children}
- ) : null}
-
- >
- );
-};
diff --git a/packages/widget/tests/app/bundled-renderer.dom.test.tsx b/packages/widget/tests/app/bundled-renderer.dom.test.tsx
index a126b45a..558f4e6d 100644
--- a/packages/widget/tests/app/bundled-renderer.dom.test.tsx
+++ b/packages/widget/tests/app/bundled-renderer.dom.test.tsx
@@ -74,34 +74,24 @@ describe("bundled widget renderer", () => {
widget.unmount();
});
- it("rejects a second Widget Instance without disturbing the active root", () => {
- const activeWidget = renderSKWidget({
+ it("supports concurrent Widget Instances in the same document", () => {
+ const first = renderSKWidget({
apiKey: "api-key",
container: document.createElement("div"),
});
-
- let mountError: unknown;
- try {
- renderSKWidget({
- apiKey: "api-key",
- container: document.createElement("div"),
- });
- } catch (error) {
- mountError = error;
- }
-
- expect(mountError).toMatchObject({
- name: "StakeKitWidgetInstanceAlreadyMountedError",
- message:
- "Only one StakeKit Widget may be mounted in a browser document at a time.",
+ const second = renderSKWidget({
+ apiKey: "api-key",
+ container: document.createElement("div"),
});
- expect(createRoot).toHaveBeenCalledOnce();
+
+ expect(createRoot).toHaveBeenCalledTimes(2);
expect(reactRoot.unmount).not.toHaveBeenCalled();
- activeWidget.unmount();
+ first.unmount();
+ second.unmount();
});
- it("releases the claim for a clean sequential bundled remount", () => {
+ it("supports a clean sequential bundled remount", () => {
const first = renderSKWidget({
apiKey: "api-key",
container: document.createElement("div"),
@@ -119,7 +109,7 @@ describe("bundled widget renderer", () => {
second.unmount();
});
- it("shares the document claim across separately evaluated copies", async () => {
+ it("allows separately evaluated copies to mount concurrently", async () => {
const activeWidget = renderSKWidget({
apiKey: "api-key",
container: document.createElement("div"),
@@ -128,16 +118,14 @@ describe("bundled widget renderer", () => {
vi.resetModules();
const secondCopy = await import("../../src/App");
- expect(() =>
- secondCopy.renderSKWidget({
- apiKey: "api-key",
- container: document.createElement("div"),
- })
- ).toThrow(
- "Only one StakeKit Widget may be mounted in a browser document at a time."
- );
- expect(createRoot).toHaveBeenCalledOnce();
+ const secondWidget = secondCopy.renderSKWidget({
+ apiKey: "api-key",
+ container: document.createElement("div"),
+ });
+
+ expect(createRoot).toHaveBeenCalledTimes(2);
activeWidget.unmount();
+ secondWidget.unmount();
});
});
diff --git a/packages/widget/tests/app/widget-instance-lifecycle.dom.test.tsx b/packages/widget/tests/app/widget-instance-lifecycle.dom.test.tsx
index 955c8a89..46ce7fd9 100644
--- a/packages/widget/tests/app/widget-instance-lifecycle.dom.test.tsx
+++ b/packages/widget/tests/app/widget-instance-lifecycle.dom.test.tsx
@@ -1,10 +1,4 @@
-import {
- act,
- Component,
- type PropsWithChildren,
- type ReactNode,
- StrictMode,
-} from "react";
+import { act, type PropsWithChildren, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "../utils/test-utils.dom.tsx";
@@ -51,29 +45,10 @@ vi.mock("../../src/shared/ui/primitives/box", () => ({
Box: ({ children }: PropsWithChildren) => children,
}));
-import { renderSKWidget, SKApp } from "../../src/App";
+import { type RenderedSKWidget, renderSKWidget, SKApp } from "../../src/App";
const originalHref = window.location.href;
-class MountErrorBoundary extends Component<
- PropsWithChildren<{ readonly onError: (error: unknown) => void }>,
- { readonly failed: boolean }
-> {
- override state = { failed: false };
-
- static getDerivedStateFromError() {
- return { failed: true };
- }
-
- override componentDidCatch(error: unknown) {
- this.props.onError(error);
- }
-
- override render(): ReactNode {
- return this.state.failed ? mount rejected
: this.props.children;
- }
-}
-
describe("Widget Instance lifecycle", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -83,27 +58,16 @@ describe("Widget Instance lifecycle", () => {
window.history.replaceState({}, "", originalHref);
});
- it("rejects a second package mount before its providers initialize", async () => {
+ it("mounts concurrent package instances in the same document", async () => {
const first = await render();
- const onError = vi.fn<(error: unknown) => void>();
- const providerCallCount = providersRendered.mock.calls.length;
+ const second = await render();
- const second = await render(
-
-
-
- );
+ expect(first.container.textContent).toContain("active widget");
+ expect(second.container.textContent).toContain("active widget");
+ second.unmount();
expect(first.container.textContent).toContain("active widget");
- expect(second.container.textContent).toContain("mount rejected");
- expect(providersRendered).toHaveBeenCalledTimes(providerCallCount);
- expect(onError).toHaveBeenCalledWith(
- expect.objectContaining({
- name: "StakeKitWidgetInstanceAlreadyMountedError",
- message:
- "Only one StakeKit Widget may be mounted in a browser document at a time.",
- })
- );
+ first.unmount();
});
it("supports a clean sequential package remount", async () => {
@@ -158,7 +122,7 @@ describe("Widget Instance lifecycle", () => {
expect(app.container.textContent).toContain("active widget");
});
- it("claims the document that owns the package mount container", async () => {
+ it("supports mounting across different documents", async () => {
const mainDocumentApp = await render();
const secondaryDocument = document.implementation.createHTMLDocument();
const secondaryContainer = secondaryDocument.createElement("div");
@@ -175,10 +139,12 @@ describe("Widget Instance lifecycle", () => {
act(() => secondaryRoot.unmount());
});
- it("accepts a bundled API key change and keeps the claim until unmount", async () => {
+ it("accepts a bundled API key change and supports concurrent bundled mounts", async () => {
const container = document.createElement("div");
- document.body.append(container);
- let controller: ReturnType;
+ const otherContainer = document.createElement("div");
+ document.body.append(container, otherContainer);
+ let controller: RenderedSKWidget;
+ let otherController: RenderedSKWidget | undefined;
try {
await act(async () => {
@@ -191,17 +157,24 @@ describe("Widget Instance lifecycle", () => {
expect(
container.querySelector('[data-testid="bundled-api-key"]')?.textContent
).toBe("updated-api-key");
- expect(() =>
- renderSKWidget({
+
+ await act(async () => {
+ otherController = renderSKWidget({
apiKey: "other-api-key",
- container: document.createElement("div"),
- })
- ).toThrow(
- "Only one StakeKit Widget may be mounted in a browser document at a time."
- );
+ container: otherContainer,
+ });
+ });
+ expect(
+ otherContainer.querySelector('[data-testid="bundled-api-key"]')
+ ?.textContent
+ ).toBe("other-api-key");
} finally {
- act(() => controller.unmount());
+ act(() => {
+ controller.unmount();
+ otherController?.unmount();
+ });
container.remove();
+ otherContainer.remove();
}
});
@@ -226,11 +199,10 @@ describe("Widget Instance lifecycle", () => {
container.remove();
}
});
-
it("ignores rerender after the bundled Widget Instance unmounts", async () => {
const container = document.createElement("div");
document.body.append(container);
- let controller: ReturnType;
+ let controller: RenderedSKWidget;
await act(async () => {
controller = renderSKWidget({ apiKey: "api-key", container });
@@ -243,37 +215,28 @@ describe("Widget Instance lifecycle", () => {
container.remove();
});
- it("releases the package claim after runtime cleanup", async () => {
+ it("supports mounting a bundled widget during runtime cleanup", async () => {
const app = await render();
- let cleanupMount: ReturnType | undefined;
- let cleanupMountError: unknown;
+ let cleanupMount: RenderedSKWidget | undefined;
+ const cleanupContainer = document.createElement("div");
+ document.body.append(cleanupContainer);
runtimeReleased.mockImplementation(() => {
- try {
- cleanupMount = renderSKWidget({
- apiKey: "cleanup-api-key",
- container: document.createElement("div"),
- });
- } catch (error) {
- cleanupMountError = error;
- }
+ cleanupMount = renderSKWidget({
+ apiKey: "cleanup-api-key",
+ container: cleanupContainer,
+ });
});
app.unmount();
- cleanupMount?.unmount();
- expect(cleanupMount).toBeUndefined();
- expect(cleanupMountError).toMatchObject({
- name: "StakeKitWidgetInstanceAlreadyMountedError",
- });
+ expect(cleanupMount).toBeDefined();
+ expect(
+ cleanupContainer.querySelector('[data-testid="bundled-api-key"]')
+ ?.textContent
+ ).toBe("cleanup-api-key");
- let remountedController: ReturnType;
- await act(async () => {
- remountedController = renderSKWidget({
- apiKey: "remount-api-key",
- container: document.createElement("div"),
- });
- });
- act(() => remountedController.unmount());
+ act(() => cleanupMount?.unmount());
+ cleanupContainer.remove();
});
});
diff --git a/packages/widget/tests/utils/test-utils.tsx b/packages/widget/tests/utils/test-utils.tsx
index ce15859e..686f0a49 100644
--- a/packages/widget/tests/utils/test-utils.tsx
+++ b/packages/widget/tests/utils/test-utils.tsx
@@ -3,7 +3,6 @@ import type { ComponentProps } from "react";
import { type RenderOptions, render } from "vitest-browser-react";
import { type SKApp, SKAppRegistryContent } from "../../src/App";
import { WidgetConfigBoundaryAdapter } from "../../src/app/composition/providers/widget-config-binding";
-import { WidgetInstanceReactBoundary } from "../../src/app/embedding/widget-instance-react-boundary";
import { applicationRoutes } from "../../src/app/routes/application-routes";
import { applicationRuntimeInitAtom } from "../../src/app/runtime/application-runtime-init";
import { walletConnectorSourceRuntime } from "../../src/app/runtime/wallet-connector-source-runtime";
@@ -31,25 +30,23 @@ const renderApp = (opts?: {
] as const)
: [];
const App = (
-
-
-
- {children}
-
-
-
+
+
+ {children}
+
+
);
return render(App, opts?.options);