@@ -153,7 +153,7 @@ describe('hx-push-url and hx-replace-url attributes', function() {
153153 try {
154154 mockResponse ( 'GET' , '/restore-test' , '<div id="restored">Restored Content</div>' ) ;
155155
156- htmx . __restoreHistory ( '/restore-test' ) ;
156+ htmx . __restoreHistory ( { htmx : true } , '/restore-test' ) ;
157157 await forRequest ( ) ;
158158
159159 document . body . innerHTML . should . include ( 'Restored Content' ) ;
@@ -336,7 +336,7 @@ describe('hx-history-elt scopes history restore', function() {
336336 </body></html>` ;
337337 mockResponse ( 'GET' , '/restore-test' , response ) ;
338338
339- htmx . __restoreHistory ( '/restore-test' ) ;
339+ htmx . __restoreHistory ( { htmx : true } , '/restore-test' ) ;
340340 await forRequest ( ) ;
341341
342342 document . getElementById ( 'sentinel' ) . should . not . equal ( null ) ;
@@ -353,3 +353,231 @@ describe('hx-history-elt scopes history restore', function() {
353353 document . body . textContent . should . not . include ( 'FOOTER LEAK' ) ;
354354 } ) ;
355355} ) ;
356+
357+
358+
359+ describe ( 'scroll restoration on history traversal' , function ( ) {
360+
361+ const hasNavigationAPI = typeof Navigation === 'function' && ! / F i r e f o x \/ / . test ( navigator . userAgent ) ;
362+
363+ beforeEach ( ( ) => { setupTest ( this . currentTest ) ; } ) ;
364+
365+ afterEach ( ( ) => {
366+ window . scrollTo ( 0 , 0 ) ;
367+ cleanupTest ( ) ;
368+ } ) ;
369+
370+ async function untilScrollY ( y , timeout = 1500 ) {
371+ let start = performance . now ( ) ;
372+ while ( window . scrollY !== y && performance . now ( ) - start < timeout ) {
373+ await new Promise ( r => requestAnimationFrame ( r ) ) ;
374+ }
375+ }
376+
377+ it ( 'boosted back restores content, then the browser restores scroll' , async function ( ) {
378+ if ( ! hasNavigationAPI ) this . skip ( ) ;
379+ playground ( ) . innerHTML = '<main hx-history-elt><div style="height:3000px">page A</div></main>' ;
380+ htmx . process ( playground ( ) ) ;
381+ history . replaceState ( { htmx : true } , '' , '/scroll-page-a' ) ;
382+ window . scrollTo ( 0 , 500 ) ;
383+
384+ htmx . __pushUrlIntoHistory ( '/scroll-page-b' ) ;
385+ playground ( ) . innerHTML = '<main hx-history-elt><p>page B</p></main>' ;
386+ window . scrollTo ( 0 , 0 ) ;
387+
388+ mockResponse ( 'GET' , '/scroll-page-a' , ( ) => new Promise ( resolve =>
389+ setTimeout ( ( ) => resolve ( new MockResponse (
390+ '<html><body><main hx-history-elt><div style="height:3000px">page A restored</div></main></body></html>'
391+ ) ) , 100 ) ) ) ;
392+
393+ history . back ( ) ;
394+ await forRequest ( 400 ) ;
395+ await untilScrollY ( 500 ) ;
396+
397+ playground ( ) . textContent . should . include ( 'page A restored' ) ;
398+ assert . equal ( window . scrollY , 500 ) ;
399+ } ) ;
400+
401+ it ( 'back returns to the latest scroll position after re-scrolling' , async function ( ) {
402+ if ( ! hasNavigationAPI ) this . skip ( ) ;
403+ this . timeout ( 5000 ) ;
404+ playground ( ) . innerHTML = '<main hx-history-elt><div style="height:3000px">page A</div></main>' ;
405+ htmx . process ( playground ( ) ) ;
406+ history . replaceState ( { htmx : true } , '' , '/scroll-page-a' ) ;
407+ window . scrollTo ( 0 , 500 ) ;
408+
409+ htmx . __pushUrlIntoHistory ( '/scroll-page-b' ) ;
410+ playground ( ) . innerHTML = '<main hx-history-elt><div style="height:3000px">page B</div></main>' ;
411+ window . scrollTo ( 0 , 0 ) ;
412+
413+ mockResponse ( 'GET' , '/scroll-page-a' ,
414+ '<html><body><main hx-history-elt><div style="height:3000px">page A</div></main></body></html>' ) ;
415+ mockResponse ( 'GET' , '/scroll-page-b' ,
416+ '<html><body><main hx-history-elt><div style="height:3000px">page B</div></main></body></html>' ) ;
417+
418+ history . back ( ) ;
419+ await forRequest ( ) ;
420+ await untilScrollY ( 500 ) ;
421+
422+ window . scrollTo ( 0 , 800 ) ;
423+ history . forward ( ) ;
424+ await forRequest ( ) ;
425+ await untilScrollY ( 0 ) ;
426+
427+ history . back ( ) ;
428+ await forRequest ( ) ;
429+ await untilScrollY ( 800 ) ;
430+
431+ assert . equal ( window . scrollY , 800 ) ;
432+ } ) ;
433+
434+ it ( 'back to an entry created by an anchor jump still restores content' , async function ( ) {
435+ if ( ! hasNavigationAPI ) this . skip ( ) ;
436+ playground ( ) . innerHTML = '<main hx-history-elt><div style="height:3000px">reference page</div></main>' ;
437+ htmx . process ( playground ( ) ) ;
438+ history . replaceState ( { htmx : true } , '' , '/scroll-ref' ) ;
439+
440+ location . hash = '#events' ;
441+ assert . isNull ( history . state ) ;
442+ window . scrollTo ( 0 , 500 ) ;
443+
444+ htmx . __pushUrlIntoHistory ( '/scroll-hxget' ) ;
445+ playground ( ) . innerHTML = '<main hx-history-elt><p>hx-get page</p></main>' ;
446+ window . scrollTo ( 0 , 0 ) ;
447+
448+ mockResponse ( 'GET' , '/scroll-ref' , ( ) => new Promise ( resolve =>
449+ setTimeout ( ( ) => resolve ( new MockResponse (
450+ '<html><body><main hx-history-elt><div style="height:3000px">reference restored</div></main></body></html>'
451+ ) ) , 100 ) ) ) ;
452+
453+ history . back ( ) ;
454+ await forRequest ( 400 ) ;
455+ await untilScrollY ( 500 ) ;
456+
457+ playground ( ) . textContent . should . include ( 'reference restored' ) ;
458+ assert . equal ( location . hash , '#events' ) ;
459+ assert . equal ( window . scrollY , 500 ) ;
460+ } ) ;
461+
462+ it ( 'restores horizontal scroll as well' , async function ( ) {
463+ if ( ! hasNavigationAPI ) this . skip ( ) ;
464+ playground ( ) . innerHTML = '<main hx-history-elt><div style="height:3000px;width:3000px">wide page</div></main>' ;
465+ htmx . process ( playground ( ) ) ;
466+ history . replaceState ( { htmx : true } , '' , '/scroll-wide' ) ;
467+ window . scrollTo ( 300 , 500 ) ;
468+
469+ htmx . __pushUrlIntoHistory ( '/scroll-narrow' ) ;
470+ playground ( ) . innerHTML = '<main hx-history-elt><p>narrow page</p></main>' ;
471+ window . scrollTo ( 0 , 0 ) ;
472+
473+ mockResponse ( 'GET' , '/scroll-wide' , ( ) => new Promise ( resolve =>
474+ setTimeout ( ( ) => resolve ( new MockResponse (
475+ '<html><body><main hx-history-elt><div style="height:3000px;width:3000px">wide restored</div></main></body></html>'
476+ ) ) , 100 ) ) ) ;
477+
478+ history . back ( ) ;
479+ await forRequest ( 400 ) ;
480+ await untilScrollY ( 500 ) ;
481+
482+ assert . equal ( window . scrollY , 500 ) ;
483+ assert . equal ( window . scrollX , 300 ) ;
484+ } ) ;
485+
486+ it ( 'ignores traversal to non-htmx history entries' , async function ( ) {
487+ history . replaceState ( { foreign : true } , '' , '/foreign-page' ) ;
488+ history . pushState ( { htmx : true } , '' , '/scroll-page-b' ) ;
489+
490+ history . back ( ) ;
491+ let evt = await forRequest ( 150 ) ;
492+
493+ assert . isNull ( evt ) ;
494+ } ) ;
495+ it ( 'ignores hash-only traversal' , async function ( ) {
496+ if ( ! hasNavigationAPI ) this . skip ( ) ;
497+ history . replaceState ( { htmx : true } , '' , location . pathname + '#a' ) ;
498+ history . pushState ( { htmx : true } , '' , location . pathname + '#b' ) ;
499+
500+ history . back ( ) ;
501+ let evt = await forRequest ( 150 ) ;
502+
503+ assert . isNull ( evt ) ;
504+ assert . equal ( location . hash , '#a' ) ;
505+ } ) ;
506+ } ) ;
507+
508+ describe ( 'history restore edge cases' , function ( ) {
509+
510+ beforeEach ( ( ) => { setupTest ( this . currentTest ) ; } ) ;
511+ afterEach ( ( ) => { cleanupTest ( ) ; } ) ;
512+
513+ it ( 'a second back aborts the in-flight restore' , async function ( ) {
514+ this . timeout ( 5000 ) ;
515+ playground ( ) . innerHTML = '<main hx-history-elt><p>page C</p></main>' ;
516+ htmx . process ( playground ( ) ) ;
517+ history . replaceState ( { htmx : true } , '' , '/edge-a' ) ;
518+ htmx . __pushUrlIntoHistory ( '/edge-b' ) ;
519+ htmx . __pushUrlIntoHistory ( '/edge-c' ) ;
520+
521+ mockResponse ( 'GET' , '/edge-a' ,
522+ '<html><body><main hx-history-elt><p>page A</p></main></body></html>' ) ;
523+ mockResponse ( 'GET' , '/edge-b' , ( ) => new Promise ( resolve =>
524+ setTimeout ( ( ) => resolve ( new MockResponse (
525+ '<html><body><main hx-history-elt><p>page B</p></main></body></html>'
526+ ) ) , 150 ) ) ) ;
527+
528+ history . back ( ) ;
529+ await new Promise ( r => setTimeout ( r , 50 ) ) ;
530+ history . back ( ) ;
531+ await new Promise ( r => setTimeout ( r , 500 ) ) ;
532+
533+ assert . equal ( location . pathname , '/edge-a' ) ;
534+ playground ( ) . textContent . should . include ( 'page A' ) ;
535+ } ) ;
536+
537+ it ( 'restores the replaced URL after a replace' , async function ( ) {
538+ playground ( ) . innerHTML = '<main hx-history-elt><p>replaced page</p></main>' ;
539+ htmx . process ( playground ( ) ) ;
540+ history . replaceState ( { htmx : true } , '' , '/edge-orig' ) ;
541+ htmx . __pushUrlIntoHistory ( '/edge-next' ) ;
542+ htmx . __replaceUrlInHistory ( '/edge-replaced' ) ;
543+
544+ mockResponse ( 'GET' , '/edge-orig' ,
545+ '<html><body><main hx-history-elt><p>original restored</p></main></body></html>' ) ;
546+ mockResponse ( 'GET' , '/edge-replaced' ,
547+ '<html><body><main hx-history-elt><p>replaced restored</p></main></body></html>' ) ;
548+
549+ history . back ( ) ;
550+ await forRequest ( ) ;
551+ playground ( ) . textContent . should . include ( 'original restored' ) ;
552+ assert . equal ( location . pathname , '/edge-orig' ) ;
553+ await new Promise ( r => setTimeout ( r , 50 ) ) ;
554+
555+ history . forward ( ) ;
556+ await forRequest ( ) ;
557+ playground ( ) . textContent . should . include ( 'replaced restored' ) ;
558+ assert . equal ( location . pathname , '/edge-replaced' ) ;
559+ } ) ;
560+
561+ it ( 'skips a foreign entry but restores the htmx entry behind it' , async function ( ) {
562+ playground ( ) . innerHTML = '<main hx-history-elt><p>after page</p></main>' ;
563+ htmx . process ( playground ( ) ) ;
564+ history . replaceState ( { htmx : true } , '' , '/edge-mine' ) ;
565+ history . pushState ( { vue : true } , '' , '/edge-foreign' ) ;
566+ htmx . __pushUrlIntoHistory ( '/edge-after' ) ;
567+
568+ mockResponse ( 'GET' , '/edge-mine' ,
569+ '<html><body><main hx-history-elt><p>mine restored</p></main></body></html>' ) ;
570+
571+ history . back ( ) ;
572+ let evt = await forRequest ( 150 ) ;
573+ assert . isNull ( evt ) ;
574+ playground ( ) . textContent . should . include ( 'after page' ) ;
575+ assert . equal ( location . pathname , '/edge-foreign' ) ;
576+
577+ history . back ( ) ;
578+ await forRequest ( ) ;
579+ playground ( ) . textContent . should . include ( 'mine restored' ) ;
580+ assert . equal ( location . pathname , '/edge-mine' ) ;
581+ } ) ;
582+ } ) ;
583+
0 commit comments