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} -