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( + + {If(show, () => ( + + + + ))} + + ); + 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( + + {Switch(selected, { + circle: () => , + rect: () => , + })} + + ); + + 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( + + {If( + Cell.derived(() => slot.get() === 'first'), + () => + )} + {If( + Cell.derived(() => slot.get() === 'second'), + () => + )} + + ); + 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 = () => ( + + {If(show, { + true: () => , + false: () => , + })} + + ); + + 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', () => { {If( Cell.derived(() => slot.get() === 'first'), - () => + () => ( + + ) )} {If( Cell.derived(() => slot.get() === 'second'), - () => + () => ( + + ) )} ); 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'); + }); + }); +});