@@ -233,6 +233,170 @@ describe("chunked-transfer extension", () => {
233233 } ) ;
234234 } ) ;
235235
236+ describe ( "comment filtering (heartbeats)" , ( ) => {
237+ test ( "ignores comment-only chunks" , ( ) => {
238+ const element = document . createElement ( "div" ) ;
239+ const mockXhr = {
240+ getResponseHeader : ( header : string ) => {
241+ if ( header === "Transfer-Encoding" ) return "chunked" ;
242+ return null ;
243+ } ,
244+ response : "<!-- heartbeat -->" ,
245+ onprogress : null as any ,
246+ } ;
247+
248+ const event = {
249+ target : element ,
250+ detail : { xhr : mockXhr } ,
251+ } ;
252+
253+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
254+ mockXhr . onprogress ! ( ) ;
255+
256+ // Target should remain empty (comment-only chunk ignored)
257+ expect ( target . innerHTML ) . toBe ( "" ) ;
258+ } ) ;
259+
260+ test ( "ignores multi-line comment-only chunks" , ( ) => {
261+ const element = document . createElement ( "div" ) ;
262+ const mockXhr = {
263+ getResponseHeader : ( header : string ) => {
264+ if ( header === "Transfer-Encoding" ) return "chunked" ;
265+ return null ;
266+ } ,
267+ response : "<!-- \n heartbeat\n still processing\n -->" ,
268+ onprogress : null as any ,
269+ } ;
270+
271+ const event = {
272+ target : element ,
273+ detail : { xhr : mockXhr } ,
274+ } ;
275+
276+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
277+ mockXhr . onprogress ! ( ) ;
278+
279+ // Multi-line comment should also be ignored
280+ expect ( target . innerHTML ) . toBe ( "" ) ;
281+ } ) ;
282+
283+ test ( "ignores multiple comments without content" , ( ) => {
284+ const element = document . createElement ( "div" ) ;
285+ const mockXhr = {
286+ getResponseHeader : ( header : string ) => {
287+ if ( header === "Transfer-Encoding" ) return "chunked" ;
288+ return null ;
289+ } ,
290+ response : "<!-- comment 1 --><!-- comment 2 --> " ,
291+ onprogress : null as any ,
292+ } ;
293+
294+ const event = {
295+ target : element ,
296+ detail : { xhr : mockXhr } ,
297+ } ;
298+
299+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
300+ mockXhr . onprogress ! ( ) ;
301+
302+ // Multiple comments with only whitespace should be ignored
303+ expect ( target . innerHTML ) . toBe ( "" ) ;
304+ } ) ;
305+
306+ test ( "processes chunks with comments and HTML content" , ( ) => {
307+ const element = document . createElement ( "div" ) ;
308+ const mockXhr = {
309+ getResponseHeader : ( header : string ) => {
310+ if ( header === "Transfer-Encoding" ) return "chunked" ;
311+ return null ;
312+ } ,
313+ response : "<!-- debug info --><p>Content</p>" ,
314+ onprogress : null as any ,
315+ } ;
316+
317+ const event = {
318+ target : element ,
319+ detail : { xhr : mockXhr } ,
320+ } ;
321+
322+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
323+ mockXhr . onprogress ! ( ) ;
324+
325+ // Should process the chunk (has HTML beyond comments)
326+ expect ( target . innerHTML ) . toBe ( "<!-- debug info --><p>Content</p>" ) ;
327+ } ) ;
328+
329+ test ( "processes chunks with comments between HTML" , ( ) => {
330+ const element = document . createElement ( "div" ) ;
331+ const mockXhr = {
332+ getResponseHeader : ( header : string ) => {
333+ if ( header === "Transfer-Encoding" ) return "chunked" ;
334+ return null ;
335+ } ,
336+ response : "<p>Start</p><!-- middle --><p>End</p>" ,
337+ onprogress : null as any ,
338+ } ;
339+
340+ const event = {
341+ target : element ,
342+ detail : { xhr : mockXhr } ,
343+ } ;
344+
345+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
346+ mockXhr . onprogress ! ( ) ;
347+
348+ // Should process the entire chunk
349+ expect ( target . innerHTML ) . toBe ( "<p>Start</p><!-- middle --><p>End</p>" ) ;
350+ } ) ;
351+
352+ test ( "processes empty chunks (no comments)" , ( ) => {
353+ const element = document . createElement ( "div" ) ;
354+ const mockXhr = {
355+ getResponseHeader : ( header : string ) => {
356+ if ( header === "Transfer-Encoding" ) return "chunked" ;
357+ return null ;
358+ } ,
359+ response : "" ,
360+ onprogress : null as any ,
361+ } ;
362+
363+ const event = {
364+ target : element ,
365+ detail : { xhr : mockXhr } ,
366+ } ;
367+
368+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
369+ mockXhr . onprogress ! ( ) ;
370+
371+ // Empty chunks pass through (no content added, but swap is called)
372+ expect ( target . innerHTML ) . toBe ( "" ) ;
373+ } ) ;
374+
375+ test ( "processes whitespace-only chunks (no comments)" , ( ) => {
376+ const element = document . createElement ( "div" ) ;
377+ const mockXhr = {
378+ getResponseHeader : ( header : string ) => {
379+ if ( header === "Transfer-Encoding" ) return "chunked" ;
380+ return null ;
381+ } ,
382+ response : " \n \t " ,
383+ onprogress : null as any ,
384+ } ;
385+
386+ const event = {
387+ target : element ,
388+ detail : { xhr : mockXhr } ,
389+ } ;
390+
391+ registeredExtension . onEvent ( "htmx:beforeRequest" , event ) ;
392+ mockXhr . onprogress ! ( ) ;
393+
394+ // Whitespace-only chunks (no comments) pass through and get trimmed by DOM
395+ // The whitespace gets normalized to empty when set as innerHTML
396+ expect ( target . innerHTML . trim ( ) ) . toBe ( "" ) ;
397+ } ) ;
398+ } ) ;
399+
236400 describe ( "hx-chunked-mode=swap" , ( ) => {
237401 test ( "default mode (append) - accumulates all chunks" , ( ) => {
238402 const element = document . createElement ( "div" ) ;
0 commit comments