Skip to content

Commit

Permalink
feat: add $app/state module (#13140)
Browse files Browse the repository at this point in the history
* WIP

* most existing stuff working

* working

* paperwork

* get $app/state working

* prettier

* stopgap

* this seems to work

* update most of the test files

* fix

* oops

* navigating

* whitespace is important for our test assertion

* fix

* adjust new tests/folder names, keep around old $app/stores-specific tests until SvelteKit 3

* adjust test

* updated

* docs

* better dev time errors

* another test

* adjust documentation

* nomenclature

* lint

* adjust test name

* oops

* changeset

* deprecate $app/stores

* fix hash link + typo

* Update documentation/docs/60-appendix/40-migrating.md

Co-authored-by: Tee Ming <chewteeming01@gmail.com>

* fix/tweak

* typos

* document deprecation, link between state/stores

* Apply suggestions from code review

* regenerate

* tweak wording

* small tweaks

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Tee Ming <chewteeming01@gmail.com>
  • Loading branch information
3 people authored Dec 16, 2024
1 parent 0464283 commit f2393eb
Show file tree
Hide file tree
Showing 122 changed files with 943 additions and 271 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-news-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add `$app/state` module
2 changes: 1 addition & 1 deletion documentation/docs/10-getting-started/40-web-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Most of the time, your endpoints will return complete data, as in the `userAgent
## URL APIs
URLs are represented by the [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) interface, which includes useful properties like `origin` and `pathname` (and, in the browser, `hash`). This interface shows up in various places — `event.url` in [hooks](hooks) and [server routes](routing#server), [`$page.url`]($app-stores) in [pages](routing#page), `from` and `to` in [`beforeNavigate` and `afterNavigate`]($app-navigation) and so on.
URLs are represented by the [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) interface, which includes useful properties like `origin` and `pathname` (and, in the browser, `hash`). This interface shows up in various places — `event.url` in [hooks](hooks) and [server routes](routing#server), [`page.url`]($app-state) in [pages](routing#page), `from` and `to` in [`beforeNavigate` and `afterNavigate`]($app-navigation) and so on.
### URLSearchParams
Expand Down
7 changes: 5 additions & 2 deletions documentation/docs/20-core-concepts/10-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,15 @@ If an error occurs during `load`, SvelteKit will render a default error page. Yo
```svelte
<!--- file: src/routes/blog/[slug]/+error.svelte --->
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
</script>
<h1>{$page.status}: {$page.error.message}</h1>
<h1>{page.status}: {page.error.message}</h1>
```

> [!LEGACY]
> `$app/state` was added in SvelteKit 2.12. If you're using an earlier version or are using Svelte 4, use `$app/stores` instead.
SvelteKit will 'walk up the tree' looking for the closest error boundary — if the file above didn't exist it would try `src/routes/blog/+error.svelte` and then `src/routes/+error.svelte` before rendering the default error page. If _that_ fails (or if the error was thrown from the `load` function of the root `+layout`, which sits 'above' the root `+error`), SvelteKit will bail out and render a static fallback error page, which you can customise by creating a `src/error.html` file.

If the error occurs inside a `load` function in `+layout(.server).js`, the closest error boundary in the tree is an `+error.svelte` file _above_ that layout (not next to it).
Expand Down
18 changes: 11 additions & 7 deletions documentation/docs/20-core-concepts/20-load.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,14 @@ Data returned from layout `load` functions is available to child `+layout.svelte
```svelte
/// file: src/routes/blog/[slug]/+page.svelte
<script>
+++import { page } from '$app/stores';+++
+++import { page } from '$app/state';+++
/** @type {{ data: import('./$types').PageData }} */
let { data } = $props();
+++ // we can access `data.posts` because it's returned from
// the parent layout `load` function
let index = $derived(data.posts.findIndex(post => post.slug === $page.params.slug));
let index = $derived(data.posts.findIndex(post => post.slug === page.params.slug));
let next = $derived(data.posts[index + 1]);+++
</script>
Expand All @@ -137,24 +137,28 @@ Data returned from layout `load` functions is available to child `+layout.svelte

> [!NOTE] If multiple `load` functions return data with the same key, the last one 'wins' — the result of a layout `load` returning `{ a: 1, b: 2 }` and a page `load` returning `{ b: 3, c: 4 }` would be `{ a: 1, b: 3, c: 4 }`.
## $page.data
## page.data

The `+page.svelte` component, and each `+layout.svelte` component above it, has access to its own data plus all the data from its parents.

In some cases, we might need the opposite — a parent layout might need to access page data or data from a child layout. For example, the root layout might want to access a `title` property returned from a `load` function in `+page.js` or `+page.server.js`. This can be done with `$page.data`:
In some cases, we might need the opposite — a parent layout might need to access page data or data from a child layout. For example, the root layout might want to access a `title` property returned from a `load` function in `+page.js` or `+page.server.js`. This can be done with `page.data`:

```svelte
<!--- file: src/routes/+layout.svelte --->
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
</script>
<svelte:head>
<title>{$page.data.title}</title>
<title>{page.data.title}</title>
</svelte:head>
```

Type information for `$page.data` is provided by `App.PageData`.
Type information for `page.data` is provided by `App.PageData`.

> [!LEGACY]
> `$app/state` was added in SvelteKit 2.12. If you're using an earlier version or are using Svelte 4, use `$app/stores` instead.
> It provides a `page` store with the same interface that you can subscribe to, e.g. `$page.data.title`.
## Universal vs server

Expand Down
8 changes: 4 additions & 4 deletions documentation/docs/20-core-concepts/30-form-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ As well as the `action` attribute, we can use the `formaction` attribute on a bu
## Anatomy of an action
Each action receives a `RequestEvent` object, allowing you to read the data with `request.formData()`. After processing the request (for example, logging the user in by setting a cookie), the action can respond with data that will be available through the `form` property on the corresponding page and through `$page.form` app-wide until the next update.
Each action receives a `RequestEvent` object, allowing you to read the data with `request.formData()`. After processing the request (for example, logging the user in by setting a cookie), the action can respond with data that will be available through the `form` property on the corresponding page and through `page.form` app-wide until the next update.
```js
/// file: src/routes/login/+page.server.js
Expand Down Expand Up @@ -156,7 +156,7 @@ export const actions = {
### Validation errors
If the request couldn't be processed because of invalid data, you can return validation errors — along with the previously submitted form values — back to the user so that they can try again. The `fail` function lets you return an HTTP status code (typically 400 or 422, in the case of validation errors) along with the data. The status code is available through `$page.status` and the data through `form`:
If the request couldn't be processed because of invalid data, you can return validation errors — along with the previously submitted form values — back to the user so that they can try again. The `fail` function lets you return an HTTP status code (typically 400 or 422, in the case of validation errors) along with the data. The status code is available through `page.status` and the data through `form`:
```js
/// file: src/routes/login/+page.server.js
Expand Down Expand Up @@ -352,7 +352,7 @@ The easiest way to progressively enhance a form is to add the `use:enhance` acti
Without an argument, `use:enhance` will emulate the browser-native behaviour, just without the full-page reloads. It will:
- update the `form` property, `$page.form` and `$page.status` on a successful or invalid response, but only if the action is on the same page you're submitting from. For example, if your form looks like `<form action="/somewhere/else" ..>`, `form` and `$page` will _not_ be updated. This is because in the native form submission case you would be redirected to the page the action is on. If you want to have them updated either way, use [`applyAction`](#Progressive-enhancement-Customising-use:enhance)
- update the `form` property, `page.form` and `page.status` on a successful or invalid response, but only if the action is on the same page you're submitting from. For example, if your form looks like `<form action="/somewhere/else" ..>`, the `form` prop and the `page.form` state will _not_ be updated. This is because in the native form submission case you would be redirected to the page the action is on. If you want to have them updated either way, use [`applyAction`](#Progressive-enhancement-Customising-use:enhance)
- reset the `<form>` element
- invalidate all data using `invalidateAll` on a successful response
- call `goto` on a redirect response
Expand Down Expand Up @@ -411,7 +411,7 @@ If you return a callback, you may need to reproduce part of the default `use:enh
The behaviour of `applyAction(result)` depends on `result.type`:
- `success`, `failure` — sets `$page.status` to `result.status` and updates `form` and `$page.form` to `result.data` (regardless of where you are submitting from, in contrast to `update` from `enhance`)
- `success`, `failure` — sets `page.status` to `result.status` and updates `form` and `page.form` to `result.data` (regardless of where you are submitting from, in contrast to `update` from `enhance`)
- `redirect` — calls `goto(result.location, { invalidateAll: true })`
- `error` — renders the nearest `+error` boundary with `result.error`
Expand Down
37 changes: 20 additions & 17 deletions documentation/docs/20-core-concepts/50-state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Instead, you should _authenticate_ the user using [`cookies`](load#Cookies) and

## No side-effects in load

For the same reason, your `load` functions should be _pure_ — no side-effects (except maybe the occasional `console.log(...)`). For example, you might be tempted to write to a store inside a `load` function so that you can use the store value in your components:
For the same reason, your `load` functions should be _pure_ — no side-effects (except maybe the occasional `console.log(...)`). For example, you might be tempted to write to a store or global state inside a `load` function so that you can use the value in your components:

```js
/// file: +page.js
Expand Down Expand Up @@ -76,31 +76,25 @@ export async function load({ fetch }) {
}
```

...and pass it around to the components that need it, or use [`$page.data`](load#$page.data).
...and pass it around to the components that need it, or use [`page.data`](load#page.data).

If you're not using SSR, then there's no risk of accidentally exposing one user's data to another. But you should still avoid side-effects in your `load` functions — your application will be much easier to reason about without them.

## Using stores with context
## Using state and stores with context

You might wonder how we're able to use `$page.data` and other [app stores]($app-stores) if we can't use our own stores. The answer is that app stores on the server use Svelte's [context API](/tutorial/svelte/context-api) — the store is attached to the component tree with `setContext`, and when you subscribe you retrieve it with `getContext`. We can do the same thing with our own stores:
You might wonder how we're able to use `page.data` and other [app state]($app-state) (or [app stores]($app-stores)) if we can't use global state. The answer is that app state and app stores on the server use Svelte's [context API](/tutorial/svelte/context-api) — the state (or store) is attached to the component tree with `setContext`, and when you subscribe you retrieve it with `getContext`. We can do the same thing with our own state:

```svelte
<!--- file: src/routes/+layout.svelte --->
<script>
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
/** @type {{ data: import('./$types').LayoutData }} */
let { data } = $props();
// Create a store and update it when necessary...
const user = writable(data.user);
$effect.pre(() => {
user.set(data.user);
});
// ...and add it to the context for child components to access
setContext('user', user);
// Pass a function referencing our state
// to the context for child components to access
setContext('user', () => data.user);
</script>
```

Expand All @@ -113,10 +107,15 @@ You might wonder how we're able to use `$page.data` and other [app stores]($app-
const user = getContext('user');
</script>
<p>Welcome {$user.name}</p>
<p>Welcome {user().name}</p>
```

Updating the value of a context-based store in deeper-level pages or components while the page is being rendered via SSR will not affect the value in the parent component because it has already been rendered by the time the store value is updated. In contrast, on the client (when CSR is enabled, which is the default) the value will be propagated and components, pages, and layouts higher in the hierarchy will react to the new value. Therefore, to avoid values 'flashing' during state updates during hydration, it is generally recommended to pass state down into components rather than up.
> [!NOTE] We're passing a function into `setContext` to keep reactivity across boundaries. Read more about it [here](https://svelte.dev/docs/svelte/$state#Passing-state-into-functions)
> [!LEGACY]
> You also use stores from `svelte/store` for this, but when using Svelte 5 it is recommended to make use of universal reactivity instead.
Updating the value of context-based state in deeper-level pages or components while the page is being rendered via SSR will not affect the value in the parent component because it has already been rendered by the time the state value is updated. In contrast, on the client (when CSR is enabled, which is the default) the value will be propagated and components, pages, and layouts higher in the hierarchy will react to the new value. Therefore, to avoid values 'flashing' during state updates during hydration, it is generally recommended to pass state down into components rather than up.

If you're not using SSR (and can guarantee that you won't need to use SSR in future) then you can safely keep state in a shared module, without using the context API.

