diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4f3968..d001884 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - run: bun run build - run: bun x prettier --check . - run: bun x tsc --noEmit - - run: bun test + - run: bun run test e2e-oob: runs-on: ubuntu-latest diff --git a/index.test.ts b/index.test.ts index add9c9a..6ccd377 100644 --- a/index.test.ts +++ b/index.test.ts @@ -65,6 +65,8 @@ describe("chunked-transfer extension", () => { const mockXhr = { getResponseHeader: (header: string) => { if (header === "Transfer-Encoding") return null; + // Non-chunked responses have Content-Length + if (header === "Content-Length") return "22"; return null; }, response: "

Normal response

", @@ -87,6 +89,34 @@ describe("chunked-transfer extension", () => { expect(target.innerHTML).toBe(""); }); + test("processes HTTP/2 streaming responses (no Transfer-Encoding or Content-Length)", () => { + const element = document.createElement("div"); + const mockXhr = { + getResponseHeader: (header: string) => { + // HTTP/2 streaming: no Transfer-Encoding, no Content-Length + return null; + }, + response: "

HTTP/2 streamed content

", + onprogress: null as any, + }; + + const event = { + target: element, + detail: { xhr: mockXhr }, + }; + + registeredExtension.onEvent("htmx:beforeRequest", event); + + // Verify onprogress handler was set + expect(mockXhr.onprogress).toBeDefined(); + + // Simulate progress event + mockXhr.onprogress!(); + + // Verify content was swapped + expect(target.innerHTML).toBe("

HTTP/2 streamed content

"); + }); + test("ignores events other than beforeRequest", () => { const element = document.createElement("div"); const mockXhr = { @@ -576,7 +606,8 @@ describe("chunked-transfer extension", () => { const mockXhr = { getResponseHeader: (header: string) => { - // NOT chunked + // NOT chunked - has Content-Length + if (header === "Content-Length") return "15"; return null; }, response: "

Final

", diff --git a/index.ts b/index.ts index 02f0391..d742d5b 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,15 @@ declare const htmx: typeof htmxType; (function () { let api: HtmxApi; + // Helper function to detect chunked transfer (HTTP/1.1) or streaming (HTTP/2) + function isChunkedTransfer(xhr: XMLHttpRequest): boolean { + const te = xhr.getResponseHeader("Transfer-Encoding"); + const cl = xhr.getResponseHeader("Content-Length"); + const isHttp1Chunked = te === "chunked"; + const isStreamingWithoutLength = !te && !cl; // typical HTTP/2 streaming + return isHttp1Chunked || isStreamingWithoutLength; + } + htmx.defineExtension("chunked-transfer", { init: function (apiRef: HtmxApi) { api = apiRef; @@ -22,14 +31,15 @@ declare const htmx: typeof htmxType; if (name === "htmx:beforeRequest") { const xhr = evt.detail.xhr as XMLHttpRequest; - (xhr as any)._chunkedMode = elt.getAttribute("hx-chunked-mode") || "append"; + (xhr as any)._chunkedMode = + elt.getAttribute("hx-chunked-mode") || "append"; (xhr as any)._chunkedLastLen = 0; xhr.onprogress = function () { - const is_chunked = - xhr.getResponseHeader("Transfer-Encoding") === "chunked"; + if (!isChunkedTransfer(xhr)) return; - if (!is_chunked) return; + const swapSpec = api.getSwapSpecification(elt); + if (swapSpec.swapStyle !== "innerHTML") return; const mode = (xhr as any)._chunkedMode || "append"; const full = (xhr.response as string) ?? ""; @@ -54,7 +64,6 @@ declare const htmx: typeof htmxType; response = extension.transformResponse(response, xhr, elt); }); - const swapSpec = api.getSwapSpecification(elt); const settleInfo = api.makeSettleInfo(elt); if (api.swap) { @@ -82,9 +91,7 @@ declare const htmx: typeof htmxType; const mode = (xhr as any)._chunkedMode; if (mode !== "swap") return; - const is_chunked = - xhr.getResponseHeader("Transfer-Encoding") === "chunked"; - if (!is_chunked) return; + if (!isChunkedTransfer(xhr)) return; detail.shouldSwap = false; } @@ -131,13 +138,13 @@ interface SwapSpec { } interface SwapOptions { - select: string; - selectOOB: string; - eventInfo: Object; - anchor: Element; - contextElement: Element; - afterSwapCallback: () => void; - afterSettleCallback: () => void; + select?: string; + selectOOB?: string; + eventInfo?: Object; + anchor?: Element; + contextElement?: Element; + afterSwapCallback?: () => void; + afterSettleCallback?: () => void; } interface SettleInfo {