Skip to content

Commit ed6c3aa

Browse files
[breaking] respect cache-control max-age on the client (#6461)
* [breaking] respect cache-control max-age on the client ...for initially fetched responses Closes #4625 The proposed etag logic is not implemented because it sounds too complex and would add needless code for 99% of users This is a breaking change insofar that people could have relied on the old behavior somehow * calculate ttl on the server * oops * oops * no * invalidate cache on POST * fix * simplify * deflake, hopefully * try to make test less flaky * disable when prerendering Co-authored-by: Rich Harris <[email protected]>
1 parent 60ab965 commit ed6c3aa

File tree

15 files changed

+152
-10
lines changed

15 files changed

+152
-10
lines changed

.changeset/serious-bottles-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[breaking] respect cache-control max-age on the client for initially fetched responses

packages/kit/src/runtime/client/client.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { onMount, tick } from 'svelte';
22
import { normalize_error } from '../../utils/error.js';
33
import { make_trackable, decode_params, normalize_path } from '../../utils/url.js';
44
import { find_anchor, get_base_uri, scroll_state } from './utils.js';
5-
import { lock_fetch, unlock_fetch, initial_fetch, native_fetch } from './fetcher.js';
5+
import { lock_fetch, unlock_fetch, initial_fetch, subsequent_fetch } from './fetcher.js';
66
import { parse } from './parse.js';
77
import { error } from '../../exports/index.js';
88

@@ -584,11 +584,13 @@ export function create_client({ target, base, trailing_slash }) {
584584
}
585585

586586
// we must fixup relative urls so they are resolved from the target page
587-
const normalized = new URL(requested, url).href;
588-
depends(normalized);
587+
const resolved = new URL(requested, url).href;
588+
depends(resolved);
589589

590-
// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be normalized
591-
return started ? native_fetch(normalized, init) : initial_fetch(requested, init);
590+
// prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be resolved
591+
return started
592+
? subsequent_fetch(resolved, init)
593+
: initial_fetch(requested, resolved, init);
592594
},
593595
setHeaders: () => {}, // noop
594596
depends,

packages/kit/src/runtime/client/fetcher.js

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { hash } from '../hash.js';
22

33
let loading = 0;
44

5-
export const native_fetch = window.fetch;
5+
const native_fetch = window.fetch;
66

77
export function lock_fetch() {
88
loading += 1;
@@ -33,15 +33,40 @@ if (import.meta.env.DEV) {
3333
);
3434
}
3535

36+
const method = input instanceof Request ? input.method : init?.method || 'GET';
37+
38+
if (method !== 'GET') {
39+
const url = new URL(input instanceof Request ? input.url : input.toString(), document.baseURI)
40+
.href;
41+
cache.delete(url);
42+
}
43+
44+
return native_fetch(input, init);
45+
};
46+
} else {
47+
window.fetch = (input, init) => {
48+
const method = input instanceof Request ? input.method : init?.method || 'GET';
49+
50+
if (method !== 'GET') {
51+
const url = new URL(input instanceof Request ? input.url : input.toString(), document.baseURI)
52+
.href;
53+
cache.delete(url);
54+
}
55+
3656
return native_fetch(input, init);
3757
};
3858
}
3959