Expand Down Expand Up @@ -163,14 +162,18 @@ Instead, we need to make the value [_reactive_](/tutorial/svelte/state):
Reusing components like this means that things like sidebar scroll state are preserved, and you can easily animate between changing values. In the case that you do need to completely destroy and remount a component on navigation, you can use this pattern:

```svelte
{#key $page.url.pathname}
<script>
import { page } from '$app/state';
</script>
{#key page.url.pathname}
<BlogPost title={data.title} content={data.title} />
{/key}
```

## Storing state in the URL

If you have state that should survive a reload and/or affect SSR, such as filters or sorting rules on a table, URL search parameters (like `?sort=price&order=ascending`) are a good place to put them. You can put them in `<a href="...">` or `<form action="...">` attributes, or set them programmatically via `goto('?key=value')`. They can be accessed inside `load` functions via the `url` parameter, and inside components via `$page.url.searchParams`.
If you have state that should survive a reload and/or affect SSR, such as filters or sorting rules on a table, URL search parameters (like `?sort=price&order=ascending`) are a good place to put them. You can put them in `<a href="...">` or `<form action="...">` attributes, or set them programmatically via `goto('?key=value')`. They can be accessed inside `load` functions via the `url` parameter, and inside components via `page.url.searchParams`.

## Storing ephemeral state in snapshots

