diff --git a/.changeset/image-preloads.md b/.changeset/image-preloads.md
new file mode 100644
index 0000000..cb38ec2
--- /dev/null
+++ b/.changeset/image-preloads.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/redact': patch
+---
+
+Document renders now preload the images they render eagerly and open `
` with the charset and viewport metas, as React's server renderer does. Every ` ` outside ``, `` and SVG that has a `src` or `srcSet`, is not `loading="lazy"` or `fetchPriority="low"` and is not a `data:` URI gets a ` `; the first ten and every `fetchPriority="high"` image are placed ahead of the stylesheets, the rest after the scripts.
diff --git a/packages/redact/src/server/dispatcher.ts b/packages/redact/src/server/dispatcher.ts
index fe22539..2fc7b6b 100644
--- a/packages/redact/src/server/dispatcher.ts
+++ b/packages/redact/src/server/dispatcher.ts
@@ -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
}
@@ -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 adds its preload. A charset or viewport 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): void {
diff --git a/packages/redact/src/server/resource-hints.ts b/packages/redact/src/server/resource-hints.ts
index 285812a..393cbc9 100644
--- a/packages/redact/src/server/resource-hints.ts
+++ b/packages/redact/src/server/resource-hints.ts
@@ -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'
@@ -56,8 +56,9 @@ export function createResourceHints(shellFlushed = false, styleNonce?: string) {
const styles = new Map()
const dependencies = new Map()
const visibleInline = new Set()
+ 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'
@@ -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 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 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)
@@ -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 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 and 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
+ },
+ // 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
@@ -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
diff --git a/packages/redact/src/server/stream.ts b/packages/redact/src/server/stream.ts
index 694a489..a17fc88 100644
--- a/packages/redact/src/server/stream.ts
+++ b/packages/redact/src/server/stream.ts
@@ -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.')
@@ -221,6 +222,7 @@ function streamBoundary(
onError: options.onError,
componentStack: b.componentStack,
resourceScope,
+ textState: { bits: b.textBits },
})
state.frame?.resources?.mergeScope(resourceScope)
} catch (err) {
diff --git a/packages/redact/src/server/walk.ts b/packages/redact/src/server/walk.ts
index c015f74..a871c3f 100644
--- a/packages/redact/src/server/walk.ts
+++ b/packages/redact/src/server/walk.ts
@@ -34,6 +34,7 @@ import {
popContext,
snapshotContexts,
recordHostResource,
+ recordHoisted,
currentSSRFrame,
type ContextSnapshot,
} from './dispatcher'
@@ -54,6 +55,8 @@ export interface SuspendedBoundary {
thenable: Promise
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 {
@@ -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 = 4, inside = 8,
+ // inside = 16.
// Host children restore previous flags on return or throw.
textState?: { bits: number }
}
@@ -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 with the charset and viewport metas. Only document renders do this here, since
+ // their head is position-insensitive for hydration. Neither applies inside or ,
+ // and images inside are not preloaded.
+ const bits = opts.textState!.bits
+ if (!isSvg && bits & 16 && !(bits & 8) && (tag === 'meta' || tag === 'img' && !(bits & 4))) {
+ 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 }
+ }
+ }
//