diff --git a/.changeset/poor-gifts-cross.md b/.changeset/poor-gifts-cross.md new file mode 100644 index 000000000000..c8641e85b151 --- /dev/null +++ b/.changeset/poor-gifts-cross.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[feat] allow ssr to be configurable in +page.js and +layout.js diff --git a/documentation/docs/12-page-options.md b/documentation/docs/12-page-options.md index a00448f1078c..f0a58f9af181 100644 --- a/documentation/docs/12-page-options.md +++ b/documentation/docs/12-page-options.md @@ -2,9 +2,7 @@ title: Page options --- -By default, SvelteKit will render any component first on the server and send it to the client as HTML. It will then render the component again in the browser to make it interactive in a process called **hydration**. For this reason, you need to ensure that components can run in both places. SvelteKit will then initialise a [**router**](/docs/routing) that takes over subsequent navigations. - -You can control each of these on a per-app (via `svelte.config.js`) or per-page (via `+page.js` or `+page.server.js`) basis. If both are specified, per-page settings override per-app settings in case of conflicts. +By default, SvelteKit will render any component first on the server and send it to the client as HTML. It will then render the component again in the browser to make it interactive in a process called **hydration**. For this reason, you need to ensure that components can run in both places. SvelteKit will then initialise a [**router**](/docs/routing) that takes over subsequent navigations. You can control this behavior through several options: ### router @@ -13,23 +11,27 @@ SvelteKit includes a [client-side router](/docs/appendix#routing) that intercept In certain circumstances you might need to disable [client-side routing](/docs/appendix#routing) with the app-wide [`browser.router` config option](/docs/configuration#browser) or the page-level `router` export: ```js -/// file: +page.js/+page.server.js +/// file: +page.js/+page.js export const router = false; ``` Note that this will disable client-side routing for any navigation from this page, regardless of whether the router is already active. +You can control this setting on a per-app (via `svelte.config.js`) or per-page (via `+page.js`) basis. If both are specified, per-page settings override per-app settings in case of conflicts. + ### hydrate Ordinarily, SvelteKit [hydrates](/docs/appendix#hydration) your server-rendered HTML into an interactive page. Some pages don't require JavaScript at all — many blog posts and 'about' pages fall into this category. In these cases you can skip hydration when the app boots up with the app-wide [`browser.hydrate` config option](/docs/configuration#browser) or the page-level `hydrate` export: ```js -/// file: +page.js/+page.server.js +/// file: +page.js/+page.js export const hydrate = false; ``` > If `hydrate` and `router` are both `false`, SvelteKit will not add any JavaScript to the page at all. If [server-side rendering](/docs/hooks#handle) is disabled in `handle`, `hydrate` must be `true` or no content will be rendered. +You can control this setting on a per-app (via `svelte.config.js`) or per-page (via `+page.js`) basis. If both are specified, per-page settings override per-app settings in case of conflicts. + ### prerender It's likely that at least some pages of your app can be represented as a simple HTML file generated at build time. These pages can be [_prerendered_](/docs/appendix#prerendering). @@ -52,6 +54,8 @@ export const prerender = false; The prerenderer will start at the root of your app and generate HTML for any prerenderable pages it finds. Each page is scanned for `` elements that point to other pages that are candidates for prerendering — because of this, you generally don't need to specify which pages should be accessed. If you _do_ need to specify which pages should be accessed by the prerenderer, you can do so with the `entries` option in the [prerender configuration](/docs/configuration#prerender). +You can control this setting on a per-app (via `svelte.config.js`) or per-page (via `+page.js` or `+page.server.js`) basis. If both are specified, per-page settings override per-app settings in case of conflicts. + #### When not to prerender The basic rule is this: for a page to be prerenderable, any two users hitting it directly must get the same content from the server. @@ -69,3 +73,16 @@ Because prerendering writes to the filesystem, it isn't possible to have two end For that reason among others, it's recommended that you always include a file extension — `src/routes/foo.json/+server.js` and `src/routes/foo/bar.json/+server.js` would result in `foo.json` and `foo/bar.json` files living harmoniously side-by-side. For _pages_, we skirt around this problem by writing `foo/index.html` instead of `foo`. + +### ssr + +Normally, SvelteKit renders your page on the server first and sends that HTML to the client where it's hydrated. If you set `ssr` to `false`, it renders an empty 'shell' page instead. This is useful if your page accesses browser-only methods or objects, but in most situations it's not recommended ([see appendix](/docs/appendix#ssr)). + +```js +/// file: +page.js/+page.js +export const ssr = false; +``` + +In contrast to the other options, you can set this option in both `+page.js` and `+layout.js`. `ssr` options in subsequent layouts or the page overwrite earlier options. You cannot set this option in `+page.server.js` or `+layout.server.js`. This option does not take effect if the `ssr` option [in the handle hook](/docs/hooks#handle) is evaluated to `false`. + +> Why two `ssr` options? The `ssr` option in the handle hook is useful if your `+page.js` is already eagerly accessing browser-only objects, which means the server would crash while trying to evaluate the `ssr` option. diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 744af2f2d15c..70c8862b3913 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -1,7 +1,7 @@ import { negotiate } from '../../../utils/http.js'; import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; -import { method_not_allowed, error_to_pojo, allowed_methods } from '../utils.js'; +import { method_not_allowed, error_to_pojo, allowed_methods, load_ssr_node } from '../utils.js'; import { create_fetch } from './fetch.js'; import { HttpError, Redirect } from '../../../index/private.js'; import { error, json } from '../../../index/index.js'; @@ -48,15 +48,30 @@ export async function render_page(event, route, options, state, resolve_opts) { } const { fetcher, fetched, cookies } = create_fetch({ event, options, state, route }); + const is_get_request = event.request.method === 'GET' || event.request.method === 'HEAD'; + + if (is_get_request && !resolve_opts.ssr) { + return await spa_response(200); + } try { + /** @type {Array} */ const nodes = await Promise.all([ // we use == here rather than === because [undefined] serializes as "[null]" - ...route.layouts.map((n) => (n == undefined ? n : options.manifest._.nodes[n]())), - options.manifest._.nodes[route.leaf]() + ...route.layouts.map(async (n) => (n == undefined ? n : load_ssr_node(options.manifest, n))), + load_ssr_node(options.manifest, route.leaf) ]); - const leaf_node = /** @type {import('types').SSRNode} */ (nodes.at(-1)); + resolve_opts = { + ...resolve_opts, + ssr: + nodes.reduce( + (ssr, node) => node?.shared?.ssr ?? ssr, + /** @type {boolean|undefined} */ (undefined) + ) ?? resolve_opts.ssr + }; + + const leaf_node = /** @type {import('types').LoadedSSRNode} */ (nodes.at(-1)); let status = 200; @@ -66,7 +81,7 @@ export async function render_page(event, route, options, state, resolve_opts) { /** @type {Record | undefined} */ let validation_errors; - if (leaf_node.server && event.request.method !== 'GET' && event.request.method !== 'HEAD') { + if (leaf_node.server && !is_get_request) { // for non-GET requests, first call handler in +page.server.js // (this also determines status code) try { @@ -103,22 +118,7 @@ export async function render_page(event, route, options, state, resolve_opts) { const data_pathname = `${event.url.pathname.replace(/\/$/, '')}/__data.json`; if (!resolve_opts.ssr) { - return await render_response({ - branch: [], - validation_errors: undefined, - fetched, - cookies, - page_config: { - hydrate: true, - router: true - }, - status, - error: null, - event, - options, - state, - resolve_opts - }); + return await spa_response(status); } const should_prerender = @@ -246,7 +246,7 @@ export async function render_page(event, route, options, state, resolve_opts) { while (i--) { if (route.errors[i]) { const index = /** @type {number} */ (route.errors[i]); - const node = await options.manifest._.nodes[index](); + const node = await load_ssr_node(options.manifest, index); let j = i; while (!branch[j]) j -= 1; @@ -304,7 +304,10 @@ export async function render_page(event, route, options, state, resolve_opts) { options, state, resolve_opts, - page_config: get_page_config(leaf_node, options), + page_config: { + router: leaf_node.shared?.router ?? options.router, + hydrate: leaf_node.shared?.hydrate ?? options.hydrate + }, status, error: null, branch: compact(branch), @@ -326,30 +329,34 @@ export async function render_page(event, route, options, state, resolve_opts) { resolve_opts }); } -} -/** - * @param {import('types').SSRNode} leaf - * @param {SSROptions} options - */ -function get_page_config(leaf, options) { - // TODO we can reinstate this now that it's in the module - if (leaf.shared && 'ssr' in leaf.shared) { - throw new Error( - '`export const ssr` has been removed — use the handle hook instead: https://kit.svelte.dev/docs/hooks#handle' - ); + /** + * @param {number} status + */ + async function spa_response(status) { + return await render_response({ + branch: [], + validation_errors: undefined, + fetched, + cookies, + page_config: { + hydrate: true, + router: true + }, + status, + error: null, + event, + options, + state, + resolve_opts + }); } - - return { - router: leaf.shared?.router ?? options.router, - hydrate: leaf.shared?.hydrate ?? options.hydrate - }; } /** * @param {import('types').RequestEvent} event * @param {import('types').SSROptions} options - * @param {import('types').SSRNode['server']} mod + * @param {Required['server']} mod */ export async function handle_json_request(event, options, mod) { const method = /** @type {'POST' | 'PUT' | 'PATCH' | 'DELETE'} */ (event.request.method); diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 2150b5f202d8..a319e308d0cc 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -5,7 +5,7 @@ import { LoadURL, PrerenderingURL } from '../../../utils/url.js'; * @param {{ * dev: boolean; * event: import('types').RequestEvent; - * node: import('types').SSRNode | undefined; + * node: import('types').SSRNode | import('types').LoadedSSRNode | undefined; * parent: () => Promise>; * }} opts * @returns {Promise} @@ -78,7 +78,7 @@ export async function load_server_data({ dev, event, node, parent }) { * @param {{ * event: import('types').RequestEvent; * fetcher: typeof fetch; - * node: import('types').SSRNode | undefined; + * node: import('types').LoadedSSRNode | undefined; * parent: () => Promise>; * server_data_promise: Promise; * state: import('types').SSRState; diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 1802881f2c81..d86ab8da2e63 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -1,7 +1,7 @@ import { render_response } from './render.js'; import { load_data, load_server_data } from './load_data.js'; import { coalesce_to_error } from '../../../utils/error.js'; -import { GENERIC_ERROR } from '../utils.js'; +import { GENERIC_ERROR, load_ssr_node } from '../utils.js'; import { create_fetch } from './fetch.js'; /** @@ -32,7 +32,7 @@ export async function respond_with_error({ event, options, state, status, error, const branch = []; if (resolve_opts.ssr) { - const default_layout = await options.manifest._.nodes[0](); // 0 is always the root layout + const default_layout = await load_ssr_node(options.manifest, 0); // 0 is always the root layout const server_data_promise = load_server_data({ dev: options.dev, @@ -59,7 +59,7 @@ export async function respond_with_error({ event, options, state, status, error, data }, { - node: await options.manifest._.nodes[1](), // 1 is always the root error + node: await load_ssr_node(options.manifest, 1), // 1 is always the root error data: null, server_data: null } diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index c3230ddf995a..1eb64caa1462 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -1,4 +1,4 @@ -import { ResponseHeaders, SSRNode, CspDirectives } from 'types'; +import { ResponseHeaders, CspDirectives, LoadedSSRNode } from 'types'; import { HttpError } from '../../../index/private'; export interface Fetched { @@ -19,7 +19,7 @@ export interface FetchState { } export type Loaded = { - node: SSRNode; + node: LoadedSSRNode; data: Record | null; server_data: any; }; diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 27fa3abebff5..8458703cc7d7 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -114,3 +114,16 @@ export function allowed_methods(mod) { return allowed; } + +/** + * @param {import('types').SSRManifest} manifest + * @param {number} idx + * @returns {Promise} + */ +export async function load_ssr_node(manifest, idx) { + const node = await manifest._.nodes[idx](); + return { + ...node, + shared: node.shared && (await node.shared()) + }; +} diff --git a/packages/kit/src/vite/build/build_server.js b/packages/kit/src/vite/build/build_server.js index cb126b83c40d..782d60f0ca68 100644 --- a/packages/kit/src/vite/build/build_server.js +++ b/packages/kit/src/vite/build/build_server.js @@ -268,8 +268,9 @@ export async function build_server(options, client) { imported.push(...entry.imports); stylesheets.push(...entry.stylesheets); - imports.push(`import * as shared from '../${vite_manifest[node.shared].file}';`); - exports.push(`export { shared };`); + exports.push( + `export const shared = async () => (await import('../${vite_manifest[node.shared].file}'));` + ); } if (node.server) { diff --git a/packages/kit/src/vite/dev/index.js b/packages/kit/src/vite/dev/index.js index 9cbfb128e65d..25195cc8bb29 100644 --- a/packages/kit/src/vite/dev/index.js +++ b/packages/kit/src/vite/dev/index.js @@ -97,13 +97,14 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { } if (node.shared) { - const { module, module_node } = await resolve(node.shared); - - module_nodes.push(module_node); + result.shared = async () => { + const { module, module_node } = await resolve(/** @type {string} */ (node.shared)); + module_nodes.push(module_node); - result.shared = module; + prevent_illegal_vite_imports(module_node, illegal_imports, extensions); - prevent_illegal_vite_imports(module_node, illegal_imports, extensions); + return module; + }; } if (node.server) { diff --git a/packages/kit/test/apps/basics/src/hooks.js b/packages/kit/test/apps/basics/src/hooks.js index d6b9caa9d274..2e865395ac3f 100644 --- a/packages/kit/test/apps/basics/src/hooks.js +++ b/packages/kit/test/apps/basics/src/hooks.js @@ -33,7 +33,9 @@ export const handle = sequence( } const response = await resolve(event, { - ssr: !event.url.pathname.startsWith('/no-ssr'), + ssr: + !event.url.pathname.startsWith('/no-ssr') || + event.url.pathname.startsWith('/no-ssr/ssr-page-config'), transformPageChunk: event.url.pathname.startsWith('/transform-page-chunk') ? ({ html }) => html.replace('__REPLACEME__', 'Worked!') : undefined diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.js b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.js new file mode 100644 index 000000000000..55a08ad14787 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.js @@ -0,0 +1,2 @@ +document; +export function load() {} diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte index 2e48a4f8ebd7..560f8bab641b 100644 --- a/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/browser-only-global/+page.svelte @@ -2,4 +2,6 @@ document; +{document} +

