Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] allow ssr to be configurable in +page.js and +layout.js (enhanced version) #6207

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-gifts-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[feat] allow ssr to be configurable in +page.js and +layout.js
27 changes: 22 additions & 5 deletions documentation/docs/12-page-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// file: +page.js/+page.js
/// file: +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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// file: +page.js/+page.js
/// file: +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).
Expand All @@ -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 `<a>` 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.
Expand All @@ -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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for treating ssr differently? This feels a bit confusing to me. If we made it a page option like all the others then we could move the 'You can control this setting...' paragraph back to the top instead of repeating it


> 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.
87 changes: 47 additions & 40 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<import('types').LoadedSSRNode | undefined>} */
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;

Expand All @@ -66,7 +81,7 @@ export async function render_page(event, route, options, state, resolve_opts) {
/** @type {Record<string, string> | 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 {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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<import('types').SSRNode>['server']} mod
*/
export async function handle_json_request(event, options, mod) {
const method = /** @type {'POST' | 'PUT' | 'PATCH' | 'DELETE'} */ (event.request.method);
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/page/load_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>>;
* }} opts
* @returns {Promise<import('types').ServerDataNode | null>}
Expand Down Expand Up @@ -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<Record<string, any>>;
* server_data_promise: Promise<import('types').ServerDataNode | null>;
* state: import('types').SSRState;
Expand Down
6 changes: 3 additions & 3 deletions packages/kit/src/runtime/server/page/respond_with_error.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/page/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ResponseHeaders, SSRNode, CspDirectives } from 'types';
import { ResponseHeaders, CspDirectives, LoadedSSRNode } from 'types';
import { HttpError } from '../../../index/private';

export interface Fetched {
Expand All @@ -19,7 +19,7 @@ export interface FetchState {
}

export type Loaded = {
node: SSRNode;
node: LoadedSSRNode;
data: Record<string, any> | null;
server_data: any;
};
Expand Down
13 changes: 13 additions & 0 deletions packages/kit/src/runtime/server/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,16 @@ export function allowed_methods(mod) {

return allowed;
}

/**
* @param {import('types').SSRManifest} manifest
* @param {number} idx
* @returns {Promise<import('types').LoadedSSRNode>}
*/
export async function load_ssr_node(manifest, idx) {
const node = await manifest._.nodes[idx]();
return {
...node,
shared: node.shared && (await node.shared())
};
}
5 changes: 3 additions & 2 deletions packages/kit/src/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 6 additions & 5 deletions packages/kit/src/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/test/apps/basics/src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
document;
export function load() {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
document;
</script>

{document}

<p>Works</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ssr = false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script context="module">
document;
</script>

{document}

<p>Works</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ssr = false;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<slot />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script context="module">
document;
</script>

{document}

<p>Works</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ssr = true;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script context="module">
document;
</script>

{document}

<p>You shouldn't see this</p>
Loading