Skip to content

Commit d7eb120

Browse files
committed
perf(router): prefetch on touchstart, and eager links while idle
A finger does not hover, so a phone never prefetched: the page was fetched on click. Touchstart now starts the fetch a tap-length early (passive), and links marked data-stx-prefetch="eager" (a tab bar) are fetched once the page is idle, so even their first tap is a swap. Not on a connection that asked to save data.
1 parent 7a56fa2 commit d7eb120

2 files changed

Lines changed: 140 additions & 18 deletions

File tree

‎packages/router/src/client.ts‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,26 +2096,58 @@ else {
20962096
navigate(location.pathname+location.search+location.hash,false);
20972097
});
20982098
2099-
// ── Prefetch on hover ──
2099+
// ── Prefetch ──
2100+
// A link's page, fetched before it is followed into the cache navigate
2101+
// reads, so following it is a swap rather than a round trip.
2102+
function prefetchLink(link){
2103+
// Same opt-out set as the click path. Without it, hovering a link the
2104+
// router is not allowed to claim still fired a real GET at it — a
2105+
// logout or OAuth URL was requested on hover alone.
2106+
if(isRouterExcluded(link))return;
2107+
var href=withCurrentLocale(link.getAttribute('href'));
2108+
var key=cacheKey(href);
2109+
if(cache[key]||prefetching[key])return;
2110+
prefetching[key]=true;
2111+
var wantsFragment=shouldUseFragmentResponse();
2112+
fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
2113+
return readPrefetchResponse(r,wantsFragment);
2114+
}).then(function(result){
2115+
if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2116+
}).catch(function(){}).finally(function(){delete prefetching[key]});
2117+
}
2118+
2119+
// Links marked data-stx-prefetch="eager", fetched once the page is idle, so
2120+
// even their first tap is served from the cache: the way a phone's tab bar
2121+
// switches instantly. Not on a connection that asked to save data.
2122+
var eagerQueued=false;
2123+
function prefetchEager(){
2124+
if(!o.prefetch||!o.cache||eagerQueued)return;
2125+
eagerQueued=true;
2126+
var run=function(){
2127+
eagerQueued=false;
2128+
var connection=navigator.connection;
2129+
if(connection&&(connection.saveData||/2g/.test(connection.effectiveType||'')))return;
2130+
var here=cacheKey(location.pathname+location.search);
2131+
var links=document.querySelectorAll('[data-stx-link][data-stx-prefetch="eager"]');
2132+
for(var i=0;i<links.length;i++){
2133+
var href=links[i].getAttribute('href');
2134+
if(href&&cacheKey(withCurrentLocale(href))!==here)prefetchLink(links[i]);
2135+
}
2136+
};
2137+
if(window.requestIdleCallback)window.requestIdleCallback(run,{timeout:2000});
2138+
else setTimeout(run,300);
2139+
}
2140+
21002141
if(o.prefetch){
2101-
document.addEventListener('mouseover',function(e){
2142+
// A mouse hovers before it clicks. A finger does not hover, but it
2143+
// touches down a tap's length before the click: start the fetch there.
2144+
var onIntent=function(e){
21022145
if(!e.target||!e.target.closest)return;
2103-
var link=e.target.closest('[data-stx-link]');
2104-
// Same opt-out set as the click path. Without it, hovering a link the
2105-
// router is not allowed to claim still fired a real GET at it — a
2106-
// logout or OAuth URL was requested on hover alone.
2107-
if(isRouterExcluded(link))return;
2108-
var href=withCurrentLocale(link.getAttribute('href'));
2109-
var key=cacheKey(href);
2110-
if(cache[key]||prefetching[key])return;
2111-
prefetching[key]=true;
2112-
var wantsFragment=shouldUseFragmentResponse();
2113-
fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
2114-
return readPrefetchResponse(r,wantsFragment);
2115-
}).then(function(result){
2116-
if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
2117-
}).catch(function(){}).finally(function(){delete prefetching[key]});
2118-
},true);
2146+
prefetchLink(e.target.closest('[data-stx-link]'));
2147+
};
2148+
document.addEventListener('mouseover',onIntent,true);
2149+
document.addEventListener('touchstart',onIntent,{capture:true,passive:true});
2150+
window.addEventListener('stx:load',prefetchEager);
21192151
}
21202152
21212153
// ── Active link management ──
@@ -2317,6 +2349,7 @@ else {
23172349
injectStyles();
23182350
injectViewTransitionCSS();
23192351
refreshCurrentLinks();
2352+
prefetchEager();
23202353
}
23212354
23222355
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Prefetch on a phone.
3+
*
4+
* The router prefetched on mouseover only, and a finger never hovers, so on a
5+
* phone every first visit waited for its fetch. A touch now starts the fetch
6+
* at touchdown, a tap's length before the click, and links marked
7+
* data-stx-prefetch="eager" (a tab bar) are fetched once the page is idle, so
8+
* their first tap is served from the cache like a native tab switch.
9+
*/
10+
import { afterEach, describe, expect, it } from 'bun:test'
11+
import { Window } from 'very-happy-dom'
12+
import { getRouterScript } from '../src/client'
13+
14+
const originalGlobals = {
15+
window: globalThis.window,
16+
document: globalThis.document,
17+
location: globalThis.location,
18+
history: globalThis.history,
19+
fetch: globalThis.fetch,
20+
navigator: globalThis.navigator,
21+
CustomEvent: globalThis.CustomEvent,
22+
Event: globalThis.Event,
23+
DOMParser: globalThis.DOMParser,
24+
}
25+
26+
afterEach(() => {
27+
Object.assign(globalThis, originalGlobals)
28+
})
29+
30+
function installRouter(body: string, connection?: { saveData?: boolean, effectiveType?: string }) {
31+
const window = new Window({ url: 'http://localhost/m' })
32+
window.document.write(`<html><head><meta name="stx-layout" content="layouts/mobile.stx"></head><body>${body}<main>Today</main></body></html>`)
33+
;(window as any).stx = {}
34+
;(window as any).__stxRouterConfig = { cache: true, prefetch: true, progress: false, viewTransitions: false }
35+
if (connection) Object.defineProperty(window.navigator, 'connection', { configurable: true, value: connection })
36+
const requested: string[] = []
37+
Object.assign(globalThis, {
38+
window,
39+
document: window.document,
40+
location: window.location,
41+
history: window.history,
42+
navigator: window.navigator,
43+
CustomEvent: window.CustomEvent,
44+
Event: window.Event,
45+
DOMParser: window.DOMParser,
46+
fetch: async (url: string) => {
47+
requested.push(String(url))
48+
return new Response('<section>page</section>', { status: 200, headers: { 'Content-Type': 'text/html', 'X-STX-Fragment': 'true', 'X-STX-Layout': 'layouts/mobile.stx' } })
49+
},
50+
})
51+
new Function(getRouterScript())()
52+
return { window, requested }
53+
}
54+
55+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
56+
57+
describe('router prefetch on a phone', () => {
58+
it('starts fetching a link when a finger touches it', async () => {
59+
const { window, requested } = installRouter('<a href="/m/calendar" data-stx-link id="cal">Calendar</a>')
60+
const link = window.document.getElementById('cal')!
61+
link.dispatchEvent(new window.Event('touchstart', { bubbles: true }))
62+
await wait(10)
63+
expect(requested).toContain('/m/calendar')
64+
})
65+
66+
it('fetches eager links once the page is idle, but not the page it is on', async () => {
67+
const { requested } = installRouter(
68+
'<nav><a href="/m" data-stx-link data-stx-prefetch="eager">Today</a>'
69+
+ '<a href="/m/calendar" data-stx-link data-stx-prefetch="eager">Calendar</a>'
70+
+ '<a href="/m/me" data-stx-link data-stx-prefetch="eager">Me</a>'
71+
+ '<a href="/m/health" data-stx-link>Health</a></nav>',
72+
)
73+
await wait(400)
74+
expect(requested.sort()).toEqual(['/m/calendar', '/m/me'])
75+
})
76+
77+
it('leaves eager links alone on a connection that asked to save data', async () => {
78+
const { requested } = installRouter('<a href="/m/calendar" data-stx-link data-stx-prefetch="eager">Calendar</a>', { saveData: true })
79+
await wait(400)
80+
expect(requested).toEqual([])
81+
})
82+
83+
it('never prefetches a link the router may not claim', async () => {
84+
const { window, requested } = installRouter('<a href="/logout" data-stx-link data-stx-no-router id="out">Log out</a>')
85+
window.document.getElementById('out')!.dispatchEvent(new window.Event('touchstart', { bubbles: true }))
86+
await wait(10)
87+
expect(requested).toEqual([])
88+
})
89+
})

0 commit comments

Comments
 (0)