From 7aefe4da8f3040273485ef54bff2ebd3ec3c925f Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 11 Apr 2026 16:40:49 +0000 Subject: [PATCH] Fix streaming: resolve scripts inside , live server tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell HTML included closing tags. Resolve script chunks arrived AFTER the document end — browser ignored them (ERR_INCOMPLETE_CHUNKED_ENCODING). Now strips from shell, sends resolve scripts inside the body, closes document last. Added live server Playwright tests that hit the actual streaming endpoint and verify suspense slots resolve with content. Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/ocaml/bin/sx_server.ml | 26 +++++++++++++++--- tests/playwright/streaming.spec.js | 43 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index ca2608a8..f414d425 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -2079,11 +2079,30 @@ let http_render_page_streaming env path _headers fd page_name = in let t1 = Unix.gettimeofday () in - (* Phase 2: Send chunked header + shell HTML *) + (* Phase 2: Send chunked header + shell HTML. + Strip closing from shell — resolve scripts must go INSIDE + the body, otherwise the browser's HTML parser ignores them. *) + let shell_body, shell_tail = + (* Find last and split there *) + let s = shell_html in + let body_close = "" in + let rec find_last i found = + if i < 0 then found + else if i + String.length body_close <= String.length s + && String.sub s i (String.length body_close) = body_close + then find_last (i - 1) i + else find_last (i - 1) found + in + let pos = find_last (String.length s - String.length body_close) (-1) in + if pos >= 0 then + (String.sub s 0 pos, String.sub s pos (String.length s - pos)) + else + (s, "") + in let header = http_chunked_header () in let header_bytes = Bytes.of_string header in (try ignore (Unix.write fd header_bytes 0 (Bytes.length header_bytes)) with _ -> ()); - write_chunk fd shell_html; + write_chunk fd shell_body; (* Bootstrap resolve script — must come after shell so suspense elements exist *) write_chunk fd _sx_streaming_bootstrap; let t2 = Unix.gettimeofday () in @@ -2170,7 +2189,8 @@ let http_render_page_streaming env path _headers fd page_name = end else Printf.eprintf "[sx-stream] %s shell=%.3fs (no :data/:content)\n%!" path (t1 -. t0); - (* Phase 4: End chunked response *) + (* Phase 4: Send closing tags + end chunked response *) + if shell_tail <> "" then write_chunk fd shell_tail; end_chunked fd (* ====================================================================== *) diff --git a/tests/playwright/streaming.spec.js b/tests/playwright/streaming.spec.js index e84fde61..6d58dcf4 100644 --- a/tests/playwright/streaming.spec.js +++ b/tests/playwright/streaming.spec.js @@ -386,3 +386,46 @@ test.describe('Streaming sandbox', () => { await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('~5s'); }); }); + +// ========================================================================= +// Live server tests — verify the actual chunked response works end-to-end +// ========================================================================= +const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; + +test.describe('Streaming live server', () => { + test.describe.configure({ timeout: 30000 }); + + test('streaming page resolves suspense slots from server', async ({ page }) => { + // Load the streaming page from the actual server + await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', { + waitUntil: 'networkidle', + timeout: 20000, + }); + + // Should have 3 suspense slots + await expect(page.locator('[data-suspense]')).toHaveCount(3); + + // All 3 slots should have resolved content (not empty, not just skeletons) + await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source', { timeout: 15000 }); + await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source', { timeout: 15000 }); + await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow source', { timeout: 15000 }); + }); + + test('no chunked encoding errors in console', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + page.on('console', msg => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', { + waitUntil: 'networkidle', + timeout: 20000, + }); + + // Filter for chunked encoding errors specifically + const chunkErrors = errors.filter(e => + e.includes('CHUNKED') || e.includes('INCOMPLETE') || e.includes('ERR_EMPTY')); + expect(chunkErrors).toEqual([]); + }); +});