diff --git a/.changeset/bright-tips-beam.md b/.changeset/bright-tips-beam.md new file mode 100644 index 000000000000..831f230aa994 --- /dev/null +++ b/.changeset/bright-tips-beam.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Added a "never" value to the config.kit.ssr option that prevents pages from being evaluated on the server on both ssr and prerendering. diff --git a/documentation/docs/11-ssr-and-javascript.md b/documentation/docs/11-ssr-and-javascript.md index fe9b3e0721a7..9e4315bb6993 100644 --- a/documentation/docs/11-ssr-and-javascript.md +++ b/documentation/docs/11-ssr-and-javascript.md @@ -14,7 +14,7 @@ Disabling [server-side rendering](#appendix-ssr) effectively turns your SvelteKi > In most situations this is not recommended: see [the discussion in the appendix](#appendix-ssr). Consider whether it's truly appropriate to disable and don't simply disable SSR because you've hit an issue with it. -You can disable SSR app-wide with the [`ssr` config option](#configuration-ssr), or a page-level `ssr` export: +You can disable SSR on a page-level with a `ssr` export. Page-level `ssr` exports must be boolean values, if another value is provided it will be cast into a boolean. ```html ``` +You can also disable SSR app-wide with the [`ssr` config option](#configuration-ssr), using a boolean or `"never"`. In case you use `"never"`, all page-level `ssr` exports will be completely ignored regardless of their value, only consider using this if you are sure you don't need SSR. + ### router SvelteKit includes a [client-side router](#appendix-routing) that intercepts navigations (from the user clicking on links, or interacting with the back/forward buttons) and updates the page contents, rather than letting the browser handle the navigation by reloading. diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md index e7e8d5a775e1..fbebd31ef803 100644 --- a/documentation/docs/14-configuration.md +++ b/documentation/docs/14-configuration.md @@ -199,7 +199,8 @@ An object containing zero or more of the following values: ### ssr -Enables or disables [server-side rendering](#ssr-and-javascript-ssr) app-wide. +- `true` or `false` — Enables or disables [server-side rendering](#ssr-and-javascript-ssr) app-wide and allows pages to override it locally. +- `"never"` — Prevents all pages from being evaluated on the server, on both [server-side rendering](#ssr-and-javascript-ssr) and [prerendering](#ssr-and-javascript-prerender), can't be overriden by page-level exports. Only consider using this option if you don't need SSR (like when building an SPA). ### target diff --git a/documentation/faq/80-integrations.md b/documentation/faq/80-integrations.md index 7b7d866c5fa7..0d2005318604 100644 --- a/documentation/faq/80-integrations.md +++ b/documentation/faq/80-integrations.md @@ -85,6 +85,24 @@ onMount(() => { }); ``` +But if your app doesn't use SSR, you can set the `ssr` option to `'never'`: + +```js +export default { + kit: { + // ... + ssr: 'never' + // ... + } +} +``` + +```js +import { method } from 'some-browser-only-library'; + +method('hello world!'); + +``` ### How do I use Firebase? Please use SDK v9 which provides a modular SDK approach that's currently in beta. The old versions are very difficult to get working especially with SSR and also resulted in a much larger client download size. Even with v9, most users need to set `kit.ssr: false` until [vite#4425](https://github.com/vitejs/vite/issues/4425) and [firebase-js-sdk#4846](https://github.com/firebase/firebase-js-sdk/issues/4846) are solved. diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 0921e1a809a0..a3d215cffdcb 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -259,32 +259,35 @@ async function build_server( } } + const allow_ssr = config.kit.ssr !== 'never'; + /** @type {Record} */ const metadata_lookup = {}; - - manifest.components.forEach((file) => { - const js_deps = new Set(); - const css_deps = new Set(); - - find_deps(file, js_deps, css_deps); - - const js = Array.from(js_deps); - const css = Array.from(css_deps); - - const styles = config.kit.amp - ? Array.from(css_deps).map((url) => { - const resolved = `${output_dir}/client/${config.kit.appDir}/${url}`; - return fs.readFileSync(resolved, 'utf-8'); - }) - : []; - - metadata_lookup[file] = { - entry: client_manifest[file].file, - css, - js, - styles - }; - }); + if (allow_ssr) { + manifest.components.forEach((file) => { + const js_deps = new Set(); + const css_deps = new Set(); + + find_deps(file, js_deps, css_deps); + + const js = Array.from(js_deps); + const css = Array.from(css_deps); + + const styles = config.kit.amp + ? Array.from(css_deps).map((url) => { + const resolved = `${output_dir}/client/${config.kit.appDir}/${url}`; + return fs.readFileSync(resolved, 'utf-8'); + }) + : []; + + metadata_lookup[file] = { + entry: client_manifest[file].file, + css, + js, + styles + }; + }); + } /** @type {Set} */ const entry_js = new Set(); @@ -412,21 +415,22 @@ async function build_server( externalFetch: hooks.externalFetch || fetch }); - const module_lookup = { - ${manifest.components.map(file => `${s(file)}: () => import(${s(app_relative(file))})`)} + ${allow_ssr ? + `const module_lookup = { + ${manifest.components.map((file) => `${s(file)}: () => import(${s(app_relative(file))})`)} }; - - const metadata_lookup = ${s(metadata_lookup)}; + const metadata_lookup = ${s(metadata_lookup)};` : ''} async function load_component(file) { - const { entry, css, js, styles } = metadata_lookup[file]; + ${allow_ssr ? + `const { entry, css, js, styles } = metadata_lookup[file]; return { module: await module_lookup[file](), entry: assets + ${s(prefix)} + entry, css: css.map(dep => assets + ${s(prefix)} + dep), js: js.map(dep => assets + ${s(prefix)} + dep), styles - }; + };` : 'throw new Error(`Cannot evaluate pages on the server when config.kit.ssr is "never". (${file})`)'} } export function render(request, { diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 1a76bb14e113..fe857434e377 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -165,7 +165,7 @@ const options = object( files: fun((filename) => !/\.DS_STORE/.test(filename)) }), - ssr: boolean(true), + ssr: list([true, false, 'never']), target: string(null), @@ -275,16 +275,21 @@ function boolean(fallback) { } /** - * @param {string[]} options + * @param {unknown[]} options * @returns {Validator} */ function list(options, fallback = options[0]) { return validate(fallback, (input, keypath) => { + /** @param {unknown} i */ + const stringify = (i) => (typeof i === 'string' ? `"${i}"` : `${i}`); if (!options.includes(input)) { - // prettier-ignore - const msg = options.length > 2 - ? `${keypath} should be one of ${options.slice(0, -1).map(input => `"${input}"`).join(', ')} or "${options[options.length - 1]}"` - : `${keypath} should be either "${options[0]}" or "${options[1]}"`; + const msg = + options.length > 2 + ? `${keypath} should be one of ${options + .slice(0, -1) + .map(stringify) + .join(', ')} or ${stringify(options[options.length - 1])}` + : `${keypath} should be either ${stringify(options[0])} or ${stringify(options[1])}`; throw new Error(msg); } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index ea126a86d741..29a535e6c5c8 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -12,7 +12,11 @@ const s = JSON.stringify; * branch: Array; * options: import('types/internal').SSRRenderOptions; * $session: any; - * page_config: { hydrate: boolean, router: boolean, ssr: boolean }; + * page_config: { + * hydrate: boolean, + * router: boolean, + * ssr: import('types/internal').SSROption + * }; * status: number; * error?: Error, * page?: import('types/page').Page @@ -43,7 +47,8 @@ export async function render_response({ error.stack = options.get_stack(error); } - if (page_config.ssr) { + // excludes false and 'never' + if (page_config.ssr === true) { branch.forEach(({ node, loaded, fetched, uses_credentials }) => { if (node.css) node.css.forEach((url) => css.add(url)); if (node.js) node.js.forEach((url) => js.add(url)); @@ -124,9 +129,9 @@ export async function render_response({ })}, host: ${page && page.host ? s(page.host) : 'location.host'}, route: ${!!page_config.router}, - spa: ${!page_config.ssr}, + spa: ${page_config.ssr !== true}, trailing_slash: ${s(options.trailing_slash)}, - hydrate: ${page_config.ssr && page_config.hydrate ? `{ + hydrate: ${page_config.ssr === true && page_config.hydrate ? `{ status: ${status}, error: ${serialize_error(error)}, nodes: [ diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js index 5e260ce04225..fafa5ecf4323 100644 --- a/packages/kit/src/runtime/server/page/respond.js +++ b/packages/kit/src/runtime/server/page/respond.js @@ -24,6 +24,19 @@ import { coalesce_to_error } from '../../../utils/error.js'; */ export async function respond(opts) { const { request, options, state, $session, route } = opts; + if (options.ssr === 'never') { + return await render_response({ + branch: [], + $session, + options, + page_config: { + hydrate: true, + router: true, + ssr: false + }, + status: 200 + }); + } /** @type {Array} */ let nodes; @@ -227,7 +240,7 @@ export async function respond(opts) { */ function get_page_config(leaf, options) { return { - ssr: 'ssr' in leaf ? !!leaf.ssr : options.ssr, + ssr: 'ssr' in leaf ? !!leaf.ssr : options.ssr === true, router: 'router' in leaf ? !!leaf.router : options.router, hydrate: 'hydrate' in leaf ? !!leaf.hydrate : options.hydrate }; 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 986a788bb37e..481fdc283e34 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -20,9 +20,6 @@ import { coalesce_to_error } from '../../../utils/error.js'; * }} opts */ export async function respond_with_error({ request, options, state, $session, status, error }) { - const default_layout = await options.load_component(options.manifest.layout); - const default_error = await options.load_component(options.manifest.error); - const page = { host: request.host, path: request.path, @@ -30,43 +27,51 @@ export async function respond_with_error({ request, options, state, $session, st params: {} }; - // error pages don't fall through, so we know it's not undefined - const loaded = /** @type {Loaded} */ ( - await load_node({ - request, - options, - state, - route: null, - page, - node: default_layout, - $session, - stuff: {}, - prerender_enabled: is_prerender_enabled(options, default_error, state), - is_leaf: false, - is_error: false - }) - ); + /** @type {Loaded[]} */ + let branch = []; - const branch = [ - loaded, - /** @type {Loaded} */ ( + if (options.ssr !== 'never') { + const default_layout = await options.load_component(options.manifest.layout); + const default_error = await options.load_component(options.manifest.error); + + // error pages don't fall through, so we know it's not undefined + const loaded = /** @type {Loaded} */ ( await load_node({ request, options, state, route: null, page, - node: default_error, + node: default_layout, $session, - stuff: loaded ? loaded.stuff : {}, + stuff: {}, prerender_enabled: is_prerender_enabled(options, default_error, state), is_leaf: false, - is_error: true, - status, - error + is_error: false }) - ) - ]; + ); + + branch = [ + loaded, + /** @type {Loaded} */ ( + await load_node({ + request, + options, + state, + route: null, + page, + node: default_error, + $session, + stuff: loaded ? loaded.stuff : {}, + prerender_enabled: is_prerender_enabled(options, default_error, state), + is_leaf: false, + is_error: true, + status, + error + }) + ) + ]; + } try { return await render_response({ diff --git a/packages/kit/test/apps/spa/package.json b/packages/kit/test/apps/spa/package.json new file mode 100644 index 000000000000..b75528dfea7f --- /dev/null +++ b/packages/kit/test/apps/spa/package.json @@ -0,0 +1,16 @@ +{ + "name": "test-spa", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "../../../svelte-kit.js dev", + "build": "../../../svelte-kit.js build", + "preview": "../../../svelte-kit.js preview" + }, + "devDependencies": { + "@sveltejs/kit": "workspace:*", + "@sveltejs/adapter-node": "workspace:*", + "svelte": "^3.43.0" + }, + "type": "module" +} diff --git a/packages/kit/test/apps/spa/src/app.html b/packages/kit/test/apps/spa/src/app.html new file mode 100644 index 000000000000..97f318d90764 --- /dev/null +++ b/packages/kit/test/apps/spa/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %svelte.head% + + +
%svelte.body%
+ + diff --git a/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js b/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js new file mode 100644 index 000000000000..6ab4531427b5 --- /dev/null +++ b/packages/kit/test/apps/spa/src/routes/client-code/_client_dep.js @@ -0,0 +1 @@ +export const root = /** @type {HTMLElement} */ (document.getElementById('svelte')); diff --git a/packages/kit/test/apps/spa/src/routes/client-code/_tests.js b/packages/kit/test/apps/spa/src/routes/client-code/_tests.js new file mode 100644 index 000000000000..6f692ba59c78 --- /dev/null +++ b/packages/kit/test/apps/spa/src/routes/client-code/_tests.js @@ -0,0 +1,22 @@ +import * as assert from 'uvu/assert'; + +/** @type {import('test').TestMaker} */ +export default function (test) { + test('page with client only code', '/client-code', async ({ page, js }) => { + if (js) { + await page.waitForSelector('span'); + assert.equal(await page.textContent('span'), 'App root is div#svelte'); + } else { + assert.ok(await page.evaluate(() => !document.querySelector('span'))); + } + }); + + test('page with client only dependency', '/client-code/dep', async ({ page, js }) => { + if (js) { + await page.waitForSelector('span'); + assert.equal(await page.textContent('span'), 'App root is div#svelte'); + } else { + assert.ok(await page.evaluate(() => !document.querySelector('span'))); + } + }); +} diff --git a/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte b/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte new file mode 100644 index 000000000000..6b6ee2f7290f --- /dev/null +++ b/packages/kit/test/apps/spa/src/routes/client-code/dep.svelte @@ -0,0 +1,5 @@ + + +App root is {root.localName}#{root.id} diff --git a/packages/kit/test/apps/spa/src/routes/client-code/index.svelte b/packages/kit/test/apps/spa/src/routes/client-code/index.svelte new file mode 100644 index 000000000000..158a776a26c3 --- /dev/null +++ b/packages/kit/test/apps/spa/src/routes/client-code/index.svelte @@ -0,0 +1,5 @@ + + +App root is {appRoot.localName}#{appRoot.id} diff --git a/packages/kit/test/apps/spa/svelte.config.js b/packages/kit/test/apps/spa/svelte.config.js new file mode 100644 index 000000000000..0efb6d12ee5c --- /dev/null +++ b/packages/kit/test/apps/spa/svelte.config.js @@ -0,0 +1,9 @@ +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + ssr: 'never', + target: '#svelte' + } +}; + +export default config; diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 3e509fa0981d..cf4d6c4d740d 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -1,6 +1,6 @@ import { UserConfig as ViteConfig } from 'vite'; import { RecursiveRequired } from './helper'; -import { Logger, TrailingSlash } from './internal'; +import { Logger, SSROption, TrailingSlash } from './internal'; export interface AdapterUtils { log: Logger; @@ -68,7 +68,7 @@ export interface Config { serviceWorker?: { files?(filepath: string): boolean; }; - ssr?: boolean; + ssr?: SSROption; target?: string; trailingSlash?: TrailingSlash; vite?: ViteConfig | (() => ViteConfig); diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 9fe00ab6d98d..32108fb6e34b 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -20,6 +20,8 @@ export interface Logger { info(msg: string): void; } +export type SSROption = boolean | 'never'; + export interface SSRComponent { ssr?: boolean; router?: boolean; @@ -130,7 +132,7 @@ export interface SSRRenderOptions { root: SSRComponent['default']; router: boolean; service_worker?: string; - ssr: boolean; + ssr: SSROption; target: string; template({ head, body }: { head: string; body: string }): string; trailing_slash: TrailingSlash;