Fix SSR error if Suspense is missing

This commit is contained in:
Stan Wohlwend 2024-03-07 14:31:33 +01:00
parent ad7ee8d41a
commit f5108bc53d
8 changed files with 59 additions and 29 deletions

View File

@ -0,0 +1,5 @@
export default function Loading() {
return <>
Loading...
</>;
}

View File

@ -12,7 +12,7 @@
"lint": "next lint"
},
"dependencies": {
"next": "14.0.4",
"next": "^14.1",
"next-themes": "^0.2.1",
"react": "^18",
"react-dom": "^18",

View File

@ -0,0 +1,5 @@
export default function Loading() {
return <>
Loading...
</>;
}

View File

@ -60,7 +60,7 @@ npm install @stackframe/stack
4. In your `app/layout.tsx`, wrap your entire layout with a `StackProvider`. Afterwards, it should look like this:
```tsx
import React, { Suspense } from "react";
import React from "react";
import { StackProvider } from "@stackframe/stack";
import { stackApp } from "@/lib/stack";
@ -68,11 +68,9 @@ npm install @stackframe/stack
return (
<html lang="en">
<body>
<Suspense>
<StackProvider app={stackApp}>
{children}
</StackProvider>
</Suspense>
<StackProvider app={stackApp}>
{children}
</StackProvider>
</body>
</html>
);
@ -81,9 +79,19 @@ npm install @stackframe/stack
This lets you use the `useStackApp()` and `useUser()` hooks.
Note that Stack uses the new react suspense feature, which abstracted away all the hurdle of handling loading states. Check our [here](https://react.dev/reference/react/Suspense) if you want to learn more about suspense.
5. That's it! Stack is now configured in your Next.js project. If you start your Next.js app with `npm run dev` and navigate to `http://localhost:3000/handler/signup`, you will see the Stack sign-up page!
5. By default, Stack uses [`Suspense`](https://react.dev/reference/react/Suspense) to handle loading states. To show a loading indicator while Stack is fetching user data, make sure there is a `loading.tsx` file in your `app` directory:
```tsx
export default function Loading() {
// You can use any loading indicator here
return <>
Loading...
</>;
}
```
6. That's it! Stack is now configured in your Next.js project. If you start your Next.js app with `npm run dev` and navigate to `http://localhost:3000/handler/signup`, you will see the Stack sign-up page!
![Stack sign up page](../imgs/signup-page.png)

View File

@ -19,8 +19,6 @@ export function getNodeText(node: React.ReactNode): string {
/**
* Suspends the currently rendered component indefinitely. Will not unsuspend unless the component rerenders.
*
* You can use this to translate older query- or AsyncResult-based code to new the Suspense system, for example: `if (query.isLoading) suspend();`
*/
export function suspend(): never {
use(neverResolve());

View File

@ -30,20 +30,23 @@ export function suspend(): never {
/**
* Use this in a component or a hook to disable SSR.
* Use this in a component or a hook to disable SSR. Should be wrapped in a Suspense boundary, or it will throw an error.
*/
export function suspendIfSsr() {
export function suspendIfSsr(caller?: string) {
if (typeof window === "undefined") {
const error = Object.assign(
new Error(deindent`
This code path is not supported in SSR. This error should be caught by the closest Suspense boundary, and hence never be thrown on the client. If you still see the error, make sure the component is rendered inside a Suspense boundary.
${caller ?? "This code path"} attempted to display a loading indicator during SSR by falling back to the nearest Suspense boundary. If you see this error, it means no Suspense boundary was found, and no loading indicator could be displayed. Make sure you are not catching this error with try-catch, and that the component is rendered inside a Suspense boundary, for example by adding a \`loading.tsx\` file in your app directory.
See: https://react.dev/reference/react/Suspense#providing-a-fallback-for-server-errors-and-client-only-content
See: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
More information on SSR and Suspense boundaries: https://react.dev/reference/react/Suspense#providing-a-fallback-for-server-errors-and-client-only-content
`),
{
// set the digest so nextjs doesn't log the error
// https://github.com/vercel/next.js/blob/d01d6d9c35a8c2725b3d74c1402ab76d4779a6cf/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts#L14
digest: "BAILOUT_TO_CLIENT_SIDE_RENDERING",
reason: caller ?? "suspendIfSsr()",
}
);

View File

@ -189,9 +189,9 @@ function getTokenStore(tokenStoreOptions: TokenStoreOptions) {
}
const loadingSentinel = Symbol("stackAppCacheLoadingSentinel");
function useCache<D extends any[], T>(cache: AsyncCache<D, T>, dependencies: D): T {
function useCache<D extends any[], T>(cache: AsyncCache<D, T>, dependencies: D, caller: string): T {
// we explicitly don't want to run this hook in SSR
suspendIfSsr();
suspendIfSsr(caller);
const subscribe = useCallback((cb: () => void) => {
const { unsubscribe } = cache.onChange(dependencies, () => cb());
@ -476,7 +476,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
const router = useRouter();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const userJson = useCache(this._currentUserCache, [tokenStore]);
const userJson = useCache(this._currentUserCache, [tokenStore], "useUser()");
if (userJson === null) {
switch (options?.or) {
@ -571,7 +571,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useProject(): ClientProjectJson {
return useCache(this._currentProjectCache, []);
return useCache(this._currentProjectCache, [], "useProject()");
}
onProjectChange(callback: (project: ClientProjectJson) => void) {
@ -592,7 +592,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
useOwnedProjects(): Project[] {
this._ensureInternalProject();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const json = useCache(this._ownedProjectsCache, [tokenStore]);
const json = useCache(this._ownedProjectsCache, [tokenStore], "useOwnedProjects()");
return json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
@ -806,7 +806,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
this._ensurePersistentTokenStore();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const userJson = useCache(this._currentServerUserCache, [tokenStore]);
const userJson = useCache(this._currentServerUserCache, [tokenStore], "useServerUser()");
if (options?.required && userJson === null) {
use(this.redirectToSignIn());
@ -829,7 +829,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useServerUsers(): ServerUser[] {
const json = useCache(this._serverUsersCache, []);
const json = useCache(this._serverUsersCache, [], "useServerUsers()");
return json.map((j) => this._serverUserFromJson(j));
}
@ -936,7 +936,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
useProjectAdmin(): Project {
return this._projectAdminFromJson(
useCache(this._adminProjectCache, []),
useCache(this._adminProjectCache, [], "useProjectAdmin()"),
this._interface,
() => this._refreshProject()
);
@ -958,7 +958,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
}
useApiKeySets(): ApiKeySet[] {
const json = useCache(this._apiKeySetsCache, []);
const json = useCache(this._apiKeySetsCache, [], "useApiKeySets()");
return json.map((j) => this._createApiKeySetFromJson(j));
}

View File

@ -118,11 +118,11 @@ importers:
specifier: workspace:*
version: link:../../packages/stack-shared
next:
specifier: 14.0.4
version: 14.0.4(react-dom@18.2.0)(react@18.2.0)
specifier: ^14.1
version: 14.1.0(react-dom@18.2.0)(react@18.2.0)
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
version: 0.2.1(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: ^18
version: 18.2.0
@ -2719,7 +2719,7 @@ packages:
peerDependencies:
react: '*'
dependencies:
'@types/react': 18.2.60
'@types/react': 18.2.63
prop-types: 15.8.1
react: 18.2.0
@ -6006,7 +6006,6 @@ packages:
'@types/prop-types': 15.7.11
'@types/scheduler': 0.16.8
csstype: 3.1.3
dev: false
/@types/retry@0.12.0:
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@ -12036,6 +12035,18 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/next-themes@0.2.1(next@14.1.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==}
peerDependencies:
next: '*'
react: '*'
react-dom: '*'
dependencies:
next: 14.1.0(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/next@14.0.4(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==}
engines: {node: '>=18.17.0'}