From 4b486b324abace054becb6b006692ac3c71610e1 Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Mon, 27 Jul 2026 18:11:22 +0200 Subject: [PATCH 1/3] test: pin behavior for arbitrary chunk boundaries and ", + ], + }); + const executed = await page.evaluate( + () => (window as any).__executed === true, + ); + + expect(newHTML).toBe( + normalize(` + + + +
foo
+ + + + `), + ); + expect(executed).toBeFalse(); + }); + + it("should NOT re-execute a pre-existing
foo
`, + newHTMLStringChunks: [ + "", + "
bar
", + ], + }); + const count = await page.evaluate(() => (window as any).__count); + + expect(newHTML).toBe( + normalize(` + + + + + +
bar
+ + + `), + ); + // Executed exactly once (on initial page load), not again after diffing. + expect(count).toBe(1); + }); }); async function testDiff({ oldHTMLString, - newHTMLStringChunks, + newHTMLStringChunks = [], + newHTMLByteChunks, useForEeachStreamNode = false, slowChunks = false, transition = false, @@ -1778,7 +1913,11 @@ describe("Diff test", () => { onNextNode, }: { oldHTMLString: string; - newHTMLStringChunks: string[]; + newHTMLStringChunks?: string[]; + // Raw byte chunks (each an array of UTF-8 byte values) to test chunk + // boundaries that split multi-byte characters, which cannot be expressed + // as JS strings. + newHTMLByteChunks?: number[][]; useForEeachStreamNode?: boolean; slowChunks?: boolean; transition?: boolean; @@ -1791,6 +1930,7 @@ describe("Diff test", () => { async ([ diffCode, newHTMLStringChunks, + newHTMLByteChunks, useForEeachStreamNode, slowChunks, transition, @@ -1800,12 +1940,18 @@ describe("Diff test", () => { ]) => { eval(diffCode as string); const encoder = new TextEncoder(); + const byteChunks = newHTMLByteChunks as number[][] | undefined; + const chunks = byteChunks ?? (newHTMLStringChunks as string[]); const readable = new ReadableStream({ start: async (controller) => { - for (const chunk of newHTMLStringChunks as string[]) { + for (const chunk of chunks) { if (slowChunks) await new Promise((resolve) => setTimeout(resolve, 100)); - controller.enqueue(encoder.encode(chunk)); + controller.enqueue( + byteChunks + ? new Uint8Array(chunk as number[]) + : encoder.encode(chunk as string), + ); } controller.close(); }, @@ -1886,6 +2032,7 @@ describe("Diff test", () => { [ diffCode, newHTMLStringChunks, + newHTMLByteChunks, useForEeachStreamNode, slowChunks, transition, From 105ea5963adbbf57c60f2c2851ac8c1c1c49c055 Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Mon, 27 Jul 2026 18:12:05 +0200 Subject: [PATCH 2/3] fix: reject the diff when the stream errors instead of hanging the walker If the input stream errored (aborted fetch, dead network), the reader rejection escaped as an unhandled rejection, streamInProgress stayed true forever and the walker kept waiting on requestAnimationFrame: diff() never resolved nor rejected. Now the error is captured, the stream is marked as finished on both paths, and diff() rejects with the original error. Co-Authored-By: Claude Fable 5 --- deno.json | 2 +- package.json | 2 +- src/index.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++- src/index.ts | 22 ++++++++++++++++------ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/deno.json b/deno.json index a525351..a9cf7f3 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@aralroca/diff-dom-streaming", - "version": "0.6.6", + "version": "0.6.8", "exports": { ".": "./src/index.ts", "./types": "./index.d.ts" diff --git a/package.json b/package.json index 23604ae..299b9ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diff-dom-streaming", - "version": "0.6.7", + "version": "0.6.8", "bugs": "https://github.com/aralroca/diff-dom-streaming/issues", "description": "Diff DOM algorithm with streaming. Gets all modifications, insertions and removals between a DOM fragment and a stream HTML reader.", "keywords": [ diff --git a/src/index.test.ts b/src/index.test.ts index 5c6ca00..d1d6235 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1899,12 +1899,38 @@ describe("Diff test", () => { // Executed exactly once (on initial page load), not again after diffing. expect(count).toBe(1); }); + + it("should reject the diff when the stream errors after a chunk instead of hanging the walker", async () => { + await expect( + testDiff({ + oldHTMLString: ` +
foo
+ `, + newHTMLStringChunks: ["
bar
"], + errorStreamMessage: "network dead", + slowChunks: true, + }), + ).rejects.toThrow("network dead"); + }); + + it("should reject the diff when the stream errors before any chunk", async () => { + await expect( + testDiff({ + oldHTMLString: ` +
foo
+ `, + newHTMLStringChunks: [], + errorStreamMessage: "network dead", + }), + ).rejects.toThrow("network dead"); + }); }); async function testDiff({ oldHTMLString, newHTMLStringChunks = [], newHTMLByteChunks, + errorStreamMessage, useForEeachStreamNode = false, slowChunks = false, transition = false, @@ -1918,6 +1944,9 @@ describe("Diff test", () => { // boundaries that split multi-byte characters, which cannot be expressed // as JS strings. newHTMLByteChunks?: number[][]; + // When set, the stream errors with this message after emitting all chunks + // instead of closing (simulates an aborted fetch / dead network). + errorStreamMessage?: string; useForEeachStreamNode?: boolean; slowChunks?: boolean; transition?: boolean; @@ -1931,6 +1960,7 @@ describe("Diff test", () => { diffCode, newHTMLStringChunks, newHTMLByteChunks, + errorStreamMessage, useForEeachStreamNode, slowChunks, transition, @@ -1953,7 +1983,15 @@ describe("Diff test", () => { : encoder.encode(chunk as string), ); } - controller.close(); + if (errorStreamMessage) { + // Let the already-enqueued chunks be consumed by the walker + // first, so the error lands while it awaits the next chunk. + if (slowChunks) + await new Promise((resolve) => setTimeout(resolve, 100)); + controller.error(new Error(errorStreamMessage as string)); + } else { + controller.close(); + } }, }); const allMutations: any[] = []; @@ -2033,6 +2071,7 @@ describe("Diff test", () => { diffCode, newHTMLStringChunks, newHTMLByteChunks, + errorStreamMessage, useForEeachStreamNode, slowChunks, transition, diff --git a/src/index.ts b/src/index.ts index e8c28da..1db3cdf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -282,30 +282,38 @@ async function htmlStreamWalker( const decoderStream = new TextDecoderStream(); const decoderStreamReader = decoderStream.readable.getReader(); let streamInProgress = true; + let streamError: Error | undefined; - stream.pipeTo(decoderStream.writable); + // The error already surfaces through the reader in processStream; this + // only silences the duplicated unhandled rejection. + stream.pipeTo(decoderStream.writable).catch(() => {}); processStream(); async function processStream() { try { while (true) { const { done, value } = await decoderStreamReader.read(); - if (done) { - streamInProgress = false; - break; - } + if (done) break; doc.write(value); } + } catch (error) { + streamError = error as Error; } finally { + streamInProgress = false; doc.close(); } } - while (!doc.documentElement || isLastNodeOfChunk(doc.documentElement)) { + while ( + !streamError && + (!doc.documentElement || isLastNodeOfChunk(doc.documentElement)) + ) { await wait(); } + if (streamError) throw streamError; + const visited = new WeakSet(); function next(field: "firstChild" | "nextSibling") { @@ -331,6 +339,8 @@ async function htmlStreamWalker( await wait(); } + if (streamError) throw streamError; + return nextNode; }; } From b7badd15d65cc88aa9269150b107260a4327c471 Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Mon, 27 Jul 2026 18:12:25 +0200 Subject: [PATCH 3/3] fix: apply children of an open last node instead of stalling the whole body When the streamed content hangs off the LAST child of a still-open container (the common
after