Skip to content

Commit 7159889

Browse files
committed
Handle history scroll restoration using navigation api except for firefox
1 parent a7d320a commit 7159889

6 files changed

Lines changed: 311 additions & 25 deletions

File tree

src/ext/hx-history-cache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@
198198
// Before core pushes/replaces, save the outgoing page
199199
htmx_before_history_update: (elt, detail) => {
200200
if (cfg().disable) return;
201-
if (!_currentId) stampCurrentEntry();
201+
stampCurrentEntry();
202202
saveCurrentPage();
203203
},
204204

src/htmx.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,16 +1600,19 @@ var htmx = (() => {
16001600
if (!history.state) {
16011601
history.replaceState({htmx: true}, '', location.href);
16021602
}
1603-
window.addEventListener('popstate', (event) => {
1604-
if (event.state && event.state.htmx) {
1605-
this.#historyAbort?.abort();
1606-
this.__restoreHistory();
1607-
}
1608-
});
1603+
if (window.navigation && !/firefox/i.test(navigator.userAgent)) {
1604+
navigation.addEventListener('navigate', (event) => {
1605+
if (event.navigationType === 'traverse' && event.canIntercept && !event.hashChange)
1606+
event.intercept({handler: () => this.__restoreHistory()});
1607+
});
1608+
} else {
1609+
window.addEventListener('popstate', (event) => this.__restoreHistory(event.state));
1610+
}
16091611
}
16101612

16111613
__pushUrlIntoHistory(path) {
16121614
if (!this.config.history) return;
1615+
if (!history.state) history.replaceState({htmx: true}, '', location.href);
16131616
history.pushState({htmx: true}, '', path);
16141617
this.__trigger(document, "htmx:after:history:push", {path});
16151618
}
@@ -1620,15 +1623,19 @@ var htmx = (() => {
16201623
this.__trigger(document, "htmx:after:history:replace", {path});
16211624
}
16221625

1623-
__restoreHistory(path) {
1626+
async __restoreHistory(state, path) {
1627+
await this.timeout(1);
1628+
state ??= history.state;
1629+
if (!state?.htmx) return;
1630+
this.#historyAbort?.abort();
16241631
path = path || location.pathname + location.search;
16251632
let historyElt = document.querySelector(this.__prefixSelector('[hx-history-elt]')) || document.body;
16261633
if (this.__trigger(document, "htmx:before:history:restore", {path, cacheMiss: true})) {
16271634
if (this.config.history === "reload") {
16281635
location.reload();
16291636
} else {
16301637
this.#historyAbort = new AbortController();
1631-
this.ajax('GET', path, {
1638+
return this.ajax('GET', path, {
16321639
target: historyElt,
16331640
swap: 'outerSync',
16341641
select: historyElt !== document.body ? this.__prefixSelector('[hx-history-elt]') : undefined,

test/lib/helpers.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ function cleanupTest() {
6363
}
6464
fetchMock.reset()
6565
}
66-
history.replaceState(null, '', savedUrl);
66+
let saved = new URL(savedUrl);
67+
if (location.pathname !== saved.pathname || location.search !== saved.search) {
68+
// Use location.replace() instead of history.replaceState() to avoid
69+
// the browser's 100 replaceState/10s rate limit across large test suites.
70+
// location.replace() navigates to the URL without adding a history entry
71+
// and is not subject to the replaceState rate limit.
72+
history.replaceState(null, '', savedUrl);
73+
}
6774
}
6875

6976
//================================================================================

test/tests/end2end/basic-history.js

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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' && !/Firefox\//.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+

test/tests/ext/hx-alpine-compat-history.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
history.replaceState({ htmx: true, htmxId }, '', cachedPath);
7979
await new Promise(resolve => {
8080
document.addEventListener('htmx:history:cache:after:restore', resolve, { once: true });
81-
htmx.__restoreHistory(cachedPath);
81+
htmx.__restoreHistory(history.state, cachedPath);
8282
});
8383
await htmx.timeout(50);
8484
return historyElt;
@@ -108,7 +108,7 @@
108108
history.replaceState({ htmx: true, htmxId }, '', cachedPath);
109109
await new Promise(resolve => {
110110
document.addEventListener('htmx:history:cache:after:restore', resolve, { once: true });
111-
htmx.__restoreHistory(cachedPath);
111+
htmx.__restoreHistory(history.state, cachedPath);
112112
});
113113
await htmx.timeout(50);
114114

@@ -138,7 +138,7 @@
138138
history.replaceState({ htmx: true, htmxId }, '', cachedPath);
139139
await new Promise(resolve => {
140140
document.addEventListener('htmx:history:cache:after:restore', resolve, { once: true });
141-
htmx.__restoreHistory(cachedPath);
141+
htmx.__restoreHistory(history.state, cachedPath);
142142
});
143143
await htmx.timeout(50);
144144

@@ -183,7 +183,7 @@
183183
history.replaceState({ htmx: true, htmxId }, '', cachedPath);
184184
await new Promise(resolve => {
185185
document.addEventListener('htmx:history:cache:after:restore', resolve, { once: true });
186-
htmx.__restoreHistory(cachedPath);
186+
htmx.__restoreHistory(history.state, cachedPath);
187187
});
188188
await htmx.timeout(50);
189189

0 commit comments

Comments
 (0)