60+
const cache = new Map();
61+
4062
/**
63+
* Should be called on the initial run of load functions that hydrate the page.
64+
* Saves any requests with cache-control max-age to the cache.
4165
* @param {RequestInfo} resource
66+
* @param {string} resolved
4267
* @param {RequestInit} [opts]
4368
*/
44-
export function initial_fetch(resource, opts) {
69+
export function initial_fetch(resource, resolved, opts) {
4570
const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url);
4671

4772
let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
@@ -51,10 +76,32 @@ export function initial_fetch(resource, opts) {
5176
}
5277

5378
const script = document.querySelector(selector);
54-
if (script && script.textContent) {
79+
if (script?.textContent) {
5580
const { body, ...init } = JSON.parse(script.textContent);
81+
82+
const ttl = script.getAttribute('data-ttl');
83+
if (ttl) cache.set(resolved, { body, init, ttl: 1000 * Number(ttl) });
84+
5685
return Promise.resolve(new Response(body, init));
5786
}
5887

5988
return native_fetch(resource, opts);
6089
}
90+
91+
/**
92+
* Tries to get the response from the cache, if max-age allows it, else does a fetch.
93+
* @param {string} resolved
94+
* @param {RequestInit} [opts]
95+
*/
96+
export function subsequent_fetch(resolved, opts) {
97+
const cached = cache.get(resolved);
98+
if (cached) {
99+
if (performance.now() < cached.ttl) {
100+
return new Response(cached.body, cached.init);
101+
}
102+
103+
cache.delete(resolved);
104+
}
105+
106+
return native_fetch(resolved, opts);
107+
}

packages/kit/src/runtime/server/page/fetch.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ export function create_fetch({ event, options, state, route, prerender_default }
214214

215215
fetched.push({
216216
url: requested,
217+
method: opts.method || 'GET',
217218
body: opts.body,
218219
response: {
219220
status: status_number,

packages/kit/src/runtime/server/page/render.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ export async function render_response({
284284
}
285285

286286
if (page_config.ssr && page_config.csr) {
287-
body += `\n\t${fetched.map(serialize_data).join('\n\t')}`;
287+
body += `\n\t${fetched.map((item) => serialize_data(item, !!state.prerendering)).join('\n\t')}`;
288288
}
289289

290290
if (options.service_worker) {

packages/kit/src/runtime/server/page/serialize_data.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,11 @@ const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
3535
* and that the resulting string isn't further modified.
3636
*
3737
* @param {import('./types.js').Fetched} fetched
38+
* @param {boolean} [prerendering]
3839
* @returns {string} The raw HTML of a script element carrying the JSON payload.
3940
* @example const html = serialize_data('/data.json', null, { foo: 'bar' });
4041
*/
41-
export function serialize_data(fetched) {
42+
export function serialize_data(fetched, prerendering = false) {
4243
const safe_payload = JSON.stringify(fetched.response).replace(
4344
pattern,
4445
(match) => replacements[match]
@@ -54,5 +55,18 @@ export function serialize_data(fetched) {
5455
attrs.push(`data-hash=${escape_html_attr(hash(fetched.body))}`);
5556
}
5657

58+
if (!prerendering && fetched.method === 'GET') {
59+
const cache_control = /** @type {string} */ (fetched.response.headers['cache-control']);
60+
if (cache_control) {
61+
const match = /s-maxage=(\d+)/g.exec(cache_control) ?? /max-age=(\d+)/g.exec(cache_control);
62+
if (match) {
63+
const age = /** @type {string} */ (fetched.response.headers['age']) ?? '0';
64+
65+
const ttl = +match[1] - +age;
66+
attrs.push(`data-ttl="${ttl}"`);
67+
}
68+
}
69+
}
70+
5771
return `<script ${attrs.join(' ')}>${safe_payload}</script>`;
5872
}

packages/kit/src/runtime/server/page/serialize_data.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ test('escapes slashes', () => {
66
assert.equal(
77
serialize_data({
88
url: 'foo',
9+
method: 'GET',
910
body: null,
1011
response: {
1112
status: 200,
@@ -24,6 +25,7 @@ test('escapes exclamation marks', () => {
2425
assert.equal(
2526
serialize_data({
2627
url: 'foo',
28+
method: 'GET',
2729
body: null,
2830
response: {
2931
status: 200,
@@ -44,6 +46,7 @@ test('escapes the attribute values', () => {
4446
assert.equal(
4547
serialize_data({
4648
url: raw,
49+
method: 'GET',
4750
body: null,
4851
response: {
4952
status: 200,

packages/kit/src/runtime/server/page/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { HttpError } from '../../control.js';
33

44
export interface Fetched {
55
url: string;
6+
method: string;
67
body?: string | null;
78
response: {
89
status: number;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** @type {import('./$types').PageLoad} */
2+
export async function load({ fetch }) {
3+
const resp = await fetch('/load/cache-control/count');
4+
return resp.json();
5+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script>
2+
import { invalidate } from '$app/navigation';
3+
4+
/** @type {import('./$types').PageData} */
5+
export let data;
6+
7+
async function fetch_again() {
8+
await fetch('/load/cache-control/increment');
9+
invalidate('/load/cache-control/count');
10+
}
11+
</script>
12+
13+
<p>Count is {data.count}</p>
14+
<button on:click={fetch_again}>Fetch again</button>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { json } from '@sveltejs/kit';
2+
import { count } from '../state.js';
3+
4+
export function GET({ setHeaders }) {
5+
setHeaders({ 'cache-control': 'public, max-age=4', age: '2' });
6+
return json({ count });
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { json } from '@sveltejs/kit';
2+
import { increment } from '../state.js';
3+
4+
export function GET() {
5+
increment();
6+
return json({});
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { json } from '@sveltejs/kit';
2+
import { reset } from '../state.js';
3+
4+
export function GET() {
5+
reset();
6+
return json({});
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export let count = 0;
2+
3+
export function increment() {
4+
return count++;
5+
}
6+
7+
export function reset() {
8+
count = 0;
9+
}

packages/kit/test/apps/basics/test/client.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,26 @@ test.describe('Load', () => {
381381
expect(await page.textContent('pre')).toBe(JSON.stringify({ foo: { bar: 'Custom layout' } }));
382382
});
383383

384+
test('load does not call fetch if max-age allows it', async ({ page, request }) => {
385+
await request.get('/load/cache-control/reset');
386+
387+
page.addInitScript(`
388+
window.now = 0;
389+
window.performance.now = () => now;
390+
`);
391+
392+
await page.goto('/load/cache-control');
393+
expect(await page.textContent('p')).toBe('Count is 0');
394+
await page.waitForTimeout(500);
395+
await page.click('button');
396+
await page.waitForTimeout(500);
397+
expect(await page.textContent('p')).toBe('Count is 0');
398+
399+
await page.evaluate(() => (window.now = 2500));
400+
await page.click('button');
401+
await expect(page.locator('p')).toHaveText('Count is 2');
402+
});
403+
384404
if (process.env.DEV) {
385405
test('using window.fetch causes a warning', async ({ page }) => {
386406
const port = 5173;

0 commit comments

Comments
 (0)