Expand Down
9 changes: 6 additions & 3 deletions documentation/docs/30-advanced/25-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,20 @@ export async function load({ params }) {
}
```

This throws an exception that SvelteKit catches, causing it to set the response status code to 404 and render an [`+error.svelte`](routing#error) component, where `$page.error` is the object provided as the second argument to `error(...)`.
This throws an exception that SvelteKit catches, causing it to set the response status code to 404 and render an [`+error.svelte`](routing#error) component, where `page.error` is the object provided as the second argument to `error(...)`.

```svelte
<!--- file: src/routes/+error.svelte --->
<script>
import { page } from '$app/stores';
import { page } from '$app/state';
</script>
<h1>{$page.error.message}</h1>
<h1>{page.error.message}</h1>
```

> [!LEGACY]
> `$app/state` was added in SvelteKit 2.12. If you're using an earlier version or are using Svelte 4, use `$app/stores` instead.
You can add extra properties to the error object if needed...

```js
Expand Down
19 changes: 11 additions & 8 deletions documentation/docs/30-advanced/67-shallow-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SvelteKit makes this possible with the [`pushState`]($app-navigation#pushState)
<!--- file: +page.svelte --->
<script>
import { pushState } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Modal from './Modal.svelte';
function showModal() {
Expand All @@ -22,21 +22,24 @@ SvelteKit makes this possible with the [`pushState`]($app-navigation#pushState)
}
</script>
{#if $page.state.showModal}
{#if page.state.showModal}
<Modal close={() => history.back()} />
{/if}
```

The modal can be dismissed by navigating back (unsetting `$page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically.
The modal can be dismissed by navigating back (unsetting `page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically.

## API

The first argument to `pushState` is the URL, relative to the current URL. To stay on the current URL, use `''`.

The second argument is the new page state, which can be accessed via the [page store]($app-stores#page) as `$page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`).
The second argument is the new page state, which can be accessed via the [page object]($app-state#page) as `page.state`. You can make page state type-safe by declaring an [`App.PageState`](types#PageState) interface (usually in `src/app.d.ts`).

To set page state without creating a new history entry, use `replaceState` instead of `pushState`.

> [!LEGACY]
> `page.state` from `$app/state` was added in SvelteKit 2.12. If you're using an earlier version or are using Svelte 4, use `$page.state` from `$app/stores` instead.
## Loading data for a route

When shallow routing, you may want to render another `+page.svelte` inside the current page. For example, clicking on a photo thumbnail could pop up the detail view without navigating to the photo page.
Expand All @@ -47,7 +50,7 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c
<!--- file: src/routes/photos/+page.svelte --->
<script>
import { preloadData, pushState, goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import Modal from './Modal.svelte';
import PhotoPage from './[id]/+page.svelte';
Expand Down Expand Up @@ -85,17 +88,17 @@ For this to work, you need to load the data that the `+page.svelte` expects. A c
</a>
{/each}
{#if $page.state.selected}
{#if page.state.selected}
<Modal onclose={() => history.back()}>
<!-- pass page data to the +page.svelte component,
just like SvelteKit would on navigation -->
<PhotoPage data={$page.state.selected} />
<PhotoPage data={page.state.selected} />
</Modal>
{/if}
```

## Caveats

During server-side rendering, `$page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page (or returns from another document), state will _not_ be applied until they navigate.
During server-side rendering, `page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page (or returns from another document), state will _not_ be applied until they navigate.

Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available.
Loading

0 comments on commit f2393eb

Please sign in to comment.