Skip to content
Open
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/image-preloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/redact': patch
---

Document renders now preload the images they render eagerly and open `<head>` with the charset and viewport metas, as React's server renderer does. Every `<img>` outside `<picture>`, `<noscript>` and SVG that has a `src` or `srcSet`, is not `loading="lazy"` or `fetchPriority="low"` and is not a `data:` URI gets a `<link rel="preload" as="image">`; the first ten and every `fetchPriority="high"` image are placed ahead of the stylesheets, the rest after the scripts.
12 changes: 10 additions & 2 deletions packages/redact/src/server/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ export interface SSRFrame {

let frame: SSRFrame | null = null

const resources = () => frame!.resources ||= createResourceHints(!!frame!.shellFlushed, frame!.styleNonce)

resourceHintDispatcher.emit = (kind, href, options) => {
if (!frame) return false
;(frame.resources ||= createResourceHints(!!frame.shellFlushed, frame.styleNonce)).add(kind, href, options)
resources().add(kind, href, options)
return true
}

Expand All @@ -35,7 +37,13 @@ export function currentSSRFrame(): SSRFrame {
}

export function recordHostResource(type: string, props: any, scope?: ResourceScope): boolean {
return !!frame && (frame.resources ||= createResourceHints(!!frame.shellFlushed, frame.styleNonce)).addHost(type, props, scope)
return !!frame && resources().addHost(type, props, scope)
}

// An <img> adds its preload. A charset or viewport <meta> in the shell returns the sink that
// hoists its markup.
export function recordHoisted(tag: string, props: any): ((chunk: string) => void) | null | void {
if (frame) return tag === 'img' ? resources().addImage(props) : resources().addMeta(props)
}

export function endSSR(previous?: ReturnType<typeof beginSSR>): void {
Expand Down
40 changes: 35 additions & 5 deletions packages/redact/src/server/resource-hints.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isFunction } from '../core/functions'
import { isFunction, isString } from '../core/functions'
import { resourceKind, resourceURL, type ResourceHintKind, type ResourceHintOptions } from '../core/resource-hints'
import { attrToHtml, escapeAttr } from './escape'
import { attributeName } from '../core/attributes'
Expand Down Expand Up @@ -56,8 +56,9 @@ export function createResourceHints(shellFlushed = false, styleNonce?: string) {
const styles = new Map<string, Resource[]>()
const dependencies = new Map<string, string[]>()
const visibleInline = new Set<string>()
let charset = '', viewport = ''
const collector = {
add(kind: ResourceHintKind | 'style', href: string, options: ResourceHintOptions): Resource {
add(kind: ResourceHintKind | 'style', href: string, options: ResourceHintOptions, eager?: boolean): Resource {
let type: string = kind
let source = href
const init = kind === 'style' || kind === 'stylesheet' || kind === 'script' || kind === 'module-script'
Expand All @@ -69,7 +70,15 @@ export function createResourceHints(shellFlushed = false, styleNonce?: string) {
else if (kind === 'preconnect') type += ':' + (options.crossOrigin ?? 'default')
const key = JSON.stringify([type, source])
const prior = resources.get(key)
if (prior && (!init || prior.kind !== 'preload' && prior.kind !== 'modulepreload')) return prior
// Image preloads that go ahead of the stylesheets: every fetchPriority="high" one and the
// first ten eager <img> per flush, as in React.
const high = kind === 'preload' && options.as === 'image' && (options.fetchPriority === 'high' || eager && groups[2]!.length < 10)
if (prior && (!init || prior.kind !== 'preload' && prior.kind !== 'modulepreload')) {
// An eager <img> also moves the preload() of the same image ahead, keeping its attributes.
const index = eager && high ? groups[4]!.indexOf(prior) : -1
if (index >= 0) groups[2]!.push(...groups[4]!.splice(index, 1))
return prior
}

const resource: Resource = { kind, href, options: { ...options } }
if (kind === 'style' && (!styleNonce || options.nonce === styleNonce)) resource.css = styleText(options)
Expand All @@ -89,12 +98,32 @@ export function createResourceHints(shellFlushed = false, styleNonce?: string) {
} else {
const group = kind === 'dns-prefetch' || kind === 'preconnect' ? 0
: kind === 'preload' && options.as === 'font' ? 1
: kind === 'preload' && options.as === 'image' && options.fetchPriority === 'high' ? 2
: high ? 2
: init ? 3 : 4
groups[group]!.push(resource)
}
return resource
},
// Preload an eagerly rendered <img> as React does: the first ten per flush and every
// fetchPriority="high" image go ahead of the stylesheets, the rest after the scripts.
addImage(props: any): void {
const { srcSet } = props, source = srcSet || props.src
if (props.loading === 'lazy' || props.fetchPriority === 'low' || !source || !isString(source) || /^data:/i.test(source)) return
collector.add('preload', source, { as: 'image', imageSrcSet: srcSet, imageSizes: props.sizes, crossOrigin: props.crossOrigin, integrity: props.integrity, type: props.type, fetchPriority: props.fetchPriority, referrerPolicy: props.referrerPolicy } as any, true)
},
// Hoist <meta charSet> and <meta name="viewport"> to the start of the head. Content streamed
// after the shell keeps its metas in place.
addMeta(props: any): ((chunk: string) => void) | null {
const isCharset = isString(props.charSet)
return !shellFlushed && (isCharset || props.name === 'viewport') && props.itemProp == null ? chunk => { isCharset ? charset += chunk : viewport += chunk } : null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
// Snapshot the hoisted metas, or restore a snapshot when the content that added them is
// discarded (a suspended boundary, an abandoned shell attempt). Hints stay: they deduplicate.
metas(restore?: string[]): string[] {
const saved = [charset, viewport]
if (restore) [charset, viewport] = restore as [string, string]
return saved
},
addHost(type: string, props: any, scope?: ResourceScope): boolean {
const kind = resourceKind(type, props)
if (!kind) return false
Expand Down Expand Up @@ -127,7 +156,8 @@ export function createResourceHints(shellFlushed = false, styleNonce?: string) {
return result
},
drain(): string {
let output = ''
let output = charset + viewport
charset = viewport = ''
function flush(group: Resource[], precedence?: string) {
let remaining = 0
let inline = false, hasStylesheets = false
Expand Down
2 changes: 2 additions & 0 deletions packages/redact/src/server/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export async function streamHtml(
shellChunks.length = chunkCount
boundaries.length = boundaryCount
state.nextId = nextId
currentSSRFrame().resources?.metas(['', ''])
if (!isThenable(err)) throw err
if (++rootRetries > 50) {
throw new Error('renderToReadableStream exceeded 50 root suspension retries.')
Expand Down Expand Up @@ -221,6 +222,7 @@ function streamBoundary(
onError: options.onError,
componentStack: b.componentStack,
resourceScope,
textState: { bits: b.textBits },
})
state.frame?.resources?.mergeScope(resourceScope)
} catch (err) {
Expand Down
64 changes: 47 additions & 17 deletions packages/redact/src/server/walk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
popContext,
snapshotContexts,
recordHostResource,
recordHoisted,
currentSSRFrame,
type ContextSnapshot,
} from './dispatcher'
Expand All @@ -54,6 +55,8 @@ export interface SuspendedBoundary {
thenable: Promise<any>
contextSnapshot: ContextSnapshot
componentStack: string
// SVG, picture, noscript and document flags of the parent, so a boundary rendered later keeps them.
textBits: number
}

export interface WalkOptions {
Expand All @@ -68,7 +71,8 @@ export interface WalkOptions {
bootstrapped?: boolean | undefined
isBoundaryResolution?: boolean | undefined
resourceScope?: ResourceScope | undefined
// Shared traversal flags: SVG = 1, adjacent text = 2.
// Shared traversal flags: SVG = 1, adjacent text = 2, inside <picture> = 4, inside <noscript> = 8,
// inside <html> = 16.
// Host children restore previous flags on return or throw.
textState?: { bits: number }
}
Expand Down Expand Up @@ -247,6 +251,19 @@ function walkHost(
): void {
const isSvg = !!(opts.textState!.bits & 1) || tag === 'svg'
if (!isSvg && resourceKind(tag, props) && recordHostResource(tag, props, opts.resourceScope)) return
// In a document render React's server renderer preloads the images it renders eagerly and
// opens <head> with the charset and viewport metas. Only document renders do this here, since
// their head is position-insensitive for hydration. Neither applies inside <svg> or <noscript>,
// and images inside <picture> are not preloaded.
const bits = opts.textState!.bits
if (!isSvg && bits & 16 && !(bits & 8) && (tag === 'meta' || tag === 'img' && !(bits & 4))) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const emit = recordHoisted(tag, props)
if (emit) {
// The hoisted meta leaves the body, so keep the text around it apart as React does.
if (bits & 2) opts.emit('<!-- -->')
opts = { ...opts, emit }
}
}
// <textarea value="..."> serializes its value as a TEXT CHILD, not an
// attribute. `defaultValue` is the fallback when `value` is absent. This
// matches React and the HTML spec — `<textarea value="x">` is not valid
Expand Down Expand Up @@ -322,7 +339,7 @@ function walkHost(

if (VOID_ELEMENTS.has(tag)) {
opts.emit('/>')
opts.textState!.bits &= 1
opts.textState!.bits &= 29
return
}
opts.emit('>')
Expand All @@ -342,7 +359,7 @@ function walkHost(
if (isTextarea && textareaValue != null) {
opts.emit(escapeText(String(textareaValue)))
opts.emit(`</${tag}>`)
parentTextState!.bits &= 1
parentTextState!.bits &= 29
return
}

Expand All @@ -360,15 +377,15 @@ function walkHost(
}
}
opts.emit(`</${tag}>`)
parentTextState!.bits &= 1
parentTextState!.bits &= 29
return
}

if (dangerouslyHtml != null) {
opts.emit(String(dangerouslyHtml))
} else {
const previousState = parentTextState!.bits
parentTextState!.bits = +(isSvg && tag !== 'foreignObject')
parentTextState!.bits = +(isSvg && tag !== 'foreignObject') | previousState & 28 | (tag === 'picture' ? 4 : tag === 'noscript' ? 8 : tag === 'html' ? 16 : 0)
try {
walkNode(props.children, opts)
} finally {
Expand All @@ -377,7 +394,7 @@ function walkHost(
}
opts.emit(`</${tag}>`)
// Host element closing resets outer flow — next sibling text starts fresh.
parentTextState!.bits &= 1
parentTextState!.bits &= 29
} finally {
if (isSelect) popSelectContext()
}
Expand Down Expand Up @@ -459,37 +476,51 @@ function walkSuspense(
const nested: SuspendedBoundary[] = []
const thrownInfo: { componentStack?: string | undefined } = {}
const childEmit = (s: string) => childParts.push(s)
// Where <head> opened inside the buffered children, reported to the renderer once they commit.
let head = -1
const resourceScope: ResourceScope | undefined = currentSSRFrame().shellFlushed ? {} : undefined
const frame = currentSSRFrame()
const metas = frame.resources?.metas()
try {
walkNode(props.children, {
...opts,
resourceScope,
thrownInfo,
emit: childEmit,
onHead: () => { head = childParts.length },
onSuspend: opts.onSuspend ? (boundary) => nested.push(boundary) : undefined,
nextBoundaryId: opts.nextBoundaryId,
})
} catch (thenable: any) {
nested.length = 0
frame.resources?.metas(metas || ['', ''])
const suspended = isThenable(thenable)
if (!suspended) {
if (isBrowserError(thenable)) opts.onBrowserBailout?.(thenable, { componentStack: (thenable as any).componentStack || opts.componentStack || '' })
else opts.onError?.(thenable)
}
const fallbackParts: string[] = []
head = -1
walkNode(props.fallback, {
...opts,
emit: (s) => fallbackParts.push(s),
onHead: () => { head = fallbackParts.length },
onSuspend: opts.onSuspend ? (boundary) => nested.push(boundary) : undefined,
nextBoundaryId: opts.nextBoundaryId,
})
if (!suspended) {
opts.emit(`<!--${isBrowserError(thenable) ? '$!' : '$E'}${id}-->${fallbackParts.join('')}<!--/$-->`)
} else emitBoundary(opts, id, fallbackParts.join(''))
// A fallback's charset and viewport metas are omitted, as React omits hoistables from fallbacks.
frame.resources?.metas(metas || ['', ''])
// Visible div wrapper for a pending boundary 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(suspended ? `<!--$?${id}--><div id="B:${id}">` : `<!--${isBrowserError(thenable) ? '$!' : '$E'}${id}-->`)
emitParts(opts, fallbackParts, head)
opts.emit(suspended ? '</div><!--/$-->' : '<!--/$-->')
for (const boundary of nested) opts.onSuspend?.(boundary)
if (suspended) opts.onSuspend?.({
id, fallbackHTML: fallbackParts.join(''), children: props.children, thenable, contextSnapshot,
componentStack: thrownInfo.componentStack || opts.componentStack || '',
textBits: opts.textState!.bits & 29,
})
return
}
Expand All @@ -504,16 +535,15 @@ function walkSuspense(
// producing visible duplicates (e.g. double navbar logos). Markers let the
// client treat this as a resolved boundary and hydrate in-place.
opts.emit(`<!--$${id}-->`)
opts.emit(childParts.join(''))
emitParts(opts, childParts, head)
opts.emit(`<!--/$-->`)
for (const boundary of nested) opts.onSuspend?.(boundary)
}

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}">`)
opts.emit(fallbackHTML)
opts.emit(`</div><!--/$-->`)
// Emit buffered output, reporting where <head> opened in it so the renderer inserts its hints there.
function emitParts(opts: WalkOptions, parts: string[], head: number): void {
if (head < 0) return opts.emit(parts.join(''))
opts.emit(parts.slice(0, head).join(''))
opts.onHead?.()
opts.emit(parts.slice(head).join(''))
}
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],
}

// Approved image preload and head meta hoisting cost on main 0c7274e, [source, built].
// Only the server bundle changes; the client entries are untouched.
const IMAGE_PRELOAD_SIZE_ALLOWANCE = {
'redact/server': [468, 468],
}

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 + (IMAGE_PRELOAD_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
Loading