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
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
11 changes: 7 additions & 4 deletions docs/adr/0001-one-widget-instance-per-browser-document.md
Original file line numberDiff line numberDiff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/widget/ARCHITECTURE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).

Expand Down
76 changes: 30 additions & 46 deletions packages/widget/src/App.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -93,55 +91,41 @@ const SKAppProductionContent = ({
};

export const SKApp = ({ children, ...hostConfiguration }: SKAppProps) => (
<WidgetInstanceReactBoundary>
<SKAppProductionContent {...hostConfiguration}>
{children}
</SKAppProductionContent>
</WidgetInstanceReactBoundary>
<SKAppProductionContent {...hostConfiguration}>
{children}
</SKAppProductionContent>
);

const BundledSKWidget = (props: BundledSKWidgetProps) => (
<SKAppProductionContent {...props} />
);
export interface RenderedSKWidget {
rerender: (newProps: BundledSKWidgetProps) => void;
unmount: () => void;
}

export const renderSKWidget = ({
container,
...rest
}: BundledSKWidgetProps & {
container: Parameters<typeof ReactDOM.createRoot>[0];
}) => {
const releaseClaim = acquireWidgetInstanceClaim(
container.ownerDocument ?? document
);
let root: ReturnType<typeof ReactDOM.createRoot>;

try {
root = ReactDOM.createRoot(container);
let currentProps = rest;
let unmounted = false;
const render = () => root.render(<BundledSKWidget {...currentProps} />);

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<typeof ReactDOM.createRoot>[0];
}): RenderedSKWidget => {
const root = ReactDOM.createRoot(container);
let currentProps = rest;
let unmounted = false;
const render = () =>
root.render(<SKAppProductionContent {...currentProps} />);

render();

return {
rerender: (newProps: BundledSKWidgetProps) => {
if (unmounted) return;
currentProps = newProps;
render();
},
unmount: () => {
if (unmounted) return;

unmounted = true;
root.unmount();
},
};
};
33 changes: 0 additions & 33 deletions packages/widget/src/app/embedding/widget-instance-claim.ts

This file was deleted.

This file was deleted.

48 changes: 18 additions & 30 deletions packages/widget/tests/app/bundled-renderer.dom.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"),
Expand All@@ -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"),
Expand All@@ -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();
});
});
Loading
Loading