diff --git a/.changeset/nasty-buttons-invent.md b/.changeset/nasty-buttons-invent.md new file mode 100644 index 000000000000..ac7eb2c235d3 --- /dev/null +++ b/.changeset/nasty-buttons-invent.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Ensure props are loaded from matching endpoint during client-side navigation diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js index c0fb8321aefb..1045292bc3d3 100644 --- a/packages/kit/src/core/dev/plugin.js +++ b/packages/kit/src/core/dev/plugin.js @@ -107,6 +107,7 @@ export async function create_plugin(config, cwd) { if (route.type === 'page') { return { type: 'page', + key: route.key, pattern: route.pattern, params: get_params(route.params), shadow: route.shadow diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 00c03e029926..c28cd3a1063c 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -72,6 +72,7 @@ export function generate_manifest( if (route.type === 'page') { return `{ type: 'page', + key: ${s(route.key)}, pattern: ${route.pattern}, params: ${get_params(route.params)}, path: ${route.path ? s(route.path) : null}, diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index 51e6cf3ffdd6..b8674c8d8445 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -261,20 +261,19 @@ export default function create_manifest_data({ walk(config.kit.files.routes, [], [], [], [layout], [error]); - // merge matching page/endpoint pairs into shadowed pages + const lookup = new Map(); + for (const route of routes) { + if (route.type === 'page') { + lookup.set(route.key, route); + } + } + let i = routes.length; while (i--) { const route = routes[i]; - const prev = routes[i - 1]; - - if (prev && prev.key === route.key) { - if (prev.type !== 'endpoint' || route.type !== 'page') { - const relative = path.relative(cwd, path.resolve(config.kit.files.routes, prev.key)); - throw new Error(`Duplicate route files: ${relative}`); - } - - route.shadow = prev.file; - routes.splice(--i, 1); + if (route.type === 'endpoint' && lookup.has(route.key)) { + lookup.get(route.key).shadow = route.file; + routes.splice(i, 1); } } diff --git a/packages/kit/src/core/sync/write_manifest.js b/packages/kit/src/core/sync/write_manifest.js index 66527c3b43de..15a85079132c 100644 --- a/packages/kit/src/core/sync/write_manifest.js +++ b/packages/kit/src/core/sync/write_manifest.js @@ -48,7 +48,7 @@ export function write_manifest(manifest_data, base, output) { // optional items if (params || route.shadow) tuple.push(params || 'null'); - if (route.shadow) tuple.push('1'); + if (route.shadow) tuple.push(`'${route.key}'`); return `// ${route.a[route.a.length - 1]}\n\t\t[${tuple.join(', ')}]`; } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index af24e0c4d46c..6d7714ab4587 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -560,7 +560,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (cached) return cached; } - const [pattern, a, b, get_params, has_shadow] = route; + const [pattern, a, b, get_params, shadow_key] = route; const params = get_params ? // the pattern is for the route which we've already matched to this path get_params(/** @type {RegExpExecArray} */ (pattern.exec(path))) @@ -611,18 +611,23 @@ export function create_client({ target, session, base, trailing_slash }) { /** @type {Record} */ let props = {}; - const is_shadow_page = has_shadow && i === a.length - 1; + const is_shadow_page = shadow_key !== undefined && i === a.length - 1; if (is_shadow_page) { const res = await fetch( `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`, { headers: { - 'x-sveltekit-load': 'true' + 'x-sveltekit-load': /** @type {string} */ (shadow_key) } } ); + if (res.status === 204) { + // fallthrough + return; + } + if (res.ok) { const redirect = res.headers.get('x-sveltekit-location'); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a923a6364854..a8ffc66630d0 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -150,7 +150,17 @@ export async function respond(request, options, state = {}) { event.url = new URL(event.url.origin + normalized + event.url.search); } + // `key` will be set if this request came from a client-side navigation + // to a page with a matching endpoint + const key = request.headers.get('x-sveltekit-load'); + for (const route of options.manifest._.routes) { + if (key) { + // client is requesting data for a specific endpoint + if (route.type !== 'page') continue; + if (route.key !== key) continue; + } + const match = route.pattern.exec(decoded); if (!match) continue; @@ -163,7 +173,7 @@ export async function respond(request, options, state = {}) { response = await render_endpoint(event, await route.shadow()); // loading data for a client-side transition is a special case - if (request.headers.get('x-sveltekit-load') === 'true') { + if (key) { if (response) { // since redirects are opaque to the browser, we need to repackage // 3xx responses as 200s with a custom header @@ -180,9 +190,9 @@ export async function respond(request, options, state = {}) { } } } else { - // TODO ideally, the client wouldn't request this data - // in the first place (at least in production) - response = new Response('{}', { + // fallthrough + response = new Response(undefined, { + status: 204, headers: { 'content-type': 'application/json' } diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].js b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].js new file mode 100644 index 000000000000..458e9f018f74 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].js @@ -0,0 +1,15 @@ +/** @type {import('./[a]').RequestHandler} */ +export async function get({ params }) { + const param = params.a; + + if (param !== 'a') { + return { + fallthrough: true + }; + } + + return { + status: 200, + body: { param } + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].svelte b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].svelte new file mode 100644 index 000000000000..1d367966bb1e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].svelte @@ -0,0 +1,6 @@ + + +

a-{param}

diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].js b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].js new file mode 100644 index 000000000000..a7712b014dd3 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].js @@ -0,0 +1,15 @@ +/** @type {import('./[b]').RequestHandler} */ +export async function get({ params }) { + const param = params.b; + + if (param !== 'b') { + return { + fallthrough: true + }; + } + + return { + status: 200, + body: { param } + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].svelte b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].svelte new file mode 100644 index 000000000000..d216e0cbff2f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].svelte @@ -0,0 +1,6 @@ + + +

b-{param}

diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[c].svelte b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[c].svelte new file mode 100644 index 000000000000..53379894746e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[c].svelte @@ -0,0 +1 @@ +

c

diff --git a/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/index.svelte b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/index.svelte new file mode 100644 index 000000000000..8e3b7ad45318 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/index.svelte @@ -0,0 +1,3 @@ +fallthrough to shadow a +fallthrough to shadow b +fallthrough to no shadow c diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 821a36ba5429..308583cee590 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -504,6 +504,18 @@ test.describe.parallel('Shadowed pages', () => { await clicknav('[href="/shadowed/dynamic/bar"]'); expect(await page.textContent('h1')).toBe('slug: bar'); }); + + test('Shadow fallthrough to shadowed page', async ({ page, clicknav }) => { + await page.goto('/shadowed/fallthrough'); + await clicknav('[href="/shadowed/fallthrough/b"]'); + expect(await page.textContent('h2')).toBe('b-b'); + }); + + test('Shadow fallthrough to unshadowed page', async ({ page, clicknav }) => { + await page.goto('/shadowed/fallthrough'); + await clicknav('[href="/shadowed/fallthrough/c"]'); + expect(await page.textContent('h2')).toBe('c'); + }); }); test.describe.parallel('Endpoints', () => { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 139f0a950328..623896457153 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -71,7 +71,7 @@ export type CSRComponent = any; // TODO export type CSRComponentLoader = () => Promise; -export type CSRRoute = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?, HasShadow?]; +export type CSRRoute = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?, ShadowKey?]; export interface EndpointData { type: 'endpoint'; @@ -84,8 +84,6 @@ export interface EndpointData { export type GetParams = (match: RegExpExecArray) => Record; -type HasShadow = 1; - export interface Hooks { externalFetch: ExternalFetch; getSession: GetSession; @@ -179,6 +177,13 @@ export interface ShadowEndpointOutput { body?: Output; } +/** + * The route key of a page with a matching endpoint — used to ensure the + * client loads data from the right endpoint during client-side navigation + * rather than a different route that happens to match the path + */ +type ShadowKey = string; + export interface ShadowRequestHandler { (event: RequestEvent): MaybePromise, Fallthrough>>; } @@ -272,6 +277,7 @@ export interface SSROptions { export interface SSRPage { type: 'page'; + key: string; pattern: RegExp; params: GetParams; shadow: