Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/svg-vector-effect-streamed-suspense.md
Original file line number Diff line number Diff line change
@@ -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 `<svg>`.
2 changes: 1 addition & 1 deletion packages/redact/src/core/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions packages/redact/src/server/bootstrap-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <svg>, the fallback wrapper is <g> and the staging element is a
* hidden <svg>, 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).
Expand Down
5 changes: 4 additions & 1 deletion packages/redact/src/server/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ function streamBoundary(
onError: options.onError,
componentStack: b.componentStack,
resourceScope,
textState: { bits: b.svg },
})
state.frame?.resources?.mergeScope(resourceScope)
} catch (err) {
Expand All @@ -236,7 +237,9 @@ function streamBoundary(
if (hints) emit(hints)
}

emit(`<div hidden id="S:${b.id}">${parts.join('')}</div>${revealScript(b.id, nonce, styles)}`)
// SVG content must stage inside <svg> 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('')}</${tag}>${revealScript(b.id, nonce, styles)}`)

// Recurse: any nested suspensions inside the now-revealed content
for (const s of sub) streamBoundary(s, emit, options, state)
Expand Down
11 changes: 8 additions & 3 deletions packages/redact/src/server/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface SuspendedBoundary {
thenable: Promise<any>
contextSnapshot: ContextSnapshot
componentStack: string
// SVG bit of the parent's traversal flags, restored when the boundary streams.
svg: number
}

export interface WalkOptions {
Expand Down Expand Up @@ -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
}
Expand All @@ -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(`<!--$?${id}--><div id="B:${id}">`)
// boundary and register a reveal callback. A div would make the HTML parser
// break out of an enclosing <svg>, so SVG boundaries wrap with g instead.
const tag = opts.textState!.bits & 1 ? 'g' : 'div'
opts.emit(`<!--$?${id}--><${tag} id="B:${id}">`)
opts.emit(fallbackHTML)
opts.emit(`</div><!--/$-->`)
opts.emit(`</${tag}><!--/$-->`)
}
8 changes: 7 additions & 1 deletion scripts/size-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`)
Expand Down
96 changes: 93 additions & 3 deletions tests/svg-attribute-casing.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>(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(/<script[\s\S]*?<\/script>/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 <svg>', 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 <foreignObject>', 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'],
Expand Down