diff --git a/packages/kit/src/core/create_app/index.js b/packages/kit/src/core/create_app/index.js index 74c565843098..359ec5826889 100644 --- a/packages/kit/src/core/create_app/index.js +++ b/packages/kit/src/core/create_app/index.js @@ -86,8 +86,8 @@ function generate_client_manifest(manifest_data, base) { // 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/core/create_manifest_data/index.js b/packages/kit/src/core/create_manifest_data/index.js index de72e262c05b..4322e2738717 100644 --- a/packages/kit/src/core/create_manifest_data/index.js +++ b/packages/kit/src/core/create_manifest_data/index.js @@ -261,22 +261,25 @@ export default function create_manifest_data({ walk(config.kit.files.routes, [], [], [], [layout], [error]); - // merge matching page/endpoint pairs into shadowed pages - 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); + const pages = new Map(); + /** @type {number[]} */ + const endpoints = []; + routes.forEach((route, i) => { + if (route.type === 'page') { + pages.set(route.key, route); + } else { + endpoints.unshift(i); } - } + }); + endpoints.forEach((i) => { + const endpoint = routes[i]; + const page = pages.get(endpoint.key); + if (page) { + // @ts-ignore + page.shadow = endpoint.file; + routes.splice(i, 1); + } + }); const assets = fs.existsSync(config.kit.files.assets) ? list_files({ config, dir: config.kit.files.assets, path: '' }) diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js index 9c23b328007d..ba0fd4293163 100644 --- a/packages/kit/src/core/dev/plugin.js +++ b/packages/kit/src/core/dev/plugin.js @@ -106,6 +106,7 @@ export async function create_plugin(config, cwd) { routes: manifest_data.routes.map((route) => { if (route.type === 'page') { return { + key: route.key, type: 'page', pattern: route.pattern, params: get_params(route.params), @@ -119,7 +120,6 @@ export async function create_plugin(config, cwd) { b: route.b.map((id) => manifest_data.components.indexOf(id)) }; } - return { type: 'endpoint', pattern: route.pattern, diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 00c03e029926..6e841cc1c437 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -71,6 +71,7 @@ export function generate_manifest( ${routes.map(route => { if (route.type === 'page') { return `{ + key: '${route.key}', type: 'page', pattern: ${route.pattern}, params: ${get_params(route.params)}, diff --git a/packages/kit/src/runtime/client/renderer.js b/packages/kit/src/runtime/client/renderer.js index 9485b78287cd..0708a2b8f8c6 100644 --- a/packages/kit/src/runtime/client/renderer.js +++ b/packages/kit/src/runtime/client/renderer.js @@ -724,17 +724,19 @@ export class Renderer { /** * @param {import('./types').NavigationCandidate} selected * @param {boolean} no_cache + * @param {string} [fallthrough_target] + * @param {Record} [fallthrough_props] * @returns {Promise} undefined if fallthrough */ - async _load({ route, info: { url, path } }, no_cache) { + async _load({ route, info }, no_cache, fallthrough_target, fallthrough_props) { + const { url, path, routes } = info; const key = url.pathname + url.search; - if (!no_cache) { const cached = this.cache.get(key); 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))) @@ -785,33 +787,49 @@ export class Renderer { /** @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' + if (fallthrough_target !== undefined && shadow_key === fallthrough_target) { + if (fallthrough_props) props = fallthrough_props; + } else { + const res = await fetch( + `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`, + { + // @ts-ignore + headers: { + 'x-sveltekit-load': shadow_key + } } + ); + if (res.ok) { + const redirect = res.headers.get('x-sveltekit-location'); + const route_index = res.headers.get('x-sveltekit-load'); + if (redirect) { + return { + redirect, + props: {}, + state: this.current + }; + } + // '.' means fallthrough from server-side not match any router + if (route_index === '-1') return; + props = res.status === 204 ? {} : await res.json(); + if (route_index) { + const next_route = routes[+route_index]; + if (next_route) { + return await this._load( + { route: next_route, info }, + no_cache, + next_route[4], + props + ); + } + } + } else { + status = res.status; + error = new Error('Failed to load data'); } - ); - - if (res.ok) { - const redirect = res.headers.get('x-sveltekit-location'); - - if (redirect) { - return { - redirect, - props: {}, - state: this.current - }; - } - - props = await res.json(); - } else { - status = res.status; - error = new Error('Failed to load data'); } } diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index eb26351dca46..ba897dc94861 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -151,26 +151,29 @@ export async function respond(request, options, state = {}) { event.url = new URL(event.url.origin + normalized + event.url.search); } + const shadow_key = request.headers.get('x-sveltekit-load'); + let from_fallthrough = false; + let route_index = -1; + for (const route of options.manifest._.routes) { const match = route.pattern.exec(decoded); if (!match) continue; - event.params = route.params ? decode_params(route.params(match)) : {}; /** @type {Response | undefined} */ let response; - - if (is_data_request && route.type === 'page' && route.shadow) { - 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') { + const is_page = route.type === 'page'; + if (is_page) route_index++; + if (is_data_request && is_page && route.shadow) { + if (!from_fallthrough && shadow_key && shadow_key !== route.key) continue; + if (route.shadow) { + response = await render_endpoint(event, await route.shadow()); + // loading data for a client-side transition is a special case if (response) { // since redirects are opaque to the browser, we need to repackage // 3xx responses as 200s with a custom header if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); - if (location) { const headers = new Headers(response.headers); headers.set('x-sveltekit-location', location); @@ -180,21 +183,39 @@ export async function respond(request, options, state = {}) { }); } } + if (from_fallthrough && response.ok) { + const headers = new Headers(response.headers); + // client-site will fallthrough to the route + headers.set('x-sveltekit-load', `${route_index}`); + response = new Response(response.body, { + status: response.status, + headers + }); + } } else { - // TODO ideally, the client wouldn't request this data - // in the first place (at least in production) - response = new Response('{}', { - headers: { - 'content-type': 'application/json' - } - }); + // continue to next match page router + // if next page has shadow , fallthrough at serve-site + from_fallthrough = true; + continue; } } } else { - response = - route.type === 'endpoint' - ? await render_endpoint(event, await route.load()) - : await render_page(event, route, options, state, resolve_opts); + if (is_page) { + if (from_fallthrough) { + const headers = new Headers({ + 'x-sveltekit-load': `${route_index}` + }); + // next match not a shadow so fallthrough at client-side + return new Response(undefined, { + headers, + status: 204 + }); + } + response = await render_page(event, route, options, state, resolve_opts); + } else { + if (shadow_key || from_fallthrough) continue; + response = await render_endpoint(event, await route.load()); + } } if (response) { @@ -230,11 +251,22 @@ export async function respond(request, options, state = {}) { }); } } - return response; } } + // no match route + if (from_fallthrough) { + const headers = new Headers({ + 'x-sveltekit-load': '-1' + }); + // next match not a shadow so fallthrough at client-side + return new Response(undefined, { + headers, + status: 204 + }); + } + // if this request came direct from the user, rather than // via a `fetch` in a `load`, render a 404 page if (!state.initiator) { @@ -261,7 +293,6 @@ export async function respond(request, options, state = {}) { throw new Error('request in handle has been replaced with event' + details); } }); - // TODO for 1.0, change the error message to point to docs rather than PR if (response && !(response instanceof Response)) { throw new Error('handle must return a Response object' + details); 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..7411460dc0e1 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].js @@ -0,0 +1,14 @@ +/** @type {import('@sveltejs/kit').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..927c921aea92 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[a].svelte @@ -0,0 +1,5 @@ + +

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..c49b369ea58c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].js @@ -0,0 +1,14 @@ +/** @type {import('@sveltejs/kit').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..c26ad86352bd --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shadowed/fallthrough/[b].svelte @@ -0,0 +1,5 @@ + +

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 bcf6b02546ad..be040856e25e 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 shadow', 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 no_shadow', 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 fff9f240fcd1..46eb42a236db 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?, string?]; export interface EndpointData { type: 'endpoint'; @@ -267,6 +267,7 @@ export interface SSROptions { } export interface SSRPage { + key: string; type: 'page'; pattern: RegExp; params: GetParams;