Skip to content

Commit 8a01fdf

Browse files
authored
Fix frame stream reload batching (#11353)
1 parent 81878f7 commit 8a01fdf

7 files changed

Lines changed: 251 additions & 63 deletions

File tree

packages/ui/src/runtime/frame.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Scheduler, VirtualRoot } from './vdom.ts'
88
import { createRangeRoot, createRoot } from './vdom.ts'
99
import { diffNodes } from './diff-dom.ts'
1010
import { createStyleManager, type StyleManager } from '../style/index.ts'
11+
import { findFlushMarker, type FlushKind } from './stream-protocol.ts'
1112

1213
type FrameRoot = [Comment, Comment] | Element | Document | DocumentFragment
1314

@@ -64,10 +65,6 @@ function stripDoctypeMarkup(html: string): string {
6465
return html.replace(DOCTYPE_PATTERN, '')
6566
}
6667

67-
function hasRenderableHtml(html: string): boolean {
68-
return stripDoctypeMarkup(html).trim() !== ''
69-
}
70-
7168
function syncElementAttributes(target: Element, source: Element) {
7269
for (let attribute of Array.from(target.attributes)) {
7370
if (!source.hasAttribute(attribute.name)) {
@@ -142,6 +139,7 @@ export type Frame = {
142139
}
143140

144141
type RenderOptions = {
142+
flushKind?: FlushKind
145143
initialHydrationTracker?: InitialHydrationTracker
146144
signal?: AbortSignal
147145
}
@@ -215,9 +213,9 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
215213
if (options?.signal?.aborted) return
216214

217215
if (content instanceof ReadableStream) {
218-
await renderFrameStream(content, container.doc, async (html) => {
216+
await renderFrameStream(content, container.doc, async (html, flushKind) => {
219217
if (options?.signal?.aborted) return
220-
await render(html, options)
218+
await render(html, { ...options, flushKind })
221219
})
222220
return
223221
}
@@ -241,12 +239,24 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
241239
contentRoot = undefined
242240
}
243241

242+
if (typeof content === 'string') {
243+
let flushed = await consumeFlushBatches(content, async (html, flushKind) => {
244+
await render(html, { ...options, flushKind })
245+
})
246+
if (flushed.applied) {
247+
if (flushed.remainder !== '') {
248+
await render(flushed.remainder, { ...options, flushKind: 'fragment' })
249+
}
250+
return
251+
}
252+
}
253+
244254
let htmlContent = typeof content === 'string' ? stripDoctypeMarkup(content) : undefined
245255

246256
let isFullDocumentReload =
247257
container.root instanceof Document &&
248258
htmlContent !== undefined &&
249-
isFullDocumentHtml(htmlContent)
259+
options?.flushKind === 'document'
250260

251261
if (isFullDocumentReload && htmlContent !== undefined) {
252262
let parsed = new DOMParser().parseFromString(htmlContent, 'text/html')
@@ -982,13 +992,12 @@ function extractTemplatesFromBuffer(
982992
async function renderFrameStream(
983993
stream: ReadableStream<Uint8Array>,
984994
doc: Document,
985-
applyHtml: (html: string) => Promise<void>,
995+
applyHtml: (html: string, flushKind: FlushKind) => Promise<void>,
986996
): Promise<void> {
987997
let reader = stream.getReader()
988998
let decoder = new TextDecoder()
989999
let buffer = ''
9901000
let html = ''
991-
let appliedLength = 0
9921001
let appliedOnce = false
9931002

9941003
try {
@@ -1002,19 +1011,9 @@ async function renderFrameStream(
10021011

10031012
if (parsed.html !== '') {
10041013
html += parsed.html
1005-
// A doctype or whitespace prelude can arrive in its own chunk. Wait
1006-
// until there is actual frame content before applying the stream.
1007-
if (!hasRenderableHtml(html)) {
1008-
continue
1009-
}
1010-
1011-
let htmlMarkers = collectHtmlMarkerSummary(html)
1012-
if (!hasBalancedMarkerSummary(htmlMarkers)) {
1013-
continue
1014-
}
1015-
await applyHtml(html)
1016-
appliedLength = html.length
1017-
appliedOnce = true
1014+
let flushed = await consumeFlushBatches(html, applyHtml)
1015+
appliedOnce = flushed.applied || appliedOnce
1016+
html = flushed.remainder
10181017
}
10191018
}
10201019

@@ -1027,23 +1026,40 @@ async function renderFrameStream(
10271026
buffer = ''
10281027
}
10291028

1030-
let hasHtmlToApply = hasRenderableHtml(html)
1031-
1032-
if (hasHtmlToApply && html.length > appliedLength) {
1033-
await applyHtml(html)
1029+
if (html !== '') {
1030+
await applyHtml(html, 'fragment')
10341031
appliedOnce = true
10351032
}
10361033

10371034
// A frame stream can legitimately resolve to empty content. Ensure the
10381035
// existing frame region is cleared instead of treated as a no-op.
1039-
if (!hasHtmlToApply && !appliedOnce) {
1040-
await applyHtml('')
1036+
if (html === '' && !appliedOnce) {
1037+
await applyHtml('', 'fragment')
10411038
}
10421039
} finally {
10431040
reader.releaseLock()
10441041
}
10451042
}
10461043

1044+
async function consumeFlushBatches(
1045+
html: string,
1046+
applyHtml: (html: string, flushKind: FlushKind) => Promise<void>,
1047+
): Promise<{ applied: boolean; remainder: string }> {
1048+
let applied = false
1049+
let cursor = 0
1050+
let marker = findFlushMarker(html, cursor)
1051+
1052+
while (marker) {
1053+
let batch = html.slice(cursor, marker.index)
1054+
await applyHtml(batch, marker.kind)
1055+
applied = true
1056+
cursor = marker.endIndex
1057+
marker = findFlushMarker(html, cursor)
1058+
}
1059+
1060+
return { applied, remainder: html.slice(cursor) }
1061+
}
1062+
10471063
type FrameContainer = {
10481064
doc: Document
10491065
root: ParentNode
@@ -1108,11 +1124,6 @@ function isRemixNodeFrameContent(content: InternalFrameContent): content is Remi
11081124
)
11091125
}
11101126

1111-
function isFullDocumentHtml(content: string): boolean {
1112-
let trimmed = content.trimStart()
1113-
return /^<!doctype html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)
1114-
}
1115-
11161127
type HydrationMarker = {
11171128
id: string
11181129
start: Comment
@@ -1206,17 +1217,3 @@ function findEndMarker(
12061217
throw new Error('End marker not found')
12071218
}
12081219

1209-
function collectHtmlMarkerSummary(html: string): Record<string, number> {
1210-
return {
1211-
frameStarts: html.match(/<!--\s*rmx:f:/g)?.length ?? 0,
1212-
frameEnds: html.match(/<!--\s*\/rmx:f\s*-->/g)?.length ?? 0,
1213-
hydrationStarts: html.match(/<!--\s*rmx:h:/g)?.length ?? 0,
1214-
hydrationEnds: html.match(/<!--\s*\/rmx:h\s*-->/g)?.length ?? 0,
1215-
}
1216-
}
1217-
1218-
function hasBalancedMarkerSummary(summary: Record<string, number>): boolean {
1219-
return (
1220-
summary.frameStarts === summary.frameEnds && summary.hydrationStarts === summary.hydrationEnds
1221-
)
1222-
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export type FlushKind = 'document' | 'fragment'
2+
3+
const FLUSH_MARKER_PATTERN = /<!--\s*rmx:flush\s+(document|fragment)\s*-->/g
4+
5+
export function appendFlushMarker(html: string, kind: FlushKind): string {
6+
return `${html}<!-- rmx:flush ${kind} -->`
7+
}
8+
9+
export function stripFlushMarkers(html: string): string {
10+
FLUSH_MARKER_PATTERN.lastIndex = 0
11+
return html.replace(FLUSH_MARKER_PATTERN, '')
12+
}
13+
14+
export function findFlushMarker(
15+
html: string,
16+
startIndex: number,
17+
): { index: number; endIndex: number; kind: FlushKind } | undefined {
18+
FLUSH_MARKER_PATTERN.lastIndex = startIndex
19+
let match = FLUSH_MARKER_PATTERN.exec(html)
20+
if (!match) return undefined
21+
22+
return {
23+
index: match.index,
24+
endIndex: FLUSH_MARKER_PATTERN.lastIndex,
25+
kind: match[1] as FlushKind,
26+
}
27+
}

packages/ui/src/server/stream.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ElementType, ElementProps, RemixElement } from '../runtime/jsx.ts'
33
import { Fragment, createComponent, createFrameHandle, Frame } from '../runtime/component.ts'
44
import { isEntry, type EntryComponent } from '../runtime/client-entries.ts'
55
import { normalizeSvgAttribute } from '../runtime/svg-attributes.ts'
6+
import { appendFlushMarker, type FlushKind, stripFlushMarkers } from '../runtime/stream-protocol.ts'
67

78
interface VNode {
89
type: ElementType
@@ -90,6 +91,7 @@ interface RenderContext {
9091
unresolvedHydrationData: Map<string, UnresolvedHydrationData>
9192
frameData: Map<string, FrameData>
9293
blockingFrameTails: ReadableStream<Uint8Array>[]
94+
flushKind: FlushKind
9395
serverIdScope: string
9496
serverIdCounter: number
9597
}
@@ -230,6 +232,7 @@ export function renderToStream(
230232
unresolvedHydrationData: new Map(),
231233
frameData: new Map(),
232234
blockingFrameTails: [],
235+
flushKind: 'fragment',
233236
serverIdScope: crypto.randomUUID().slice(0, 8),
234237
serverIdCounter: 0,
235238
}
@@ -243,7 +246,7 @@ export function renderToStream(
243246
validateClientEntriesForHydration(context)
244247
let html = serializeSegment(root)
245248
let finalHtml = finalizeHtml(html, context)
246-
let bytes = encoder.encode(finalHtml)
249+
let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind))
247250
controller.enqueue(bytes)
248251

249252
// If we have any tails from blocking frame streams, stream them now.
@@ -350,13 +353,13 @@ async function splitFirstChunk(stream: ReadableStream<Uint8Array>): Promise<Reso
350353
},
351354
})
352355

353-
return { html: stripDoctypeMarkup(decoder.decode(first)), tail }
356+
return { html: stripFlushMarkers(stripDoctypeMarkup(decoder.decode(first))), tail }
354357
}
355358

356359
async function resolveFrameHtml(
357360
input: string | ReadableStream<Uint8Array>,
358361
): Promise<ResolvedFrameHtml> {
359-
if (typeof input === 'string') return { html: stripDoctypeMarkup(input) }
362+
if (typeof input === 'string') return { html: stripFlushMarkers(stripDoctypeMarkup(input)) }
360363

361364
return await splitFirstChunk(input)
362365
}
@@ -399,6 +402,7 @@ function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFr
399402
let tag = type
400403

401404
if (tag === 'html') {
405+
context.flushKind = 'document'
402406
return buildElementSegment(tag, props, context, frameState)
403407
}
404408

@@ -812,7 +816,11 @@ function buildComponentSegment(
812816
let [renderedNode] = handle.render(props)
813817
let childContext = { ...context, parentVNode: vnode }
814818

815-
return buildSegment(renderedNode, childContext, frameState)
819+
let rendered = buildSegment(renderedNode, childContext, frameState)
820+
if (childContext.flushKind === 'document') {
821+
context.flushKind = 'document'
822+
}
823+
return rendered
816824
}
817825

818826
function createHydrationPropsReplacer(context: RenderContext, frameState: SsrFrameState) {
@@ -1094,7 +1102,7 @@ function transformAttributeName(name: string, isSvg: boolean): string {
10941102
}
10951103

10961104
function finalizeHtml(html: string, context: RenderContext): string {
1097-
let hasHtmlRoot = html.trimStart().toLowerCase().startsWith('<html')
1105+
let hasHtmlRoot = context.flushKind === 'document'
10981106

10991107
let styles = collectStyleTags(context)
11001108
if (styles) {
@@ -1351,11 +1359,13 @@ async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
13511359
* @returns Rendered HTML.
13521360
*/
13531361
export async function renderToString(node: RemixNode): Promise<string> {
1354-
return drain(
1355-
renderToStream(node, {
1356-
onError(error) {
1357-
throw error
1358-
},
1359-
}),
1362+
return stripFlushMarkers(
1363+
await drain(
1364+
renderToStream(node, {
1365+
onError(error) {
1366+
throw error
1367+
},
1368+
}),
1369+
),
13601370
)
13611371
}

0 commit comments

Comments
 (0)