From d038a944bc5668bb251dadb453122feab170a18d Mon Sep 17 00:00:00 2001
From: Sefunmi
Date: Fri, 8 May 2026 16:16:13 +0100
Subject: [PATCH 1/2] Refactor SVG/MathML namespace handling
Introduce a scope for managing XML namespaces. This allows for proper
handling of SVG and MathML elements within nested structures, including
reactive branches and teleported content.
This change also improves the `createContainer` logic by inheriting
namespaces from parent scopes and setting them explicitly when needed,
rather than relying on string manipulation or re-serialization.
---
packages/retend-web/source/dom-renderer.js | 122 ++++++-------
tests/attributes.spec.tsx | 194 ++++++++++++++++++++-
tests/hydration/hydration.spec.tsx | 23 +++
3 files changed, 278 insertions(+), 61 deletions(-)
diff --git a/packages/retend-web/source/dom-renderer.js b/packages/retend-web/source/dom-renderer.js
index 7db13509..b84764ce 100644
--- a/packages/retend-web/source/dom-renderer.js
+++ b/packages/retend-web/source/dom-renderer.js
@@ -1,4 +1,4 @@
-/** @import { Observer, ReconcilerOptions, Renderer, __HMR_UpdatableFn, StateSnapshot } from "retend"; */
+/** @import { Observer, ReconcilerOptions, Renderer, __HMR_UpdatableFn, Scope, StateSnapshot } from "retend"; */
/** @import { JSX } from 'retend/jsx-runtime'; */
/** @import { ConnectedComment, HiddenElementProperties } from './utils.js'; */
@@ -7,6 +7,7 @@ import {
Cell,
branchState,
createNodesFromTemplate,
+ createScope,
getState,
normalizeJsxChild,
withState,
@@ -14,6 +15,7 @@ import {
setActiveRenderer,
runPendingSetupEffects,
linkNodes,
+ useScopeContext,
} from 'retend';
import * as Ops from './dom-ops.js';
@@ -29,6 +31,11 @@ import {
const COMMENT_NODE = 8;
const TEXT_NODE = 3;
const DOCUMENT_FRAGMENT_NODE = 11;
+/** @type {Scope} */
+const NamespaceScope = createScope('retend-web:Namespace');
+const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
/**
* @typedef {Element & HiddenElementProperties} JsxElement
@@ -302,30 +309,6 @@ export class DOMRenderer {
const shadowRoot = Ops.appendShadowRoot(parentNode, childNode, this);
if (shadowRoot) return shadowRoot;
- const tagname = parentNode.tagName;
-
- // Client-side bailout for SVG and MathML elements.
- //
- // By default, elements are created using
- // Document.createElement(), which will only yield HTML-namespaced elements.
- //
- // This means that we end up with SVG and MathML specific elements
- // that look correct, but are not actually SVG or MathML elements.
- // To fix this, we need to serialize the badly formed elements
- // and recreate them using the namespace of the nearest svg or math parent.
- //
- // This will lead to a loss of interactivity, but idk, you win and you lose.
- if (
- (tagname === 'svg' || tagname === 'math') &&
- childNode instanceof HTMLElement
- ) {
- const elementNamespace =
- parentNode.namespaceURI ?? 'http://www.w3.org/1999/xhtml';
- const temp = this.host.document.createElementNS(elementNamespace, 'div');
- temp.innerHTML = /** @type {HTMLElement} */ (childNode).outerHTML;
- parentNode.append(...temp.children);
- return parentNode;
- }
if (Array.isArray(childNode)) {
const children = childNode.filter(Boolean);
parentNode.append(...children);
@@ -371,42 +354,63 @@ export class DOMRenderer {
* @returns {JsxElement}
*/
createContainer(tagname, props) {
- if (this.#isHydrationModeEnabled) {
- const hydration = this.#getHydrationState();
- if (hydration) {
- if (containerIsDynamic(tagname, props, isReactiveChild)) {
- const branchCursor = hydration.cursor;
- const activeBranch = getState();
- const index = `${activeBranch.node.id}.${branchCursor}`;
- hydration.cursor += 1;
-
- const staticNode = this.#table.get(index);
- const hydrationNode =
- staticNode && !this.#hydratedNodes.has(staticNode)
- ? staticNode
- : null;
- if (hydrationNode) {
- this.#hydratedNodes.add(hydrationNode);
- const hydrationTask = Promise.resolve().then(() =>
- this.#hydrateNode(hydrationNode, props)
- );
- this.#trackHydrationTask(activeBranch, hydrationTask);
- return hydrationNode;
- }
- }
- // @ts-expect-error: The types are different in hydration mode.
- return new Skip(tagname);
- }
- }
+ let inheritedNamespace = HTML_NAMESPACE;
+ try {
+ inheritedNamespace = useScopeContext(NamespaceScope);
+ } catch {}
- const defaultNamespace = props?.xmlns ?? 'http://www.w3.org/1999/xhtml';
+ const defaultNamespace = props?.xmlns ?? inheritedNamespace;
let ns;
- if (tagname === 'svg') {
- ns = 'http://www.w3.org/2000/svg';
- } else if (tagname === 'math') {
- ns = 'http://www.w3.org/1998/Math/MathML';
- } else {
- ns = defaultNamespace;
+ if (tagname === 'svg') ns = SVG_NAMESPACE;
+ else if (tagname === 'math') ns = MATH_NAMESPACE;
+ else ns = defaultNamespace;
+
+ if (
+ props &&
+ props.xmlns === undefined &&
+ (tagname === 'svg' || tagname === 'math')
+ ) {
+ props.xmlns = ns;
+ }
+
+ const hydration = this.#isHydrationModeEnabled
+ ? this.#getHydrationState()
+ : null;
+ const isDynamic =
+ hydration && containerIsDynamic(tagname, props, isReactiveChild);
+
+ if (props && tagname !== 'retend-teleport' && 'children' in props) {
+ const childNamespace = tagname === 'foreignObject' ? HTML_NAMESPACE : ns;
+ const children = props.children;
+ props.children = () =>
+ NamespaceScope.Provider({
+ value: childNamespace,
+ children: () => children,
+ h: false,
+ });
+ }
+
+ if (hydration) {
+ if (isDynamic) {
+ const branchCursor = hydration.cursor;
+ const activeBranch = getState();
+ const index = `${activeBranch.node.id}.${branchCursor}`;
+ hydration.cursor += 1;
+
+ const staticNode = this.#table.get(index);
+ const hydrationNode =
+ staticNode && !this.#hydratedNodes.has(staticNode) ? staticNode : null;
+ if (hydrationNode) {
+ this.#hydratedNodes.add(hydrationNode);
+ const hydrationTask = Promise.resolve().then(() =>
+ this.#hydrateNode(hydrationNode, props)
+ );
+ this.#trackHydrationTask(activeBranch, hydrationTask);
+ return hydrationNode;
+ }
+ }
+ // @ts-expect-error: The types are different in hydration mode.
+ return new Skip(tagname);
}
/** @type {JsxElement} */ // @ts-expect-error
diff --git a/tests/attributes.spec.tsx b/tests/attributes.spec.tsx
index 700f4b81..916b13a3 100644
--- a/tests/attributes.spec.tsx
+++ b/tests/attributes.spec.tsx
@@ -1,7 +1,19 @@
-import { Cell } from 'retend';
+import {
+ Cell,
+ If,
+ Switch,
+ createUnique,
+ getActiveRenderer,
+ runPendingSetupEffects,
+} from 'retend';
+import { Teleport } from 'retend-web';
import { describe, expect, it } from 'vitest';
-import { browserSetup, render, vDomSetup } from './setup.tsx';
+import { browserSetup, render, timeout, vDomSetup } from './setup.tsx';
+
+const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
+const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
+const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
const runTests = () => {
it('should set an attribute on an element', () => {
@@ -22,6 +34,184 @@ describe('Attributes', () => {
describe('Browser', () => {
browserSetup();
runTests();
+
+ it('should preserve SVG namespaces and interactivity through component boundaries', () => {
+ const clicks = Cell.source(0);
+ const IconPart = () => (
+
+ clicks.set(clicks.get() + 1)}
+ />
+
+ );
+ const element = render(
+
+ );
+ const group = element.querySelector('#group');
+ const circle = element.querySelector('#circle') as SVGCircleElement;
+
+ expect(element.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(group?.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(circle.namespaceURI).toBe(SVG_NAMESPACE);
+
+ circle.dispatchEvent(new MouseEvent('click'));
+ expect(clicks.get()).toBe(1);
+ });
+
+ it('should switch foreignObject descendants back to HTML namespace', () => {
+ const ForeignContent = () => HTML
;
+ const element = render(
+
+ );
+ const foreignObject = element.querySelector('#foreign-object');
+ const content = element.querySelector('#html-content');
+
+ expect(foreignObject?.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(content?.namespaceURI).toBe(HTML_NAMESPACE);
+ expect(content?.getAttribute('xmlns')).toBeNull();
+ });
+
+ it('should preserve MathML namespaces through component boundaries', () => {
+ const MathPart = () => (
+
+ x
+
+ );
+ const element = render(
+
+ );
+ const row = element.querySelector('#math-row');
+ const identifier = element.querySelector('#math-identifier');
+
+ expect(element.namespaceURI).toBe(MATH_NAMESPACE);
+ expect(row?.namespaceURI).toBe(MATH_NAMESPACE);
+ expect(identifier?.namespaceURI).toBe(MATH_NAMESPACE);
+ });
+
+ it('should preserve SVG namespaces through reactive If branches', () => {
+ const show = Cell.source(true);
+ const element = render(
+
+ );
+ let group = element.querySelector('#if-group');
+ let circle = element.querySelector('#if-circle');
+
+ expect(group?.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(circle?.namespaceURI).toBe(SVG_NAMESPACE);
+
+ show.set(false);
+ expect(element.querySelector('#if-group')).toBeNull();
+
+ show.set(true);
+ group = element.querySelector('#if-group');
+ circle = element.querySelector('#if-circle');
+ expect(group?.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(circle?.namespaceURI).toBe(SVG_NAMESPACE);
+ });
+
+ it('should preserve SVG namespaces through Switch cases', () => {
+ const selected = Cell.source('circle');
+ const element = render(
+
+ );
+
+ expect(element.querySelector('#switch-circle')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+
+ selected.set('rect');
+ expect(element.querySelector('#switch-rect')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+ });
+
+ it('should preserve namespaces when teleporting SVG content out of an SVG tree', async () => {
+ const target = document.createElement('div');
+ target.id = 'svg-teleport-target';
+ document.body.append(target);
+
+ const element = render(
+
+ );
+ document.body.append(element);
+ getActiveRenderer().observer?.flush();
+
+ const group = target.querySelector('#teleported-group');
+ const circle = target.querySelector('#teleported-circle');
+
+ expect(group?.namespaceURI).toBe(SVG_NAMESPACE);
+ expect(circle?.namespaceURI).toBe(SVG_NAMESPACE);
+ });
+
+ it('should preserve SVG namespaces for Unique components after moves', async () => {
+ const slot = Cell.source('first');
+ const UniqueIcon = createUnique(() => (
+
+
+
+ ));
+
+ const element = render(
+
+ );
+ await runPendingSetupEffects();
+
+ expect(element.querySelector('#unique-group')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+ expect(element.querySelector('#unique-circle')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+
+ slot.set('second');
+ await runPendingSetupEffects();
+ await timeout();
+
+ expect(element.querySelector('#unique-group')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+ expect(element.querySelector('#unique-circle')?.namespaceURI).toBe(
+ SVG_NAMESPACE
+ );
+ });
});
describe('VDom', () => {
diff --git a/tests/hydration/hydration.spec.tsx b/tests/hydration/hydration.spec.tsx
index 341dcf04..ffea6750 100644
--- a/tests/hydration/hydration.spec.tsx
+++ b/tests/hydration/hydration.spec.tsx
@@ -112,6 +112,29 @@ describe('Hydration', () => {
expect(document.querySelector('#false-branch')).not.toBeNull();
});
+ it('should preserve SVG namespaces through hydrated If blocks', async () => {
+ const show = Cell.source(true);
+ const template = () => (
+
+ );
+
+ const { document } = await setupHydration(template);
+ expect(document.querySelector('#hydrated-circle')?.namespaceURI).toBe(
+ 'http://www.w3.org/2000/svg'
+ );
+
+ show.set(false);
+
+ expect(document.querySelector('#hydrated-rect')?.namespaceURI).toBe(
+ 'http://www.w3.org/2000/svg'
+ );
+ });
+
it('should hydrate For blocks', async () => {
const items = Cell.source(['Item 1', 'Item 2']);
const template = () => (
From 89b6982324ec00eb5f9c83203e75b8e1a1b3807f Mon Sep 17 00:00:00 2001
From: Sefunmi
Date: Fri, 8 May 2026 19:48:49 +0100
Subject: [PATCH 2/2] Refactor ClientOnly to ClientBoundaries
Introduce ClientReady component and move ClientOnly into
client-boundaries.js.
Update package.json and documentation to reflect the changes.
---
...ient-only.mdx => 24-client-boundaries.mdx} | 22 +++-
packages/retend-server/package.json | 4 +-
.../retend-server/source/client-boundaries.js | 104 ++++++++++++++++++
packages/retend-server/source/client-only.js | 51 ---------
packages/retend-web/source/dom-renderer.js | 4 +-
tests/attributes.spec.tsx | 8 +-
...ly.spec.tsx => client-boundaries.spec.tsx} | 102 ++++++++++++++++-
7 files changed, 236 insertions(+), 59 deletions(-)
rename docs/content/{24-client-only.mdx => 24-client-boundaries.mdx} (63%)
create mode 100644 packages/retend-server/source/client-boundaries.js
delete mode 100644 packages/retend-server/source/client-only.js
rename tests/hydration/{client-only.spec.tsx => client-boundaries.spec.tsx} (73%)
diff --git a/docs/content/24-client-only.mdx b/docs/content/24-client-boundaries.mdx
similarity index 63%
rename from docs/content/24-client-only.mdx
rename to docs/content/24-client-boundaries.mdx
index 99f5a379..d9bab789 100644
--- a/docs/content/24-client-only.mdx
+++ b/docs/content/24-client-boundaries.mdx
@@ -1,6 +1,6 @@
---
-title: 'ClientOnly'
-description: 'Render UI only in the browser for API-dependent components.'
+title: 'ClientOnly and ClientReady'
+description: 'Render UI only in the browser, with optional readiness gating for async client content.'
---
# ClientOnly
@@ -29,6 +29,24 @@ function App() {
During SSG, the `fallback` is rendered instead. Once the JavaScript loads and the component mounts in the browser, the real `children` replace the fallback.
+## ClientReady
+
+Use `` when the browser-only subtree also has async work that should finish before the fallback disappears. It skips the subtree during SSG like ``, then keeps showing the fallback until the client subtree has mounted and its initial `` boundary has resolved.
+
+```tsx
+import { ClientReady } from 'retend-server';
+
+function App() {
+ return (
+ }>
+
+
+ );
+}
+```
+
+This is useful for app shells, boot screens, and browser-only dashboards where showing an empty or half-prepared client subtree would be more distracting than keeping the loading UI visible for a little longer.
+
## When to Use It
Use `` when a component:
diff --git a/packages/retend-server/package.json b/packages/retend-server/package.json
index 45937713..78c03b13 100644
--- a/packages/retend-server/package.json
+++ b/packages/retend-server/package.json
@@ -6,8 +6,8 @@
"type": "module",
"exports": {
".": {
- "types": "./dist/client-only.d.ts",
- "import": "./dist/client-only.js"
+ "types": "./dist/client-boundaries.d.ts",
+ "import": "./dist/client-boundaries.js"
},
"./server": {
"types": "./dist/server.d.ts",
diff --git a/packages/retend-server/source/client-boundaries.js b/packages/retend-server/source/client-boundaries.js
new file mode 100644
index 00000000..febca637
--- /dev/null
+++ b/packages/retend-server/source/client-boundaries.js
@@ -0,0 +1,104 @@
+/** @import { JSX } from 'retend/jsx-runtime' */
+
+import { Await, Cell, If, onSetup } from 'retend';
+
+/**
+ * @typedef {Object} ClientOnlyProps
+ * @property {JSX.Children} children
+ * The content to render only on the client side.
+ * @property {JSX.Template} [fallback]
+ * Optional content to render during server-side rendering
+ * and before the client has mounted.
+ */
+
+/**
+ * A component that only renders its children on the client side.
+ *
+ * During server-side rendering, the `fallback` is rendered instead
+ * (or nothing if no fallback is provided). Once the component is
+ * mounted on the client, it swaps to the real `children`.
+ *
+ * This is useful for components that depend on browser-only APIs
+ * (e.g., `window.innerWidth`) and would produce a server/client
+ * mismatch during hydration.
+ *
+ * @param {ClientOnlyProps} props
+ * @returns {JSX.Template}
+ *
+ * @example
+ * ```tsx
+ * import { ClientOnly } from 'retend-server';
+ *
+ * const Page = () => (
+ * Loading...
}>
+ *
+ *
+ * );
+ * ```
+ */
+export function ClientOnly(props) {
+ const { children, fallback } = props;
+ const mounted = Cell.source(false);
+
+ onSetup(() => {
+ mounted.set(true);
+ });
+
+ return If(mounted, {
+ true: () => children,
+ false: () => fallback,
+ });
+}
+
+/**
+ * @typedef {Object} ClientReadyProps
+ * @property {JSX.Children} children
+ * The content to render after the app is running on the client and its
+ * initial async work has completed.
+ * @property {JSX.Template} [fallback]
+ * Optional content to render during server-side rendering, hydration,
+ * and the client subtree's initial async work.
+ */
+
+/**
+ * A component that renders fallback content until a client-only subtree is
+ * mounted and its initial `Await` boundary has finished resolving.
+ *
+ * Unlike `ClientOnly`, this keeps the fallback visible while async cells inside
+ * the client subtree are still pending on the first client render.
+ *
+ * @param {ClientReadyProps} props
+ * @returns {JSX.Template}
+ *
+ * @example
+ * ```tsx
+ * import { ClientReady } from 'retend-server';
+ *
+ * const App = () => (
+ * Starting...}>
+ *
+ *
+ * );
+ * ```
+ */
+export function ClientReady(props) {
+ const { children, fallback } = props;
+ const ready = Cell.source(false);
+
+ const ClientReadyContent = () => {
+ onSetup(() => ready.set(true));
+ return children;
+ };
+
+ return [
+ ClientOnly({
+ children: () =>
+ Await({
+ children: ClientReadyContent,
+ }),
+ }),
+ If(ready, {
+ false: () => fallback,
+ }),
+ ];
+}
diff --git a/packages/retend-server/source/client-only.js b/packages/retend-server/source/client-only.js
deleted file mode 100644
index fc080d48..00000000
--- a/packages/retend-server/source/client-only.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/** @import { JSX } from 'retend/jsx-runtime' */
-
-import { Cell, If, onSetup } from 'retend';
-
-/**
- * @typedef {Object} ClientOnlyProps
- * @property {JSX.Template} children
- * The content to render only on the client side.
- * @property {JSX.Template} [fallback]
- * Optional content to render during server-side rendering
- * and before the client has mounted.
- */
-
-/**
- * A component that only renders its children on the client side.
- *
- * During server-side rendering, the `fallback` is rendered instead
- * (or nothing if no fallback is provided). Once the component is
- * mounted on the client, it swaps to the real `children`.
- *
- * This is useful for components that depend on browser-only APIs
- * (e.g., `window.innerWidth`) and would produce a server/client
- * mismatch during hydration.
- *
- * @param {ClientOnlyProps} props
- * @returns {JSX.Template}
- *
- * @example
- * ```tsx
- * import { ClientOnly } from 'retend-server';
- *
- * const Page = () => (
- * Loading...}>
- *
- *
- * );
- * ```
- */
-export function ClientOnly(props) {
- const { children, fallback } = props;
- const mounted = Cell.source(false);
-
- onSetup(() => {
- mounted.set(true);
- });
-
- return If(mounted, {
- true: () => children,
- false: () => fallback,
- });
-}
diff --git a/packages/retend-web/source/dom-renderer.js b/packages/retend-web/source/dom-renderer.js
index b84764ce..822bc882 100644
--- a/packages/retend-web/source/dom-renderer.js
+++ b/packages/retend-web/source/dom-renderer.js
@@ -399,7 +399,9 @@ export class DOMRenderer {
const staticNode = this.#table.get(index);
const hydrationNode =
- staticNode && !this.#hydratedNodes.has(staticNode) ? staticNode : null;
+ staticNode && !this.#hydratedNodes.has(staticNode)
+ ? staticNode
+ : null;
if (hydrationNode) {
this.#hydratedNodes.add(hydrationNode);
const hydrationTask = Promise.resolve().then(() =>
diff --git a/tests/attributes.spec.tsx b/tests/attributes.spec.tsx
index 916b13a3..c52e3e7f 100644
--- a/tests/attributes.spec.tsx
+++ b/tests/attributes.spec.tsx
@@ -184,11 +184,15 @@ describe('Attributes', () => {
);
diff --git a/tests/hydration/client-only.spec.tsx b/tests/hydration/client-boundaries.spec.tsx
similarity index 73%
rename from tests/hydration/client-only.spec.tsx
rename to tests/hydration/client-boundaries.spec.tsx
index 695548cf..4f149d2a 100644
--- a/tests/hydration/client-only.spec.tsx
+++ b/tests/hydration/client-boundaries.spec.tsx
@@ -5,7 +5,7 @@ import {
onSetup,
runPendingSetupEffects,
} from 'retend';
-import { ClientOnly } from 'retend-server';
+import { ClientOnly, ClientReady } from 'retend-server';
import { describe, expect, it, vi } from 'vitest';
import { browserSetup, render, vDomSetup } from '../setup.tsx';
@@ -287,3 +287,103 @@ describe('ClientOnly', () => {
});
});
});
+
+describe('ClientReady', () => {
+ describe('SSR (serialization)', () => {
+ vDomSetup({ markDynamicNodes: true });
+
+ it('should render fallback during SSR', () => {
+ const template = () => (
+
+ Loading...}>
+ Client
+
+
+ );
+
+ const rendered = render(template) as unknown as Element;
+ expect(rendered.querySelector('#fallback')).not.toBeNull();
+ expect(rendered.querySelector('#client-content')).toBeNull();
+ });
+ });
+
+ describe('Hydration', () => {
+ browserSetup();
+
+ it('should keep fallback visible until async client content resolves', async () => {
+ const loaded = Cell.source(false);
+
+ const Delayed = () => {
+ const delay = Cell.derivedAsync(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ return 'Ready';
+ });
+
+ return {delay};
+ };
+
+ const Content = () => {
+ onSetup(() => loaded.set(true));
+ return ;
+ };
+
+ const template = () => (
+
+ Boot}>
+
+
+
+ );
+
+ const { document } = await setupHydration(template);
+ await runPendingSetupEffects();
+
+ expect(document.querySelector('#fallback')).not.toBeNull();
+ expect(document.querySelector('#delayed')).toBeNull();
+ expect(loaded.get()).toBe(false);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await runPendingSetupEffects();
+
+ expect(document.querySelector('#fallback')).toBeNull();
+ expect(document.querySelector('#delayed')?.textContent).toBe('Ready');
+ expect(loaded.get()).toBe(true);
+ });
+ });
+
+ describe('SPA (no hydration)', () => {
+ browserSetup();
+
+ it('should wait for initial async client content before removing fallback', async () => {
+ const Delayed = () => {
+ const delay = Cell.derivedAsync(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ return 'Ready';
+ });
+
+ return {delay};
+ };
+
+ const template = () => (
+
+ Boot}>
+
+
+
+ );
+
+ const root = render(template);
+ document.body.append(root);
+ await runPendingSetupEffects();
+
+ expect(document.querySelector('#spa-fallback')).not.toBeNull();
+ expect(document.querySelector('#spa-delayed')).toBeNull();
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ await runPendingSetupEffects();
+
+ expect(document.querySelector('#spa-fallback')).toBeNull();
+ expect(document.querySelector('#spa-delayed')?.textContent).toBe('Ready');
+ });
+ });
+});