diff --git a/.changeset/svg-vector-effect-streamed-suspense.md b/.changeset/svg-vector-effect-streamed-suspense.md new file mode 100644 index 0000000..74f215f --- /dev/null +++ b/.changeset/svg-vector-effect-streamed-suspense.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Map `vectorEffect` to `vector-effect` so it takes effect and no longer fails hydration, and keep SVG attribute casing, namespace and position for Suspense boundaries streamed directly inside ``. diff --git a/packages/redact/src/core/attributes.ts b/packages/redact/src/core/attributes.ts index 022d069..02946bb 100644 --- a/packages/redact/src/core/attributes.ts +++ b/packages/redact/src/core/attributes.ts @@ -4,7 +4,7 @@ export const STRING_BOOLEAN_ATTRS = /^(contenteditable|draggable|spellcheck)$/ export function attributeName(name: string, isSvg?: boolean): string { if (name === 'className') return 'class' if (name === 'htmlFor') return 'for' - if ((isSvg && /^(clip|fill|stroke)/.test(name)) || /^(httpEquiv|acceptCharset|maskType)$/.test(name)) { + if ((isSvg && /^(clip|fill|stroke|vector)/.test(name)) ||/^(httpEquiv|acceptCharset|maskType)$/.test(name)) { return name.replace(/[A-Z]/g, m => '-' + m.toLowerCase()) } if (name === 'tabIndex' || name === 'crossOrigin') return name.toLowerCase() diff --git a/packages/redact/src/server/bootstrap-script.ts b/packages/redact/src/server/bootstrap-script.ts index 6acb6aa..ff519df 100644 --- a/packages/redact/src/server/bootstrap-script.ts +++ b/packages/redact/src/server/bootstrap-script.ts @@ -12,6 +12,8 @@ * The fallback div is visible (it's the user-visible loading * state); only the resolved-content staging div is `hidden` * before $RC moves its children inline. + * Inside , the fallback wrapper is and the staging element is a + * hidden , so the parser keeps the content in the SVG namespace. * * Client hydration calls $RH(ID, cb) to register a callback invoked once the * boundary has been revealed (or immediately, if it was already revealed). diff --git a/packages/redact/src/server/stream.ts b/packages/redact/src/server/stream.ts index 694a489..fbbea88 100644 --- a/packages/redact/src/server/stream.ts +++ b/packages/redact/src/server/stream.ts @@ -221,6 +221,7 @@ function streamBoundary( onError: options.onError, componentStack: b.componentStack, resourceScope, + textState: { bits: b.svg }, }) state.frame?.resources?.mergeScope(resourceScope) } catch (err) { @@ -236,7 +237,9 @@ function streamBoundary( if (hints) emit(hints) } - emit(`${revealScript(b.id, nonce, styles)}`) + // SVG content must stage inside to be parsed in the SVG namespace. + const tag = b.svg ? 'svg' : 'div' + emit(`<${tag} ${b.svg ? 'style="display:none"' : 'hidden'} id="S:${b.id}">${parts.join('')}${revealScript(b.id, nonce, styles)}`) // Recurse: any nested suspensions inside the now-revealed content for (const s of sub) streamBoundary(s, emit, options, state) diff --git a/packages/redact/src/server/walk.ts b/packages/redact/src/server/walk.ts index c015f74..9a92aee 100644 --- a/packages/redact/src/server/walk.ts +++ b/packages/redact/src/server/walk.ts @@ -54,6 +54,8 @@ export interface SuspendedBoundary { thenable: Promise contextSnapshot: ContextSnapshot componentStack: string + // SVG bit of the parent's traversal flags, restored when the boundary streams. + svg: number } export interface WalkOptions { @@ -490,6 +492,7 @@ function walkSuspense( if (suspended) opts.onSuspend?.({ id, fallbackHTML: fallbackParts.join(''), children: props.children, thenable, contextSnapshot, componentStack: thrownInfo.componentStack || opts.componentStack || '', + svg: opts.textState!.bits & 1, }) return } @@ -512,8 +515,10 @@ function walkSuspense( function emitBoundary(opts: WalkOptions, id: number, fallbackHTML: string): void { // Visible div wrapper so the fallback UI shows; B: id lets $RC locate it on // reveal. The leading/trailing comments let hydration detect a pending - // boundary and register a reveal callback. - opts.emit(`
`) + // boundary and register a reveal callback. A div would make the HTML parser + // break out of an enclosing , so SVG boundaries wrap with g instead. + const tag = opts.textState!.bits & 1 ? 'g' : 'div' + opts.emit(`<${tag} id="B:${id}">`) opts.emit(fallbackHTML) - opts.emit(`
`) + opts.emit(``) } diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index fc69f74..0c1ee60 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,17 @@ const STORE_HYDRATION_SIZE_ALLOWANCE = { 'redact/dom-client (hydration=stub)': [3, 0], } +// Measured overage of the SVG vectorEffect and streamed Suspense context fix, [source, built] gzip bytes. +const SVG_CONTEXT_SIZE_ALLOWANCE = { + 'redact/server': [45, 47], + 'redact/dom-client (hydration=stub)': [6, 7], +} + let failed = false for (const tree of ['src', 'dist']) { for (const row of await measureSizes(tree)) { const baseBudget = BUDGETS[row.name] - const budget = baseBudget === undefined ? undefined : baseBudget + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + const budget = baseBudget === undefined ? undefined : baseBudget + (SVG_CONTEXT_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) const ok = budget !== undefined && row.gz <= budget failed ||= !ok console.log(`${ok ? 'PASS' : 'FAIL'} ${tree} ${row.name}: ${row.gz} B gzip, budget ${budget ?? 'missing'}`) diff --git a/tests/svg-attribute-casing.test.tsx b/tests/svg-attribute-casing.test.tsx index ae6a9b9..bfb40ea 100644 --- a/tests/svg-attribute-casing.test.tsx +++ b/tests/svg-attribute-casing.test.tsx @@ -1,8 +1,98 @@ import { expect, it } from 'vitest' -import { createElement as h, Suspense } from 'react' -import { hydrateRoot } from 'react-dom/client' +import { createElement as h, Suspense, type ReactNode } from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' import { flushSync } from 'react-dom' -import { renderToString } from 'react-dom/server' +import { renderToReadableStream, renderToString } from 'react-dom/server' +import { BOUNDARY_REVEAL_RUNTIME } from '../packages/redact/src/server/bootstrap-script' + +const SVG_NS = 'http://www.w3.org/2000/svg' +const HTML_NS = 'http://www.w3.org/1999/xhtml' + +// Releases the suspended child only after the shell has flushed, so the boundary is really outlined. +async function streamAfterShell(make: (Late: (props: { children: ReactNode }) => ReactNode) => ReactNode) { + let ready = false, release!: () => void + const gate = new Promise(resolve => { release = () => { ready = true; resolve() } }) + const Late = ({ children }: { children: ReactNode }) => { if (!ready) throw gate; return children } + const tree = make(Late) + const reader = (await renderToReadableStream(tree)).getReader(), decoder = new TextDecoder() + let html = '' + for (let first = true; ; first = false) { + const { done, value } = await reader.read() + if (done) break + html += decoder.decode(value, { stream: true }) + if (first) release() + } + return { tree, html } +} + +// Loads the markup without its scripts, so the test controls when the boundary reveals. +function loadStreamed(html: string) { + const container = document.createElement('div') + container.innerHTML = html.replace(//g, '') + document.body.append(container) + new Function(BOUNDARY_REVEAL_RUNTIME)() + const id = +/id="S:(\d+)"/.exec(html)![1]! + return { container, reveal: () => (window as any).$RC(id) } +} + +it('hyphenates vectorEffect through SSR, hydration and client render', () => { + const tree = h(Suspense, { fallback: null }, h('main', null, h('svg', null, h('path', { d: 'M0 0L9 9', vectorEffect: 'non-scaling-stroke' })))) + const html = renderToString(tree); expect(html).toContain('vector-effect="non-scaling-stroke"') + const container = document.createElement('div'); container.innerHTML = html; document.body.append(container) + const main = container.querySelector('main'), errors: unknown[] = [] + const root = hydrateRoot(container, tree, { onRecoverableError: error => errors.push(error) }) + try { expect(container.querySelector('main')).toBe(main); expect(errors).toEqual([]); expect(container.querySelector('path')!.getAttribute('vector-effect')).toBe('non-scaling-stroke') } + finally { flushSync(() => root.unmount()); container.remove() } + const client = document.createElement('div'), clientRoot = createRoot(client) + flushSync(() => clientRoot.render(tree)) + expect(client.querySelector('path')!.getAttributeNames()).toContain('vector-effect') + flushSync(() => clientRoot.unmount()) +}) + +it('keeps SVG context for a boundary streamed inside ', async () => { + const { tree, html } = await streamAfterShell(Late => h('main', null, h('svg', { viewBox: '0 0 9 9' }, h(Suspense, { fallback: h('rect', { id: 'f' }) }, h(Late, null, h('symbol', { id: 't', preserveAspectRatio: 'none' }), h('path', { id: 'p', vectorEffect: 'non-scaling-stroke' })))))) + expect(html).toContain('preserveAspectRatio="none"'); expect(html).toContain('vector-effect="non-scaling-stroke"'); expect(html).not.toContain('preserveaspectratio') + const { container, reveal } = loadStreamed(html) + const svg = container.querySelector('main > svg')! + try { + expect(container.querySelector('#f')!.namespaceURI).toBe(SVG_NS); expect(svg.contains(container.querySelector('#f'))).toBe(true) + reveal() + const symbol = container.querySelector('#t')! + expect(symbol.parentElement).toBe(svg); expect(symbol.namespaceURI).toBe(SVG_NS); expect(symbol.getAttribute('preserveAspectRatio')).toBe('none') + expect(container.querySelector('#p')!.namespaceURI).toBe(SVG_NS); expect(container.querySelector('#f')).toBeNull(); expect(container.querySelector('[id^="S:"]')).toBeNull() + const errors: unknown[] = [], root = hydrateRoot(container, tree, { onRecoverableError: error => errors.push(error) }) + try { await new Promise(resolve => setTimeout(resolve)); expect(errors).toEqual([]); expect(container.querySelector('#t')).toBe(symbol) } + finally { flushSync(() => root.unmount()) } + } finally { (window as any).$RE_stop?.(); container.remove() } +}) + +it('hydrates an SVG boundary whose reveal arrives after hydration starts', async () => { + const { tree, html } = await streamAfterShell(Late => h('main', null, h('svg', null, h(Suspense, { fallback: h('rect', { id: 'f' }) }, h(Late, null, h('symbol', { id: 't', preserveAspectRatio: 'none' })))))) + const { container, reveal } = loadStreamed(html) + const svg = container.querySelector('main > svg')!, fallback = container.querySelector('#f') + const errors: unknown[] = [], root = hydrateRoot(container, tree, { onRecoverableError: error => errors.push(error) }) + try { + await new Promise(resolve => setTimeout(resolve)) + expect(container.querySelector('main > svg')).toBe(svg); expect(container.querySelector('#f')).toBe(fallback) + reveal() + await new Promise(resolve => setTimeout(resolve, 20)) + const symbol = container.querySelector('#t')! + expect(errors).toEqual([]); expect(container.querySelector('main > svg')).toBe(svg); expect(container.querySelector('#f')).toBeNull() + expect(symbol.parentElement).toBe(svg); expect(symbol.namespaceURI).toBe(SVG_NS); expect(symbol.getAttribute('preserveAspectRatio')).toBe('none') + } finally { flushSync(() => root.unmount()); (window as any).$RE_stop?.(); container.remove() } +}) + +it('keeps HTML context for a boundary streamed inside ', async () => { + const { html } = await streamAfterShell(Late => h('main', null, h('svg', null, h('foreignObject', null, h(Suspense, { fallback: h('p', { id: 'f' }) }, h(Late, null, h('p', { id: 't', tabIndex: 0 }))))))) + expect(html).toContain('tabindex="0"') + const { container, reveal } = loadStreamed(html) + try { + expect(container.querySelector('#f')!.namespaceURI).toBe(HTML_NS) + reveal() + expect(container.querySelector('#t')!.namespaceURI).toBe(HTML_NS); expect(container.querySelector('foreignObject')!.contains(container.querySelector('#t'))).toBe(true) + } finally { (window as any).$RE_stop?.(); container.remove() } +}) + it.each([ ['pattern', 'patternUnits', 'userSpaceOnUse'], ['pattern', 'patternContentUnits', 'objectBoundingBox'],