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
16 changes: 16 additions & 0 deletions e2e/react-start/basic/tests/streaming.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ test('Directly visiting the deferred route', async ({ page }) => {
)
})

test('deferred route streams boundaries independently', async ({ page }) => {
await page.goto('/deferred', { waitUntil: 'commit' })

await expect(page.getByTestId('regular-person')).toContainText('John Doe')
await expect(page.getByText('Loading person...')).toBeVisible()
await expect(page.getByText('Loading stuff...')).toBeVisible()

await expect(page.getByTestId('deferred-person')).toContainText(
'Tanner Linsley',
)
await expect(page.getByText('Loading stuff...')).toBeVisible()
await expect(page.getByTestId('deferred-stuff')).toContainText(
'Hello deferred!',
)
})

test('streaming loader data', async ({ page }) => {
await page.goto('/stream')

Expand Down
16 changes: 16 additions & 0 deletions e2e/solid-start/basic/tests/streaming.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ test('Directly visiting the deferred route', async ({ page }) => {
)
})

test('deferred route streams boundaries independently', async ({ page }) => {
await page.goto('/deferred', { waitUntil: 'commit' })

await expect(page.getByTestId('regular-person')).toContainText('John Doe')
await expect(page.getByText('Loading person...')).toBeVisible()
await expect(page.getByText('Loading stuff...')).toBeVisible()

await expect(page.getByTestId('deferred-person')).toContainText(
'Tanner Linsley',
)
await expect(page.getByText('Loading stuff...')).toBeVisible()
await expect(page.getByTestId('deferred-stuff')).toContainText(
'Hello deferred!',
)
})
Comment on lines +31 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skip this SSR-streaming assertion in SPA mode.

This test exercises commit-time streamed HTML behavior, so it has the same SPA-mode constraint as the existing SSR fallback test later in this file. Without the guard, the Solid suite can fail in SPA mode even though the app is behaving correctly.

💡 Suggested fix
 test('deferred route streams boundaries independently', async ({ page }) => {
+  test.skip(isSpaMode, 'SPA mode does not render streamed SSR HTML')
   await page.goto('/deferred', { waitUntil: 'commit' })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/solid-start/basic/tests/streaming.spec.ts` around lines 31 - 45, The
SSR-streaming specific assertions in the test 'deferred route streams boundaries
independently' should be skipped when running in SPA mode: wrap the block of
expectations that assert commit-time streamed HTML (the checks for "Loading
person...", "Loading stuff...", and the deferred element contents like
page.getByTestId('deferred-person') and page.getByTestId('deferred-stuff')) in a
guard that only runs when not in SPA mode (e.g., if (!isSpaMode()) { ... }) or
conditionally call test.skip when isSpaMode() is true; reuse or add a project
helper named isSpaMode() (or the existing SPA-mode detection utility) to detect
SPA runs and ensure the streaming assertions are bypassed in SPA mode.


test('streaming loader data', async ({ page }) => {
await page.goto('/stream')

Expand Down
81 changes: 76 additions & 5 deletions packages/router-core/src/ssr/transformStreamWithRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,24 @@ function findHtmlBoundary(str: string): number {
return lastClosingTagEnd
}

function findHtmlEndTagEnd(str: string, searchFrom: number): number {
for (let i = searchFrom; i <= str.length - 7; i++) {
if (
str.charCodeAt(i) === 60 &&
str.charCodeAt(i + 1) === 47 &&
(str.charCodeAt(i + 2) | 32) === 104 &&
(str.charCodeAt(i + 3) | 32) === 116 &&
(str.charCodeAt(i + 4) | 32) === 109 &&
(str.charCodeAt(i + 5) | 32) === 108 &&
str.charCodeAt(i + 6) === 62
) {
return i + 7
}
}

return -1
}

/**
* Releasing the lock can throw if a pending read is still settling or if the
* lock was already released.
Expand Down Expand Up @@ -522,6 +540,7 @@ function makeMainStream(
clearPendingRouterHtml()
leftover = ''
pendingTail = ''
pendingTailComplete = false
clearPending()

if (cancelReader) {
Expand Down Expand Up @@ -551,8 +570,11 @@ function makeMainStream(
// between-chunk text buffer; keep bounded to avoid unbounded memory
let leftover = ''

// captured bytes from </body> onward; must stay behind router scripts.
// Captured closing tags that must stay after router-injected scripts.
// Some renderers, like Solid, continue streaming boundary chunks after
// </html>; those chunks should still pass through before these tags.
let pendingTail = ''
let pendingTailComplete = false

let streamBarrierLifted = false
let streamBarrierMarkerSeen = false
Expand Down Expand Up @@ -785,26 +807,75 @@ function makeMainStream(

const chunkString = leftover ? leftover + text : text

// If we already saw </body>, everything else is tail. Keep it bounded
// and held until router scripts are ready so injection remains before </body>.
// If we've captured the closing tags, keep streaming subsequent app
// chunks before those tags instead of buffering them until render end.
if (state >= MergeState.HoldingTail) {
appendTail(chunkString)
if (!pendingTailComplete) {
const htmlEndTagEnd = findHtmlEndTagEnd(chunkString, 0)
if (htmlEndTagEnd === -1) {
appendTail(chunkString)
leftover = ''
continue
}

appendTail(chunkString.slice(0, htmlEndTagEnd))
pendingTailComplete = true

const afterClosingTags = chunkString.slice(htmlEndTagEnd)
flushPendingRouterHtml()
if (afterClosingTags) {
writeChunk(afterClosingTags)
if (cleanedUp || isDone()) return
noteBarrierMarker(afterClosingTags)
liftBarrierAfterBoundary()
if (cleanedUp || isDone()) return
flushPendingRouterHtml()
}

leftover = ''
continue
Comment on lines +813 to +836

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle </html> split across chunk boundaries.

Once HoldingTail starts, this scan only looks at the current chunkString. If the previous tail ended with something like </ht and the next chunk starts with ml>, the closing tag is never detected, so pendingTailComplete stays false and the tail keeps buffering until EOF.

💡 Suggested fix
         if (state >= MergeState.HoldingTail) {
           if (!pendingTailComplete) {
-            const htmlEndTagEnd = findHtmlEndTagEnd(chunkString, 0)
+            const overlapChars = Math.min(6, pendingTail.length)
+            const overlap = pendingTail.slice(-overlapChars) + chunkString
+            const htmlEndTagEnd = findHtmlEndTagEnd(overlap, 0)
             if (htmlEndTagEnd === -1) {
               appendTail(chunkString)
               leftover = ''
               continue
             }
 
-            appendTail(chunkString.slice(0, htmlEndTagEnd))
+            const splitIndex = Math.max(0, htmlEndTagEnd - overlapChars)
+            appendTail(chunkString.slice(0, splitIndex))
             pendingTailComplete = true
 
-            const afterClosingTags = chunkString.slice(htmlEndTagEnd)
+            const afterClosingTags = chunkString.slice(splitIndex)
             flushPendingRouterHtml()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router-core/src/ssr/transformStreamWithRouter.ts` around lines 813 -
836, The HTML end-tag detection fails when the tail is split across chunks
(e.g., previous leftover ends with "</ht" and current chunk begins "ml>")
because the code only calls findHtmlEndTagEnd on chunkString; change the logic
in the pending-tail branch to search the concatenation of the buffered
tail/leftover plus the current chunk (use the same findHtmlEndTagEnd on leftover
+ chunkString), adjust appendTail and slicing indices to account for the
combined string (so appendTail receives only the part up to htmlEndTagEnd from
the combined string, and afterClosingTags is the remainder from the chunk
portion), and ensure leftover is cleared/updated correctly so
flushPendingRouterHtml, noteBarrierMarker and subsequent writeChunk flows behave
the same as before.

}

flushPendingRouterHtml()
writeChunk(chunkString)
if (cleanedUp || isDone()) return
noteBarrierMarker(chunkString)
liftBarrierAfterBoundary()
if (cleanedUp || isDone()) return
flushPendingRouterHtml()
leftover = ''
continue
}

const boundary = findHtmlBoundary(chunkString)
if (boundary < -1) {
const bodyEndIndex = -boundary - 2
const htmlEndTagEnd = findHtmlEndTagEnd(chunkString, bodyEndIndex)
state = MergeState.HoldingTail
appendTail(chunkString.slice(bodyEndIndex))
if (htmlEndTagEnd === -1) {
appendTail(chunkString.slice(bodyEndIndex))
} else {
appendTail(chunkString.slice(bodyEndIndex, htmlEndTagEnd))
pendingTailComplete = true
}
const bodyChunk = chunkString.slice(0, bodyEndIndex)
writeChunk(bodyChunk)
if (cleanedUp || isDone()) return
noteBarrierMarker(bodyChunk)
liftBarrierAfterBoundary()
if (cleanedUp || isDone()) return
flushPendingRouterHtml()
if (htmlEndTagEnd !== -1) {
const afterClosingTags = chunkString.slice(htmlEndTagEnd)
if (afterClosingTags) {
writeChunk(afterClosingTags)
if (cleanedUp || isDone()) return
noteBarrierMarker(afterClosingTags)
liftBarrierAfterBoundary()
if (cleanedUp || isDone()) return
flushPendingRouterHtml()
}
}
leftover = ''
continue
}
Expand Down
3 changes: 0 additions & 3 deletions packages/solid-router/src/awaited.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ export function Await<T>(
const [resource] = Solid.createResource(
() => defer(props.promise),
(p) => p,
{
deferStream: true,
},
)

return (
Expand Down
Loading