Skip to content

Commit

Permalink
[breaking] Implement new layouts system (#6174)
Browse files Browse the repository at this point in the history
#6124

Closes #6196 (using (groups) and/or composition)
Closes #5763 (root layout guaranteed to always exist + await parent())
Closes #5311 ([email protected])
Closes #4940 (no longer possible to get into this situation)
Closes #2154 (only a single root layout now)

Co-authored-by: Simon Holthausen <[email protected]>
Co-authored-by: Dominik G. <[email protected]>
Co-authored-by: Ignatius Bagus <[email protected]>
Co-authored-by: Conduitry <[email protected]>
  • Loading branch information
5 people authored Aug 24, 2022
1 parent 96a6ac3 commit 27172f7
Show file tree
Hide file tree
Showing 56 changed files with 1,045 additions and 1,421 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-forks-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[breaking] implement new layout system (see the PR for migration instructions)
122 changes: 71 additions & 51 deletions documentation/docs/04-advanced-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,75 +126,95 @@ assert.equal(

To express a `%` character, use `%25`, otherwise the result will be malformed.

### Named layouts
### Advanced layouts

Some parts of your app might need something other than the default layout. For these cases you can create _named layouts_...
By default, the _layout hierarchy_ mirrors the _route hierarchy_. In some cases, that might not be what you want.

```svelte
/// file: src/routes/+layout-foo.svelte
<div class="foo">
<slot></slot>
</div>
```
#### (group)

...and then use them by referencing the layout name (`foo`, in the example above) in the filename:
Perhaps you have some routes that are 'app' routes that should have one layout (e.g. `/dashboard` or `/item`), and others that are 'marketing' routes that should have a different layout (`/blog` or `/testimonials`). We can group these routes with a directory whose name is wrapped in parentheses — unlike normal directories, `(app)` and `(marketing)` do not affect the URL pathname of the routes inside them:

```svelte
/// file: src/routes/my-special-page/[email protected]
<h1>I am inside +layout-foo</h1>
```diff
src/routes/
+│ (app)/
│ ├ dashboard/
│ ├ item/
│ └ +layout.svelte
+│ (marketing)/
│ ├ about/
│ ├ testimonials/
│ └ +layout.svelte
├ admin/
└ +layout.svelte
```

> Named layout should only be referenced from Svelte files
Named layouts are very powerful, but it can take a minute to get your head round them. Don't worry if this doesn't make sense all at once.
You can also put a `+page` directly inside a `(group)`, for example if `/` should be an `(app)` or a `(marketing)` page.

#### Scoping
#### +page@

Named layouts can be created at any depth, and will apply to any components in the same subtree. For example, `+layout-foo` will apply to `/x/one` and `/x/two`, but not `/x/three` or `/four`:
Conversely, some routes of your app might need to break out of the layout hierarchy. Let's add an `/item/[id]/embed` route inside the `(app)` group from the previous example:

```bash
```diff
src/routes/
├ x/
│ ├ +layout-foo.svelte
│ ├ one/[email protected] # ✅ page has `@foo`
│ ├ two/[email protected] # ✅ page has `@foo`
│ └ three/+page.svelte # ❌ page does not have `@foo`
└ four/[email protected] # ❌ page has `@foo`, but +layout-foo is not 'in scope'
├ (app)/
│ ├ item/
│ │ ├ [id]/
│ │ │ ├ embed/
+│ │ │ │ └ +page.svelte
│ │ │ └ +layout.svelte
│ │ └ +layout.svelte
│ └ +layout.svelte
└ +layout.svelte
```

#### Inheritance chains

Layouts can themselves choose to inherit from named layouts, from the same directory or a parent directory. For example, `x/y/[email protected]` is the default layout for `/x/y` (meaning `/x/y/one`, `/x/y/two` and `/x/y/three` all inherit from it) because it has no name. Because it specifies `@root`, it will inherit directly from the nearest `+layout-root.svelte`, skipping `+layout.svelte` and `x/+layout.svelte`.
Ordinarily, this would inherit the root layout, the `(app)` layout, the `item` layout and the `[id]` layout. We can reset to one of those layouts by appending `@` followed by the segment name — or, for the root layout, the empty string. In this example, we can choose from `[email protected]``+page@(app).svelte`, `[email protected]` or `+page@[id].svelte`:

```
```diff
src/routes/
├ x/
│ ├ y/
│ │ ├ [email protected]
│ │ ├ one/+page.svelte
│ │ ├ two/+page.svelte
│ │ └ three/+page.svelte
├ (app)/
│ ├ item/
│ │ ├ [id]/
│ │ │ ├ embed/
+│ │ │ │ └ +page@(app).svelte
│ │ │ └ +layout.svelte
│ │ └ +layout.svelte
│ └ +layout.svelte
├ +layout.svelte
└ +layout-root.svelte
└ +layout.svelte
```

> In the case where `+layout-root.svelte` contains a lone `<slot />`, this effectively means we're able to 'reset' to a blank layout for any page or nested layout in the app by adding `@root`.
#### +layout@

If no parent is specified, a layout will inherit from the nearest default (i.e. unnamed) layout _above_ it in the tree. In some cases, it's helpful for a named layout to inherit from a default layout _alongside_ it in the tree, such as `+layout-root.svelte` inheriting from `+layout.svelte`. We can do this by explicitly specifying `@default`, allowing `/x/y/one` and siblings to use the app's default layout without using `x/+layout.svelte`:
Like pages, layouts can _themselves_ break out of their parent layout hierarchy, using the same technique. For example, a `+layout@.svelte` component would reset the hierarchy for all its child routes.

```diff
src/routes/
├ x/
│ ├ y/
│ │ ├ [email protected]
│ │ ├ one/+page.svelte
│ │ ├ two/+page.svelte
│ │ └ three/+page.svelte
│ └ +layout.svelte
├ +layout.svelte
-└ +layout-root.svelte
+[email protected]
#### When to use layout groups

Not all use cases are suited for layout grouping, nor should you feel compelled to use them. It might be that your use case would result in complex `(group)` nesting, or that you don't want to introduce a `(group)` for a single outlier. It's perfectly fine to use other means such as composition (reusable `load` functions or Svelte components) or if-statements to achieve what you want. The following example shows a layout that rewinds to the root layout and reuses components and functions that other layouts can also use:

```svelte
/// file: src/routes/nested/route/[email protected]
<script>
import ReusableLayout from '$lib/ReusableLayout.svelte';
export let data;
</script>
<ReusableLayout {data}>
<slot />
</ReusableLayout>
```

> `default` is a reserved name — in other words, you can't have a `+layout-default.svelte` file.
```js
/// file: src/routes/nested/route/+layout.js
// @filename: ambient.d.ts
declare module "$lib/reusable-load-function" {
export function reusableLoad(event: import('@sveltejs/kit').LoadEvent): Promise<Record<string, any>>;
}
// @filename: index.js
// ---cut---
import { reusableLoad } from '$lib/reusable-load-function';

/** @type {import('./$types').PageLoad} */
export function load(event) {
// Add additional logic here, if needed
return reusableLoad(event);
}
```
1 change: 0 additions & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
],
"scripts": {
"build": "npm run types",
"dev": "rollup -cw",
"lint": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore",
"check": "tsc",
"check:all": "tsc && pnpm -r --filter=\"./**\" check",
Expand Down
41 changes: 28 additions & 13 deletions packages/kit/src/core/adapt/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function create_builder({ config, build_data, prerendered, log }) {
/** @param {import('types').RouteData} route */
// TODO routes should come pre-filtered
function not_prerendered(route) {
const path = route.type === 'page' && !route.id.includes('[') && `/${route.id}`;
const path = route.page && !route.id.includes('[') && `/${route.id}`;
if (path) {
return !prerendered_paths.has(path) && !prerendered_paths.has(path + '/');
}
Expand Down Expand Up @@ -68,17 +68,31 @@ export function create_builder({ config, build_data, prerendered, log }) {
const { routes } = build_data.manifest_data;

/** @type {import('types').RouteDefinition[]} */
const facades = routes.map((route) => ({
id: route.id,
type: route.type,
segments: route.id.split('/').map((segment) => ({
dynamic: segment.includes('['),
rest: segment.includes('[...'),
content: segment
})),
pattern: route.pattern,
methods: route.type === 'page' ? ['GET'] : build_data.server.methods[route.file]
}));
const facades = routes.map((route) => {
const methods = new Set();

if (route.page) {
methods.add('SET');
}

if (route.endpoint) {
for (const method of build_data.server.methods[route.endpoint.file]) {
methods.add(method);
}
}

return {
id: route.id,
type: route.page ? 'page' : 'endpoint', // TODO change this if support pages+endpoints
segments: route.id.split('/').map((segment) => ({
dynamic: segment.includes('['),
rest: segment.includes('[...'),
content: segment
})),
pattern: route.pattern,
methods: Array.from(methods)
};
});

const seen = new Set();

Expand All @@ -102,8 +116,9 @@ export function create_builder({ config, build_data, prerendered, log }) {

// heuristic: if /foo/[bar] is included, /foo/[bar].json should
// also be included, since the page likely needs the endpoint
// TODO is this still necessary, given the new way of doing things?
filtered.forEach((route) => {
if (route.type === 'page') {
if (route.page) {
const endpoint = routes.find((candidate) => candidate.id === route.id + '.json');

if (endpoint) {
Expand Down
45 changes: 12 additions & 33 deletions packages/kit/src/core/generate_manifest/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { s } from '../../utils/misc.js';
import { parse_route_id } from '../../utils/routing.js';
import { get_mime_lookup } from '../utils.js';

/**
Expand Down Expand Up @@ -38,12 +37,11 @@ export function generate_manifest({ build_data, relative_path, routes, format =
assets.push(build_data.service_worker);
}

/** @param {import('types').PageNode | undefined} id */
const get_index = (id) => id && /** @type {LookupEntry} */ (bundled_nodes.get(id)).index;

const matchers = new Set();

// prettier-ignore
// String representation of
/** @type {import('types').SSRManifest} */
return `{
appDir: ${s(build_data.app_dir)},
assets: new Set(${s(assets)}),
Expand All @@ -55,39 +53,20 @@ export function generate_manifest({ build_data, relative_path, routes, format =
],
routes: [
${routes.map(route => {
const { pattern, names, types } = parse_route_id(route.id);
types.forEach(type => {
route.types.forEach(type => {
if (type) matchers.add(type);
});
if (route.type === 'page') {
return `{
type: 'page',
id: ${s(route.id)},
pattern: ${pattern},
names: ${s(names)},
types: ${s(types)},
errors: ${s(route.errors.map(get_index))},
layouts: ${s(route.layouts.map(get_index))},
leaf: ${s(get_index(route.leaf))}
}`.replace(/^\t\t/gm, '');
} else {
if (!build_data.server.vite_manifest[route.file]) {
// this is necessary in cases where a .css file snuck in —
// perhaps it would be better to disallow these (and others?)
return null;
}
if (!route.page && !route.endpoint) return;
return `{
type: 'endpoint',
id: ${s(route.id)},
pattern: ${pattern},
names: ${s(names)},
types: ${s(types)},
load: ${loader(`${relative_path}/${build_data.server.vite_manifest[route.file].file}`)}
}`.replace(/^\t\t/gm, '');
}
return `{
id: ${s(route.id)},
pattern: ${route.pattern},
names: ${s(route.names)},
types: ${s(route.types)},
page: ${s(route.page)},
endpoint: ${route.endpoint ? loader(`${relative_path}/${build_data.server.vite_manifest[route.endpoint.file].file}`) : 'null'}
}`;
}).filter(Boolean).join(',\n\t\t\t\t')}
],
matchers: async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/prerender/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export async function prerender() {
/** @type {import('types').ManifestData} */
const { routes } = (await import(pathToFileURL(manifest_path).href)).manifest._;
const entries = routes
.map((route) => (route.type === 'page' && !route.id.includes('[') ? `/${route.id}` : ''))
.map((route) => (route.page && !route.id.includes('[') ? `/${route.id}` : ''))
.filter(Boolean);

for (const entry of entries) {
Expand Down
Loading

0 comments on commit 27172f7

Please sign in to comment.