From b7b514e5fde1f2b565c00e3ac55c3652c03c897e Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Fri, 4 Sep 2026 12:54:11 +0200 Subject: [PATCH] refactor(widget): remove multi-instance mount check Remove the runtime document claim check that threw an error when mounting more than one Widget Instance in the same browser document. Hosts can now mount multiple widget instances concurrently without runtime errors. Update ADR 0001, ARCHITECTURE.md, and CONTEXT.md to clarify that multiple instances remain unsupported due to shared document-global state (CSS custom properties, wallet discovery, and portals). --- CONTEXT.md | 6 +- ...ne-widget-instance-per-browser-document.md | 11 +- packages/widget/ARCHITECTURE.md | 3 +- packages/widget/src/App.tsx | 76 +++++------ .../app/embedding/widget-instance-claim.ts | 33 ----- .../widget-instance-react-boundary.tsx | 48 ------- .../tests/app/bundled-renderer.dom.test.tsx | 48 +++---- .../widget-instance-lifecycle.dom.test.tsx | 127 +++++++----------- packages/widget/tests/utils/test-utils.tsx | 37 +++-- 9 files changed, 122 insertions(+), 267 deletions(-) delete mode 100644 packages/widget/src/app/embedding/widget-instance-claim.ts delete mode 100644 packages/widget/src/app/embedding/widget-instance-react-boundary.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 2ac7efedc..ce956b6f3 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 462762ed0..cbf03c755 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 aa38c3bc4..c01ca97f0 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 746c32cff..2777e4819 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 22dc2dd7a..000000000 --- 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 723ff1dcc..000000000 --- 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} -