Works

diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.js b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.js new file mode 100644 index 000000000000..a3d15781a772 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.js @@ -0,0 +1 @@ +export const ssr = false; diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.svelte new file mode 100644 index 000000000000..560f8bab641b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/+page.svelte @@ -0,0 +1,7 @@ + + +{document} + +

Works

diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.js b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.js new file mode 100644 index 000000000000..a3d15781a772 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.js @@ -0,0 +1 @@ +export const ssr = false; diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.svelte new file mode 100644 index 000000000000..0385342cef1b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/+layout.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/inherit/+page.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/inherit/+page.svelte new file mode 100644 index 000000000000..560f8bab641b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/inherit/+page.svelte @@ -0,0 +1,7 @@ + + +{document} + +

Works

diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.js b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.js new file mode 100644 index 000000000000..77ab0a02eb8e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.js @@ -0,0 +1 @@ +export const ssr = true; diff --git a/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.svelte b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.svelte new file mode 100644 index 000000000000..b5e77a2d9967 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/no-ssr/ssr-page-config/layout/overwrite/+page.svelte @@ -0,0 +1,7 @@ + + +{document} + +

You shouldn't see this

diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 8e43d8711ef7..51dab681a89d 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -580,10 +580,42 @@ test.describe('Shadow DOM', () => { }); }); -test('Can use browser-only global on client-only page', async ({ page, read_errors }) => { - await page.goto('/no-ssr/browser-only-global'); - await expect(page.locator('p')).toHaveText('Works'); - expect(read_errors('/no-ssr/browser-only-global')).toBe(undefined); +test.describe('SPA mode / no SSR', () => { + test('Can use browser-only global in +page.svelte and +page.js through ssr config in handle', async ({ + page, + read_errors + }) => { + await page.goto('/no-ssr/browser-only-global'); + await expect(page.locator('p')).toHaveText('Works'); + expect(read_errors('/no-ssr/browser-only-global')).toBe(undefined); + }); + + test('Can use browser-only global in client-only page through ssr config in layout.js', async ({ + page, + read_errors + }) => { + await page.goto('/no-ssr/ssr-page-config'); + await expect(page.locator('p')).toHaveText('Works'); + expect(read_errors('/no-ssr/ssr-page-config')).toBe(undefined); + }); + + test('Can use browser-only global in client-only page through ssr config in page.js', async ({ + page, + read_errors + }) => { + await page.goto('/no-ssr/ssr-page-config/layout/inherit'); + await expect(page.locator('p')).toHaveText('Works'); + expect(read_errors('/no-ssr/ssr-page-config/layout/inherit')).toBe(undefined); + }); + + test('Cannot use browser-only global in page because of ssr config in page.js', async ({ + page + }) => { + await page.goto('/no-ssr/ssr-page-config/layout/overwrite'); + await expect(page.locator('p')).toHaveText( + 'This is your custom error page saying: "document is not defined"' + ); + }); }); test('can use $app/stores from anywhere on client', async ({ page }) => { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 2e43e299baff..02a330e098c7 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -238,6 +238,7 @@ export interface SSREndpoint { } export interface SSRNode { + /** The component to render. Lazy-loaded due to ssr option */ component: SSRComponentLoader; /** index into the `components` array in client-manifest.js */ index: number; @@ -250,14 +251,16 @@ export interface SSRNode { /** inlined styles */ inline_styles?: () => MaybePromise>; - shared: { + /** The `+page/layout.js` file. Lazy-loaded due to ssr option */ + shared?: () => Promise<{ load?: Load; hydrate?: boolean; prerender?: boolean; router?: boolean; - }; + ssr?: boolean; + }>; - server: { + server?: { load?: ServerLoad; prerender?: boolean; POST?: Action; @@ -270,6 +273,10 @@ export interface SSRNode { server_id?: string; } +export interface LoadedSSRNode extends Omit { + shared?: Awaited['shared']>>; +} + export type SSRNodeLoader = () => Promise; export interface SSROptions {