@@ -232,4 +232,206 @@ describe("chunked-transfer extension", () => {
232232 expect ( target . innerHTML ) . toBe ( "<div>transformed</div><p>content</p>" ) ;
233233 } ) ;
234234 } ) ;
235+
236+ describe ( "hx-chunked-mode=swap" , ( ) => {
237+ test ( "default mode (append) - accumulates all chunks" , ( ) => {
238+ const element = document . createElement ( "div" ) ;
239+ // No hx-chunked-mode attribute = defaults to append
240+
241+ const mockXhr = {
242+ getResponseHeader : ( header : string ) => {
243+ if ( header === "Transfer-Encoding" ) return "chunked" ;
244+ return null ;
245+ } ,
246+ response : "" ,
247+ onprogress : null as any ,
248+ _chunkedMode : undefined ,
249+ _chunkedLastLen : 0 ,
250+ } ;
251+
252+ const event = {
253+ target : element ,
254+ detail : { xhr : mockXhr } ,
255+ } ;
256+
257+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
258+
259+ // Verify mode was set to append
260+ expect ( mockXhr . _chunkedMode ) . toBe ( "append" ) ;
261+
262+ // Simulate progressive chunks - full response each time
263+ mockXhr . response = "<p>Loading...</p>" ;
264+ mockXhr . onprogress ! ( ) ;
265+ expect ( target . innerHTML ) . toBe ( "<p>Loading...</p>" ) ;
266+
267+ mockXhr . response = "<p>Loading...</p><p>50%</p>" ;
268+ mockXhr . onprogress ! ( ) ;
269+ expect ( target . innerHTML ) . toBe ( "<p>Loading...</p><p>50%</p>" ) ;
270+
271+ mockXhr . response = "<p>Loading...</p><p>50%</p><p>Done!</p>" ;
272+ mockXhr . onprogress ! ( ) ;
273+ expect ( target . innerHTML ) . toBe ( "<p>Loading...</p><p>50%</p><p>Done!</p>" ) ;
274+ } ) ;
275+
276+ test ( "swap mode - only swaps new content (incremental)" , ( ) => {
277+ const element = document . createElement ( "div" ) ;
278+ element . setAttribute ( "hx-chunked-mode" , "swap" ) ;
279+
280+ const mockXhr = {
281+ getResponseHeader : ( header : string ) => {
282+ if ( header === "Transfer-Encoding" ) return "chunked" ;
283+ return null ;
284+ } ,
285+ response : "" ,
286+ onprogress : null as any ,
287+ _chunkedMode : undefined ,
288+ _chunkedLastLen : 0 ,
289+ } ;
290+
291+ const event = {
292+ target : element ,
293+ detail : { xhr : mockXhr } ,
294+ } ;
295+
296+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
297+
298+ // Verify mode was set to swap
299+ expect ( mockXhr . _chunkedMode ) . toBe ( "swap" ) ;
300+
301+ // First chunk - everything is new
302+ mockXhr . response = "<p>Loading...</p>" ;
303+ mockXhr . onprogress ! ( ) ;
304+ expect ( target . innerHTML ) . toBe ( "<p>Loading...</p>" ) ;
305+ expect ( mockXhr . _chunkedLastLen ) . toBe ( mockXhr . response . length ) ;
306+
307+ // Second chunk - only new content swapped
308+ mockXhr . response = "<p>Loading...</p><p>50%</p>" ;
309+ mockXhr . onprogress ! ( ) ;
310+ expect ( target . innerHTML ) . toBe ( "<p>50%</p>" ) ; // Only the NEW part
311+ expect ( mockXhr . _chunkedLastLen ) . toBe ( mockXhr . response . length ) ;
312+
313+ // Third chunk - only newest content swapped
314+ mockXhr . response = "<p>Loading...</p><p>50%</p><p>Done!</p>" ;
315+ mockXhr . onprogress ! ( ) ;
316+ expect ( target . innerHTML ) . toBe ( "<p>Done!</p>" ) ; // Only the NEWEST part
317+ expect ( mockXhr . _chunkedLastLen ) . toBe ( mockXhr . response . length ) ;
318+ } ) ;
319+
320+ test ( "swap mode - ignores duplicate progress events (same length)" , ( ) => {
321+ const element = document . createElement ( "div" ) ;
322+ element . setAttribute ( "hx-chunked-mode" , "swap" ) ;
323+
324+ const mockXhr = {
325+ getResponseHeader : ( header : string ) => {
326+ if ( header === "Transfer-Encoding" ) return "chunked" ;
327+ return null ;
328+ } ,
329+ response : "<p>Content</p>" ,
330+ onprogress : null as any ,
331+ _chunkedMode : undefined ,
332+ _chunkedLastLen : 0 ,
333+ } ;
334+
335+ const event = {
336+ target : element ,
337+ detail : { xhr : mockXhr } ,
338+ } ;
339+
340+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
341+
342+ // First progress event
343+ mockXhr . onprogress ! ( ) ;
344+ expect ( target . innerHTML ) . toBe ( "<p>Content</p>" ) ;
345+ const firstHtml = target . innerHTML ;
346+
347+ // Duplicate progress event - same response length
348+ mockXhr . onprogress ! ( ) ;
349+ // Should not swap again (innerHTML unchanged)
350+ expect ( target . innerHTML ) . toBe ( firstHtml ) ;
351+ } ) ;
352+
353+ test ( "swap mode - prevents final htmx:beforeSwap" , ( ) => {
354+ const element = document . createElement ( "div" ) ;
355+ element . setAttribute ( "hx-chunked-mode" , "swap" ) ;
356+
357+ const mockXhr = {
358+ getResponseHeader : ( header : string ) => {
359+ if ( header === "Transfer-Encoding" ) return "chunked" ;
360+ return null ;
361+ } ,
362+ response : "<p>Final</p>" ,
363+ _chunkedMode : "swap" ,
364+ } ;
365+
366+ const beforeSwapEvent = {
367+ target : element ,
368+ detail : {
369+ xhr : mockXhr ,
370+ shouldSwap : true , // Initially true
371+ } ,
372+ } ;
373+
374+ // Trigger beforeSwap event
375+ registeredExtension . onEvent ( "htmx:beforeSwap" , beforeSwapEvent ) ;
376+
377+ // Verify shouldSwap was set to false
378+ expect ( beforeSwapEvent . detail . shouldSwap ) . toBe ( false ) ;
379+ } ) ;
380+
381+ test ( "append mode - allows final htmx:beforeSwap" , ( ) => {
382+ const element = document . createElement ( "div" ) ;
383+ // Default append mode
384+
385+ const mockXhr = {
386+ getResponseHeader : ( header : string ) => {
387+ if ( header === "Transfer-Encoding" ) return "chunked" ;
388+ return null ;
389+ } ,
390+ response : "<p>Final</p>" ,
391+ _chunkedMode : "append" ,
392+ } ;
393+
394+ const beforeSwapEvent = {
395+ target : element ,
396+ detail : {
397+ xhr : mockXhr ,
398+ shouldSwap : true ,
399+ } ,
400+ } ;
401+
402+ // Trigger beforeSwap event
403+ registeredExtension . onEvent ( "htmx:beforeSwap" , beforeSwapEvent ) ;
404+
405+ // Verify shouldSwap is still true (not prevented)
406+ expect ( beforeSwapEvent . detail . shouldSwap ) . toBe ( true ) ;
407+ } ) ;
408+
409+ test ( "swap mode - non-chunked responses allow final swap" , ( ) => {
410+ const element = document . createElement ( "div" ) ;
411+ element . setAttribute ( "hx-chunked-mode" , "swap" ) ;
412+
413+ const mockXhr = {
414+ getResponseHeader : ( header : string ) => {
415+ // NOT chunked
416+ return null ;
417+ } ,
418+ response : "<p>Final</p>" ,
419+ _chunkedMode : "swap" ,
420+ } ;
421+
422+ const beforeSwapEvent = {
423+ target : element ,
424+ detail : {
425+ xhr : mockXhr ,
426+ shouldSwap : true ,
427+ } ,
428+ } ;
429+
430+ // Trigger beforeSwap event
431+ registeredExtension . onEvent ( "htmx:beforeSwap" , beforeSwapEvent ) ;
432+
433+ // Non-chunked responses should NOT be prevented
434+ expect ( beforeSwapEvent . detail . shouldSwap ) . toBe ( true ) ;
435+ } ) ;
436+ } ) ;
235437} ) ;
0 commit comments