Skip to content

Commit aa6eb5a

Browse files
committed
Added "Suspense and Transition" section to README
1 parent ebaae13 commit aa6eb5a

9 files changed

+94
-48
lines changed

README.md

+54-18
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
[What Comes After GraphQL?](https://youtu.be/gfKrdN1RzoI?t=14516)
1010

11-
Updated for SolidStart v0.5.7 (new beta, [first beta version](https://github.com/peerreynders/solid-start-notes-basic/tree/2fe3462b30ab9008576339648f13d9457da3ff5f)).
11+
Updated for SolidStart v0.5.9 (new beta, [first beta version](https://github.com/peerreynders/solid-start-notes-basic/tree/2fe3462b30ab9008576339648f13d9457da3ff5f)).
1212
The app is a port of the December 2020 [React Server Components Demo](https://github.com/reactjs/server-components-demo) ([LICENSE](https://github.com/reactjs/server-components-demo/blob/main/LICENSE); [no pg fork](https://github.com/pomber/server-components-demo/), [Data Fetching with React Server Components](https://youtu.be/TQQPAU21ZUw)) but here it's just a basic client side routing implementation.
1313
It doesn't use a database but stores the notes via the [Unstorage Node.js Filesystem (Lite) driver](https://unstorage.unjs.io/drivers/fs#nodejs-filesystem-lite) . This app is not intended to be deployed but simply serves as an experimental platform.
1414

@@ -76,21 +76,21 @@ import {
7676
//
7777
import type { NoteBrief, Note } from './types';
7878
//
79-
const getBriefs = cache<
80-
(search: string | undefined) => Promise<NoteBrief[]>,
81-
Promise<NoteBrief[]>
82-
>(async (search: string | undefined) => getBf(search), NAME_GET_BRIEFS);
79+
const getBriefs = cache<(search: string | undefined) => Promise<NoteBrief[]>>(
80+
async (search: string | undefined) => getBf(search),
81+
NAME_GET_BRIEFS
82+
);
8383

84-
const getNote = cache<
85-
(noteId: string) => Promise<Note | undefined>,
86-
Promise<Note | undefined>
87-
>(async (noteId: string) => getNt(noteId), NAME_GET_NOTE);
84+
const getNote = cache<(noteId: string) => Promise<Note | undefined>>(
85+
async (noteId: string) => getNt(noteId),
86+
NAME_GET_NOTE
87+
);
8888
//
8989
export { getBriefs, getNote, editAction };
9090
```
9191

92-
Both of these functions are wrapped in [`solid-router`](https://github.com/solidjs/solid-router)'s [`cache()`](https://github.com/solidjs/solid-router?tab=readme-ov-file#cache). The page is fully server rendered on initial load but all subsequent updates are purely client rendered.
93-
But the router's `cache()` tracks the currently loaded `:noteId` and `:search` keys; so rather than running **both** `getBriefs` and `getNote` server fetches the router will only use the one whose key has actually changed (or both if both have changed).
92+
Both of these functions are wrapped in [`@solidjs/router`](https://docs.solidjs.com/reference/solid-router/components/router)'s [`cache()`](https://docs.solidjs.com/reference/solid-router/data-apis/cache). The page is fully server rendered on initial load but all subsequent updates are purely client rendered.
93+
But the router's `cache()` tracks the currently loaded `:noteId` and `:search` keys; so rather than running **both** `getBriefs` and `getNote` server fetches, the router will only use the one whose key has actually changed (or both if both have changed).
9494

9595
So only the portion of the page that needs to change is updated on the client for `navigate()` even when the path changes.
9696
The `search` parameter affects the content of the `<nav>` within the layout that is independent from any one `Route` component; `noteId` on the other hand directly impacts which `Route` component is chosen.
@@ -171,10 +171,8 @@ The orignal demo's layout is found in [`App.js`](https://github.com/reactjs/serv
171171
//
172172
import { Route, Router, useSearchParams } from '@solidjs/router';
173173
import { MetaProvider } from '@solidjs/meta';
174-
import EditButton from './components/edit-button';
175-
import SearchField from './components/search-field';
176-
import BriefList from './components/brief-list';
177-
import BriefListSkeleton from './components/brief-list-skeleton';
174+
import { EditButton } from './components/edit-button';
175+
import { SearchField } from './components/search-field';
178176
//
179177
import type { ParentProps } from 'solid-js';
180178
//
@@ -201,18 +199,56 @@ function Layout(props: ParentProps) {
201199
<SearchField />
202200
<EditButton kind={'new'}>New</EditButton>
203201
</section>
204-
<Suspense fallback={<BriefListSkeleton />}>
202+
<Suspense>
205203
<BriefList searchText={searchParams.search} />
206204
</Suspense>
207205
</section>
208-
<section class="c-note-view c-main__column">{props.children}</section>
206+
<section class="c-note-view c-main__column">
207+
<Suspense>{props.children}</Suspense>
208+
</section>
209209
</main>
210210
</MetaProvider>
211211
);
212212
}
213213
```
214214

215-
Note the [Suspense](https://docs.solidjs.com/references/api-reference/control-flow/Suspense) boundary around `BriefList`. This way content under the suspense boundary is not displayed until all asynchonous values under it have resolved; meanwhile the `fallback` is shown.
215+
### On `Suspense` and `useTransition`
216+
217+
Browsers natively implement a behaviour called [paint holding](https://developer.chrome.com/blog/paint-holding); when the browser navigates to a new page (fetched from the server, i.e. not client rendered) the URL in the address bar will update; the old page's *paint is held* while the DOM of the new page is rendered in the background.
218+
Once the new page is *painted* the *old paint* is swapped out for the *new paint*.
219+
220+
Solid's [`useTransition`](https://docs.solidjs.com/reference/reactive-utilities/use-transition) is the primitive used to implement *Component/Block-level paint holding*. To work it relies on the presence of a [`<Suspense>`](https://docs.solidjs.com/references/api-reference/control-flow/Suspense) boundary which determines the scope and timing of the “transition”.
221+
While there are unsettled async operations under the `Suspense` boundary the transition allows rendering of the new content to progress in the background while the old DOM (fragment) is still visible and active on the browser.
222+
223+
The interaction between `Suspense` and transition has consequences for the `Suspense` [`fallback`](https://docs.solidjs.com/reference/components/suspense#props). The `fallback` will only ever show on the first render *of the suspense boundary*.
224+
At that time there is no “block of paint/DOM” to “hold” as the `Suspense` boundary didn't previously exist so the `fallback` is rendered.
225+
After that transitions take over for as long as the `Suspense` boundary exists and the `fallback` will never be seen again when new unsettled async operations occur under the `Suspense` boundary.
226+
227+
Consequently placement of the `Suspense` boundary is crucial for consistent UX. Placing the `Suspense` boundary at the root of a component will cause the `fallback` to be displayed when it's rendered but transitions take over for subsequent async operations which may seem inconsistent from the UX point of view.
228+
Placing the `Suspense` boundary around the “slot” where various components may alternately appear is often the right choice because then transitions between components work as expected, the *previous component paint is held* while the new component renders in the background.
229+
230+
If the `Suspense` `fallback` needs to appear whenever an unsettled async operation occurs under the boundary then `useTransition` or packages that leverage it like [`@solidjs/router`](https://docs.solidjs.com/guides/routing-and-navigation) **cannot** be used.
231+
232+
For the signal exposed by [`useIsRouting()`](https://docs.solidjs.com/guides/routing-and-navigation#useisrouting) to work as expected:
233+
234+
- stable, top-level `Suspense` boundaries need to exist inside the [`root`](https://docs.solidjs.com/routing/defining-routes#component-routing) layout
235+
- unsettled async operations **have to be allowed** to propagate all the way to the top-level `Suspense` boundaries in the `root` layout.
236+
The `isRouting` signal will switch to `false` once everything settles in the top-level boundary; at that time there can still be unsettled operations that were intercepted by nested `Suspense` boundaries.
237+
238+
239+
This adds up to very different rendering behaviour compared to what is implememented by the RSC demo.
240+
React's transition mechanism **does not** override the `Suspense` fallback.
241+
It renders the new content in the background (the “transition” part) but it also replaces the old content with the `Suspense` `fallback`.
242+
In cases where a transition can complete in under 1 (or 2) seconds, intermittent “skeleton” screens [are judged to provide worse UX](https://www.nngroup.com/articles/skeleton-screens/#are-skeleton-screens-better-than-progress-bars-or-spinners) than simply *holding paint*.
243+
The demo makes use of component skeletons ([`NoteListSkeleton.js`](https://github.com/reactjs/server-components-demo/blob/95fcac10102d20722af60506af3b785b557c5fd7/src/NoteListSkeleton.js), [`NoteSkeleton.js`](https://github.com/reactjs/server-components-demo/blob/95fcac10102d20722af60506af3b785b557c5fd7/src/NoteSkeleton.js)).
244+
245+
Given the use of SSR and the browser's own paint holding behaviour, it turns out that the top-level `Suspense` boundaries don't even need fallbacks! The server doesn't send the response until the initial render is complete (i.e. all async operations have settled) so the initial content is already present in the server's HTML; therefore all subsequent client side route renders are governed by transitions.
246+
247+
In an alternate, island architecture the layout could be immediately SSR rendered with skeleton islands and sent back to the browser while the island content is streamed in later once it is ready.
248+
249+
Finally the [`search-field`](#search-field) doubles as an app-wide spinner for those occasions where transitions don't occur quickly enough.
250+
The spinner is driven by the `isRouting` signal; to make it as accurate as possible, only two `Suspense` boundaries exist within the entire application, both within the top level `root` component; one enveloping the route component children, the other around the [`brief-list`](#brief-list).
251+
216252

217253
## Route Content (`Route` components)
218254

package.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@
1313
},
1414
"dependencies": {
1515
"@solidjs/meta": "^0.29.3",
16-
"@solidjs/router": "^0.12.0",
17-
"@solidjs/start": "^0.5.7",
18-
"dompurify": "^3.0.8",
16+
"@solidjs/router": "^0.12.4",
17+
"@solidjs/start": "^0.5.9",
18+
"dompurify": "^3.0.9",
1919
"marked": "^12.0.0",
2020
"nanoid": "^5.0.5",
2121
"rxjs": "8.0.0-alpha.14",
22-
"sanitize-html": "^2.11.0",
22+
"sanitize-html": "^2.12.0",
2323
"solid-js": "^1.8.15",
2424
"unstorage": "^1.10.1",
2525
"vinxi": "^0.2.1"

pnpm-lock.yaml

+16-16
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { mergeProps, Suspense } from 'solid-js';
44
import { Route, Router, useSearchParams } from '@solidjs/router';
55
import { MetaProvider } from '@solidjs/meta';
66
import { EditButton } from './components/edit-button';
7-
import SearchField from './components/search-field';
7+
import { SearchField } from './components/search-field';
88
import { BriefList } from './components/brief-list';
9-
import Note from './routes/note';
10-
import NoteNew from './routes/note-new';
11-
import NoteNone from './routes/note-none';
12-
import NotFound from './routes/not-found';
9+
import { Note } from './routes/note';
10+
import { NoteNew } from './routes/note-new';
11+
import { NoteNone } from './routes/note-none';
12+
import { NotFound } from './routes/not-found';
1313

1414
import './styles/critical.scss';
1515

src/components/search-field.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const preventSubmit = (
2727
}
2828
) => event.preventDefault();
2929

30-
export default function SearchField() {
30+
function SearchField() {
3131
const searchInputId = createUniqueId();
3232
const isRouting = useIsRouting();
3333
const [searchParams, setSearchParams] = useSearchParams<SearchParams>();
@@ -48,3 +48,5 @@ export default function SearchField() {
4848
</form>
4949
);
5050
}
51+
52+
export { SearchField };

src/routes/not-found.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Title } from '@solidjs/meta';
33
import { HttpStatusCode } from '@solidjs/start';
44
import { makeTitle } from '../route-path';
55

6-
export default function NotFound() {
6+
function NotFound() {
77
return (
88
<div class="c-not-found">
99
<Title>{makeTitle('Not Found')}</Title>
@@ -19,3 +19,5 @@ export default function NotFound() {
1919
</div>
2020
);
2121
}
22+
23+
export { NotFound };

src/routes/note-new.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { Title } from '@solidjs/meta';
33
import { makeTitle } from '../route-path';
44
import { NoteEdit } from '../components/note-edit';
55

6-
export default function NoteNew() {
6+
function NoteNew() {
77
return (
88
<>
99
<Title>{makeTitle('New Note')}</Title>
1010
<NoteEdit noteId={undefined} initialTitle={'Untitled'} initialBody={''} />
1111
</>
1212
);
1313
}
14+
15+
export { NoteNew };

src/routes/note-none.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { Title } from '@solidjs/meta';
33
import { makeTitle } from '../route-path';
44

5-
export default function NoteNone() {
5+
function NoteNone() {
66
return (
77
<>
88
<Title>{makeTitle()}</Title>
@@ -12,3 +12,5 @@ export default function NoteNone() {
1212
</>
1313
);
1414
}
15+
16+
export { NoteNone };

src/routes/note.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function NoteDisplay(props: { noteId: string; note: NoteExpanded }) {
8484

8585
export type NoteProps = RouteSectionProps & { edit: boolean };
8686

87-
export default function Note(props: NoteProps) {
87+
function Note(props: NoteProps) {
8888
const isEdit = () => props.edit;
8989
const noteId = () => props.params.noteId;
9090
const navigate = useNavigate();
@@ -108,3 +108,5 @@ export default function Note(props: NoteProps) {
108108
</>
109109
);
110110
}
111+
112+
export { Note };

0 commit comments

Comments
 (0)