Repository: dai-shi/waku
Pull request #1131 (2025-01-07 03:54:30)

feat: Remove ErrorBoundary from Router
Would this potentially be ok? re: #967 

vercel[bot]: [vc]: #SnsE8vPgdsNNAYxIPLvSSf7Xh68yUpPbdzje7Elcd3c=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3YWt1Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2RhaXNoaXMtcHJvamVjdHMvd2FrdS8xM3Jndlloem9xd1g1amJ3MTVzaU1FcGJScnU2IiwicHJldmlld1VybCI6Indha3UtZ2l0LWZvcmstcm1hcnNjaGVyLWZlYXQtY3VzdG9tLXJvby1jOGJjYTktZGFpc2hpcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **waku** | 🔄 Building ([Inspect](https://vercel.com/daishis-projects/waku/13rgvYhzoqwX5jbw15siMEpbRru6)) | [Visit Preview](https://waku-git-fork-rmarscher-feat-custom-roo-c8bca9-daishis-projects.vercel.app) | Jan 7, 2025 3:54am |


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dai-shi/waku/pr/1131/builds/576065) or the icon next to each commit SHA.
dai-shi: This is slightly different from my intention.

Do you have an actual problem with having your own ErrorBoundary in `_layout.tsx`? If that's the case, I would completely remove ErrorBoundary from `Router`.
rmarscher: > Do you have an actual problem with having your own ErrorBoundary in `_layout.tsx`? If that's the case, I would completely remove ErrorBoundary from `Router`.

No, I have an error boundary in my root layout. I keep hitting errors that are outside that error boundary though which are getting caught by the router error boundary. I want to control the fallback output and logging. The `renderError` html output is too minimal.


rmarscher: You could remove ErrorBoundary from Router completely and I could include one in main.tsx though. I think that would work. 👍 
dai-shi: Let's remove ErrorBoundary from Router completely. (Prefer composition than customization.)
Should we add the default one in rscManagedPlugin?
What would happen if we have no error boundaries?
rmarscher: > Let's remove ErrorBoundary from Router completely. (Prefer composition than customization.)
Great.

> Should we add the default one in rscManagedPlugin?
Probably...

> What would happen if we have no error boundaries?
I'm not sure! I'll have to try and see if it gets handled by Hono. That will help determine if we should add a default back in rscManagedPlugin.

dai-shi: > I'm not sure! I'll have to try and see if it gets handled by Hono. That will help determine if we should add a default back in rscManagedPlugin.

Yes, please. It's not only with Hono, because the error boundary is used on the client too.

My preference is **not** add the default one in rscManagedPlugin.
rmarscher: > What would happen if we have no error boundaries?

It seems to render no html output and console log the error. During dev, it also shows this warning:

```
An error occurred in the <InnerSlot> component.

Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
```

With npm start, it also clears out all the html on the page on error - inspecting the page reveals only script and modulepreload link tags. It still logs the error but doesn't have the dev warning.

For example, run the 01_template example, go to chrome devtools network tab, change throttling to offline, click on the about page link. You should hit the error and see something like this in the console:

Uncaught (in promise) TypeError: Failed to fetch dynamically imported module: http://localhost:8080/assets/rsc3-98568e70c.js

dai-shi: before change

<img width="848" alt="image" src="https://github.com/user-attachments/assets/bcc17aee-9dd0-4c14-a848-3f08baaad815" />

after change

<img width="863" alt="image" src="https://github.com/user-attachments/assets/af9efb8b-2ed2-4d1c-9c18-de29cbfbada9" />

----

Hmmm, I'm not very happy with the default error boundary, but the blank page doesn't seem very nice either.
dai-shi: <img width="827" alt="image" src="https://github.com/user-attachments/assets/be5661cd-3684-4f86-998d-00d206a578be" />

Although, an empty Vite / React app is the same.
rmarscher: Great. That sounds good. Thanks.
rmarscher: There's an interesting test failure now in the hot reload e2e spec.

If you load a page in the dev server, navigate to a different page and then edit the source for that page to trigger a reload, there is an error caught by the default error boundary: `No such element: route:/`

It doesn't happen when hot reloading the same page that was initially loaded. So if I load http://localhost:3000/about directly and edit the title, it reloads without issue.
rmarscher: I also notice that the new default main.tsx has a hydration error:

```
  <ErrorBoundary>
+   <Suspense>
-   <html>
```

It looks like there is a default html element getting inserted somewhere that doesn't match - maybe in vite-plugin-rsc-index?
dai-shi: > * as well as `<Suspense>` just within `<ErrorBoundary>`.

I think I was confused something. My intention was having Suspense on the server to catch server errors, but this is client side. So, let's just remove `<Suspense>`.

Also, I'm thinking about adding the default ErrorBoundary in DefaultRoot of create-pages.ts, instead of rscManagedPlugin. What do you think? @rmarscher @tylersayshi 
tylersayshi: > Also, I'm thinking about adding the default ErrorBoundary in DefaultRoot of create-pages.ts, instead of rscManagedPlugin.

this sounds good to me. Seems like a more proper place compared to the root layout
dai-shi: > adding the default ErrorBoundary in DefaultRoot

Trying it and it reveals a bug...
dai-shi: The bug is fixed, but unreachable spec is failing, which we added in #1112.
We could change the spec, but as this can happen very easily (and people see the blank page), I wonder if the decision to put the default error boundary in DefaultRoot is right.

Is it normal too see the blank page with the client navigation error? Vite React does it though. How about other React frameworks? Next? Remix?
rmarscher: > How about other React frameworks? Next? Remix?

They both implement custom ErrorBoundaries to allow components to throw routing errors during render - 401, 404, etc - that get caught and translated into server response codes. Remix has a useRouteError hook that makes the error available.

I think they also use it for redirects - so redirects throw a special error object that instructs the server to return a 307 or 308 error code and Location header.

That's something we could implement in userland, but maybe worth putting into Waku.

NextJS has special file-system naming for defining global and route error boundaries. 

Remix lets you define an ErrorBoundary for each route component. I can see here that they load a RemixRootDefaultErrorBoundary. https://github.com/remix-run/remix/blob/v2.11.1/packages/remix-react/routes.tsx#L74-L126

https://github.com/remix-run/remix/blob/main/packages/remix-react/errorBoundaries.tsx

Remix's default is pretty basic like ours has been. It's an H1 tag with the error type and error. Plus it displays the stack trace if in dev mode. It also uses console.log to try to send a message to the developer.
"💿 Hey developer 👋. You can provide a way better UX than this when your app throws errors. Check out https://remix.run/guides/errors for more information."
https://github.com/remix-run/remix/blob/542310d95cba4ac41eb70cbd26a7c40a9e31090e/packages/remix-react/errorBoundaries.tsx#L18

Remix has an interesting hydration fallback component and error boundary too.

ref:
https://nextjs.org/docs/app/building-your-application/routing/error-handling#using-error-boundaries
https://remix.run/docs/en/main/route/error-boundary
dai-shi: Alright, then, in this PR, let's do https://github.com/dai-shi/waku/pull/1131#issuecomment-2577556144 without adding `<Suspense>`

Afterward, I see several points for improvement, like ErrorBoundary in waku/router/client and DefaultRoot in create-pages.ts.
dai-shi: Wait, let me try something. I really like the idea of DefaultRoot, if it's possible.
dai-shi: It required more changes to cover this case. I think the outcome is good.
@tylersayshi This changes define-router api a bit and so does create-pages impl.

Patch:
diff --git a/examples/22_define-router/src/entries.tsx b/examples/22_define-router/src/entries.tsx
index 2873860a..e550d8f2 100644
--- a/examples/22_define-router/src/entries.tsx
+++ b/examples/22_define-router/src/entries.tsx
@@ -15,9 +15,9 @@ export default defineRouter({
       {
         pattern: '/',
         path: [],
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         elements: {
-          root: { isStatic: true },
           'layout:/': { isStatic: true },
           'page:/': { isStatic: true },
         },
@@ -25,9 +25,9 @@ export default defineRouter({
       {
         pattern: '/foo',
         path: [{ type: 'literal', name: 'foo' }],
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         elements: {
-          root: { isStatic: true },
           'layout:/': { isStatic: true },
           'page:/foo': { isStatic: true },
         },
@@ -35,9 +35,9 @@ export default defineRouter({
       {
         pattern: '/bar',
         path: [{ type: 'literal', name: 'bar' }],
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         elements: {
-          root: { isStatic: true },
           'layout:/': { isStatic: true },
           'page:/bar': { isStatic: true },
         },
@@ -48,9 +48,9 @@ export default defineRouter({
           { type: 'literal', name: 'nested' },
           { type: 'literal', name: 'baz' },
         ],
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         elements: {
-          root: { isStatic: true },
           'layout:/': { isStatic: true },
           'page:/nested/baz': { isStatic: true },
         },
@@ -61,9 +61,9 @@ export default defineRouter({
           { type: 'literal', name: 'dynamic' },
           { type: 'group', name: 'slug' },
         ],
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         elements: {
-          root: { isStatic: true },
           'layout:/': { isStatic: true },
           // using `[slug]` syntax is just an example and it technically conflicts with others. So, it's better to use a different prefix like `dynamic-page:`.
           'page:/dynamic/[slug]': {},
@@ -74,19 +74,17 @@ export default defineRouter({
   handleRoute: async (path) => {
     if (path === '/') {
       return {
+        rootElement: (
+          <Root>
+            <Children />
+          </Root>
+        ),
         routeElement: (
-          <Slot id="root">
-            <Slot id="layout:/">
-              <Slot id="page:/" />
-            </Slot>
+          <Slot id="layout:/">
+            <Slot id="page:/" />
           </Slot>
         ),
         elements: {
-          root: (
-            <Root>
-              <Children />
-            </Root>
-          ),
           'layout:/': (
             <HomeLayout>
               <Children />
@@ -98,19 +96,17 @@ export default defineRouter({
     }
     if (path === '/foo') {
       return {
+        rootElement: (
+          <Root>
+            <Children />
+          </Root>
+        ),
         routeElement: (
-          <Slot id="root">
-            <Slot id="layout:/">
-              <Slot id="page:/foo" />
-            </Slot>
+          <Slot id="layout:/">
+            <Slot id="page:/foo" />
           </Slot>
         ),
         elements: {
-          root: (
-            <Root>
-              <Children />
-            </Root>
-          ),
           'layout:/': (
             <HomeLayout>
               <Children />
@@ -122,19 +118,17 @@ export default defineRouter({
     }
     if (path === '/bar') {
       return {
+        rootElement: (
+          <Root>
+            <Children />
+          </Root>
+        ),
         routeElement: (
-          <Slot id="root">
-            <Slot id="layout:/">
-              <Slot id="page:/bar" />
-            </Slot>
+          <Slot id="layout:/">
+            <Slot id="page:/bar" />
           </Slot>
         ),
         elements: {
-          root: (
-            <Root>
-              <Children />
-            </Root>
-          ),
           'layout:/': (
             <HomeLayout>
               <Children />
@@ -146,19 +140,17 @@ export default defineRouter({
     }
     if (path === '/nested/baz') {
       return {
+        rootElement: (
+          <Root>
+            <Children />
+          </Root>
+        ),
         routeElement: (
-          <Slot id="root">
-            <Slot id="layout:/">
-              <Slot id="page:/nested/baz" />
-            </Slot>
+          <Slot id="layout:/">
+            <Slot id="page:/nested/baz" />
           </Slot>
         ),
         elements: {
-          root: (
-            <Root>
-              <Children />
-            </Root>
-          ),
           'layout:/': (
             <HomeLayout>
               <Children />
@@ -170,19 +162,17 @@ export default defineRouter({
     }
     if (path.startsWith('/dynamic/')) {
       return {
+        rootElement: (
+          <Root>
+            <Children />
+          </Root>
+        ),
         routeElement: (
-          <Slot id="root">
-            <Slot id="layout:/">
-              <Slot id="page:/dynamic/[slug]" />
-            </Slot>
+          <Slot id="layout:/">
+            <Slot id="page:/dynamic/[slug]" />
           </Slot>
         ),
         elements: {
-          root: (
-            <Root>
-              <Children />
-            </Root>
-          ),
           'layout:/': (
             <HomeLayout>
               <Children />
diff --git a/examples/22_define-router/src/main.tsx b/examples/22_define-router/src/main.tsx
index bddb502d..2bfe6e4c 100644
--- a/examples/22_define-router/src/main.tsx
+++ b/examples/22_define-router/src/main.tsx
@@ -1,10 +1,12 @@
 import { StrictMode } from 'react';
 import { createRoot, hydrateRoot } from 'react-dom/client';
-import { Router } from 'waku/router/client';
+import { Router, ErrorBoundary } from 'waku/router/client';
 
 const rootElement = (
   <StrictMode>
-    <Router />
+    <ErrorBoundary>
+      <Router />
+    </ErrorBoundary>
   </StrictMode>
 );
 
diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
index 07d7bfbc..e8d8eb4c 100644
--- a/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
+++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-transform.ts
@@ -16,6 +16,8 @@ const collectExportNames = (mod: swc.Module) => {
     if (item.type === 'ExportDeclaration') {
       if (item.declaration.type === 'FunctionDeclaration') {
         exportNames.add(item.declaration.identifier.value);
+      } else if (item.declaration.type === 'ClassDeclaration') {
+        exportNames.add(item.declaration.identifier.value);
       } else if (item.declaration.type === 'VariableDeclaration') {
         for (const d of item.declaration.declarations) {
           if (d.id.type === 'Identifier') {
diff --git a/packages/waku/src/minimal/client.ts b/packages/waku/src/minimal/client.ts
index 3c6a9f56..4ca6c683 100644
--- a/packages/waku/src/minimal/client.ts
+++ b/packages/waku/src/minimal/client.ts
@@ -9,6 +9,7 @@ import {
   useCallback,
   useEffect,
   useState,
+  Component,
 } from 'react';
 import type { ReactNode } from 'react';
 import RSDWClient from 'react-server-dom-webpack/client';
@@ -244,22 +245,56 @@ const InnerSlot = ({
   id,
   elementsPromise,
   children,
+  setFallback,
 }: {
   id: string;
   elementsPromise: Elements;
   children?: ReactNode;
+  setFallback?: (fallback: ReactNode) => void;
 }) => {
   const elements = use(elementsPromise);
   if (!(id in elements)) {
     throw new Error('No such element: ' + id);
   }
-  return createElement(
-    ChildrenContextProvider,
-    { value: children },
-    elements[id],
-  );
+  const element = elements[id];
+  useEffect(() => {
+    if (setFallback) {
+      setFallback(element);
+    }
+  }, [element, setFallback]);
+  return createElement(ChildrenContextProvider, { value: children }, element);
 };
 
+const ThrowError = ({ error }: { error: unknown }) => {
+  throw error;
+};
+
+class Fallback extends Component<
+  { children: ReactNode; fallback: ReactNode },
+  { error?: unknown }
+> {
+  constructor(props: { children: ReactNode; fallback: ReactNode }) {
+    super(props);
+    this.state = {};
+  }
+  static getDerivedStateFromError(error: unknown) {
+    return { error };
+  }
+  render() {
+    if ('error' in this.state) {
+      if (this.props.fallback) {
+        return createElement(
+          ChildrenContextProvider,
+          { value: createElement(ThrowError, { error: this.state.error }) },
+          this.props.fallback,
+        );
+      }
+      throw this.state.error;
+    }
+    return this.props.children;
+  }
+}
+
 /**
  * Slot component
  * This is used under the Root component.
@@ -277,14 +312,24 @@ const InnerSlot = ({
 export const Slot = ({
   id,
   children,
+  unstable_fallbackToPrev,
 }: {
   id: string;
   children?: ReactNode;
+  unstable_fallbackToPrev?: boolean;
 }) => {
+  const [fallback, setFallback] = useState<ReactNode>();
   const elementsPromise = use(ElementsContext);
   if (!elementsPromise) {
     throw new Error('Missing Root component');
   }
+  if (unstable_fallbackToPrev) {
+    return createElement(
+      Fallback,
+      { fallback } as never,
+      createElement(InnerSlot, { id, elementsPromise, setFallback }, children),
+    );
+  }
   return createElement(InnerSlot, { id, elementsPromise }, children);
 };
 
diff --git a/packages/waku/src/router/client.ts b/packages/waku/src/router/client.ts
index c68436da..5a53256a 100644
--- a/packages/waku/src/router/client.ts
+++ b/packages/waku/src/router/client.ts
@@ -1,7 +1,6 @@
 'use client';
 
 import {
-  Component,
   createContext,
   createElement,
   startTransition,
@@ -12,6 +11,7 @@ import {
   useState,
   useTransition,
   Fragment,
+  Component,
 } from 'react';
 import type {
   ComponentProps,
@@ -280,7 +280,7 @@ function renderError(message: string) {
   );
 }
 
-class ErrorBoundary extends Component<
+export class ErrorBoundary extends Component<
   { children: ReactNode },
   { error?: unknown }
 > {
@@ -412,14 +412,16 @@ const InnerRouter = ({
     };
   }, [changeRoute, locationListeners]);
 
-  const routeElement = createElement(Slot, {
-    id: getRouteSlotId(route.path),
-  });
-
+  const routeElement = createElement(Slot, { id: getRouteSlotId(route.path) });
+  const rootElement = createElement(
+    Slot,
+    { id: 'root', unstable_fallbackToPrev: true },
+    routeElement,
+  );
   return createElement(
     RouterContext.Provider,
     { value: { route, changeRoute, prefetchRoute } },
-    routeElement,
+    rootElement,
   );
 };
 
@@ -504,21 +506,17 @@ export function Router({
     };
   const initialRscParams = createRscParams(initialRoute.query);
   return createElement(
-    ErrorBoundary,
-    null,
-    createElement(
-      Root as FunctionComponent<Omit<ComponentProps<typeof Root>, 'children'>>,
-      {
-        initialRscPath,
-        initialRscParams,
-        unstable_enhanceFetch,
-        unstable_enhanceCreateData,
-      },
-      createElement(InnerRouter, {
-        routerData: routerData as Required<RouterData>,
-        initialRoute,
-      }),
-    ),
+    Root as FunctionComponent<Omit<ComponentProps<typeof Root>, 'children'>>,
+    {
+      initialRscPath,
+      initialRscParams,
+      unstable_enhanceFetch,
+      unstable_enhanceCreateData,
+    },
+    createElement(InnerRouter, {
+      routerData: routerData as Required<RouterData>,
+      initialRoute,
+    }),
   );
 }
 
@@ -528,6 +526,11 @@ export function Router({
  */
 export function ServerRouter({ route }: { route: RouteProps }) {
   const routeElement = createElement(Slot, { id: getRouteSlotId(route.path) });
+  const rootElement = createElement(
+    Slot,
+    { id: 'root', unstable_fallbackToPrev: true },
+    routeElement,
+  );
   return createElement(
     Fragment,
     null,
@@ -540,7 +543,7 @@ export function ServerRouter({ route }: { route: RouteProps }) {
           prefetchRoute: notAvailableInServer('prefetchRoute'),
         },
       },
-      routeElement,
+      rootElement,
     ),
   );
 }
diff --git a/packages/waku/src/router/create-pages.ts b/packages/waku/src/router/create-pages.ts
index 3b7a9316..d6768ad8 100644
--- a/packages/waku/src/router/create-pages.ts
+++ b/packages/waku/src/router/create-pages.ts
@@ -16,6 +16,7 @@ import type {
   PropsForPages,
 } from './create-pages-utils/inferred-path-types.js';
 import { Children, Slot } from '../minimal/client.js';
+import { ErrorBoundary } from '../router/client.js';
 
 const sanitizeSlug = (slug: string) =>
   slug.replace(/\./g, '').replace(/ /g, '-');
@@ -174,10 +175,14 @@ export type CreateRoot = (root: RootItem) => void;
  */
 const DefaultRoot = ({ children }: { children: ReactNode }) =>
   createElement(
-    'html',
+    ErrorBoundary,
     null,
-    createElement('head', null),
-    createElement('body', null, children),
+    createElement(
+      'html',
+      null,
+      createElement('head', null),
+      createElement('body', null, children),
+    ),
   );
 
 const createNestedElements = (
@@ -437,6 +442,7 @@ export const createPages = <
       const paths: {
         path: PathSpec;
         pathPattern?: PathSpec;
+        rootElement: { isStatic?: boolean };
         routeElement: { isStatic?: boolean };
         elements: Record<string, { isStatic?: boolean }>;
         noSsr: boolean;
@@ -458,13 +464,13 @@ export const createPages = <
             },
             {},
           ),
-          root: { isStatic: rootIsStatic },
           [`page:${path}`]: { isStatic: staticPathMap.has(path) },
         };
 
         paths.push({
           path: literalSpec,
           ...(originalSpec && { pathPattern: originalSpec }),
+          rootElement: { isStatic: rootIsStatic },
           routeElement: {
             isStatic: true,
           },
@@ -485,11 +491,11 @@ export const createPages = <
             },
             {},
           ),
-          root: { isStatic: rootIsStatic },
           [`page:${path}`]: { isStatic: false },
         };
         paths.push({
           path: pathSpec,
+          rootElement: { isStatic: rootIsStatic },
           routeElement: { isStatic: true },
           elements,
           noSsr,
@@ -508,11 +514,11 @@ export const createPages = <
             },
             {},
           ),
-          root: { isStatic: rootIsStatic },
           [`page:${path}`]: { isStatic: false },
         };
         paths.push({
           path: pathSpec,
+          rootElement: { isStatic: rootIsStatic },
           routeElement: { isStatic: true },
           elements,
           noSsr,
@@ -541,11 +547,6 @@ export const createPages = <
       const pathSpec = parsePathWithSlug(routePath);
       const mapping = getPathMapping(pathSpec, path);
       const result: Record<string, ReactNode> = {
-        root: createElement(
-          rootItem ? rootItem.component : DefaultRoot,
-          null,
-          createElement(Children),
-        ),
         [`page:${routePath}`]: createElement(
           pageComponent,
           { ...mapping, ...(query ? { query } : {}), path },
@@ -586,11 +587,12 @@ export const createPages = <
 
       return {
         elements: result,
-        routeElement: createElement(
-          Slot,
-          { id: 'root' },
-          createNestedElements(routeChildren),
+        rootElement: createElement(
+          rootItem ? rootItem.component : DefaultRoot,
+          null,
+          createElement(Children),
         ),
+        routeElement: createNestedElements(routeChildren),
       };
     },
     getApiConfig: async () => {
diff --git a/packages/waku/src/router/define-router.ts b/packages/waku/src/router/define-router.ts
index 2e8159a5..ae86ea58 100644
--- a/packages/waku/src/router/define-router.ts
+++ b/packages/waku/src/router/define-router.ts
@@ -77,6 +77,7 @@ export function unstable_defineRouter(fns: {
     Iterable<{
       path: PathSpec;
       pathPattern?: PathSpec;
+      rootElement: { isStatic?: boolean };
       routeElement: { isStatic?: boolean };
       elements: Record<SlotId, { isStatic?: boolean }>;
       noSsr?: boolean;
@@ -88,6 +89,7 @@ export function unstable_defineRouter(fns: {
       query?: string;
     },
   ) => Promise<{
+    rootElement: ReactNode;
     routeElement: ReactNode;
     elements: Record<SlotId, ReactNode>;
   }>;
@@ -137,6 +139,7 @@ export function unstable_defineRouter(fns: {
             item.path[0]!.type === 'literal' &&
             item.path[0]!.name === '404';
           const isStatic =
+            !!item.rootElement.isStatic &&
             !!item.routeElement.isStatic &&
             Object.values(item.elements).every((x) => x.isStatic);
           return {
@@ -209,7 +212,7 @@ export function unstable_defineRouter(fns: {
     }
     const skip = isStringArray(skipParam) ? skipParam : [];
     const { query } = parseRscParams(rscParams);
-    const { routeElement, elements } = await fns.handleRoute(
+    const { rootElement, routeElement, elements } = await fns.handleRoute(
       pathname,
       pathConfigItem.specs.isStatic ? {} : { query },
     );
@@ -220,6 +223,7 @@ export function unstable_defineRouter(fns: {
     }
     const entries = {
       ...elements,
+      root: rootElement,
       [ROUTE_SLOT_ID_PREFIX + pathname]: routeElement,
     };
     for (const skipId of await filterEffectiveSkip(pathname, skip)) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9f628ed3..bdef76d6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -103,7 +103,7 @@ importers:
         specifier: 19.0.0
         version: 19.0.0(react@19.0.0)
       react-error-boundary:
-        specifier: ^5.0.0
+        specifier: 5.0.0
         version: 5.0.0(react@19.0.0)
       react-server-dom-webpack:
         specifier: 19.0.0


Test patch:
diff --git a/e2e/fixtures/create-pages/package.json b/e2e/fixtures/create-pages/package.json
index 58e25c8e..a6ce7c75 100644
--- a/e2e/fixtures/create-pages/package.json
+++ b/e2e/fixtures/create-pages/package.json
@@ -11,7 +11,7 @@
   "dependencies": {
     "react": "19.0.0",
     "react-dom": "19.0.0",
-    "react-error-boundary": "^5.0.0",
+    "react-error-boundary": "5.0.0",
     "react-server-dom-webpack": "19.0.0",
     "waku": "workspace:*"
   },
diff --git a/e2e/fixtures/create-pages/src/components/HomeLayout.tsx b/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
index 5d5911f1..fab63a7c 100644
--- a/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
+++ b/e2e/fixtures/create-pages/src/components/HomeLayout.tsx
@@ -18,6 +18,7 @@ const Pending = ({ isPending }: { isPending: boolean }) => (
 
 const HomeLayout = ({ children }: { children: ReactNode }) => (
   <div>
+    <title>Waku</title>
     <ul>
       <li>
         <Link
diff --git a/e2e/fixtures/create-pages/src/components/Root.tsx b/e2e/fixtures/create-pages/src/components/Root.tsx
deleted file mode 100644
index 3dc1162c..00000000
--- a/e2e/fixtures/create-pages/src/components/Root.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { ReactNode } from 'react';
-
-export default function Root({ children }: { children: ReactNode }) {
-  return (
-    <html>
-      <head>
-        <title>Waku</title>
-      </head>
-      <body>{children}</body>
-    </html>
-  );
-}
diff --git a/e2e/fixtures/create-pages/src/entries.tsx b/e2e/fixtures/create-pages/src/entries.tsx
index ec2be01a..0ade82b8 100644
--- a/e2e/fixtures/create-pages/src/entries.tsx
+++ b/e2e/fixtures/create-pages/src/entries.tsx
@@ -5,18 +5,12 @@ import FooPage from './components/FooPage.js';
 import HomeLayout from './components/HomeLayout.js';
 import HomePage from './components/HomePage.js';
 import NestedBazPage from './components/NestedBazPage.js';
-import Root from './components/Root.js';
 import NestedLayout from './components/NestedLayout.js';
 import { DeeplyNestedLayout } from './components/DeeplyNestedLayout.js';
 import ErrorPage from './components/ErrorPage.js';
 
 const pages: ReturnType<typeof createPages> = createPages(
-  async ({ createPage, createLayout, createRoot }) => [
-    createRoot({
-      render: 'static',
-      component: Root,
-    }),
-
+  async ({ createPage, createLayout }) => [
     createLayout({
       render: 'static',
       path: '/',
diff --git a/e2e/fixtures/define-router/src/entries.tsx b/e2e/fixtures/define-router/src/entries.tsx
index 047aaa2e..6522a666 100644
--- a/e2e/fixtures/define-router/src/entries.tsx
+++ b/e2e/fixtures/define-router/src/entries.tsx
@@ -21,9 +21,9 @@ const router: ReturnType<typeof defineRouter> = defineRouter({
         .split('/')
         .filter(Boolean)
         .map((name) => ({ type: 'literal', name })),
+      rootElement: { isStatic: true },
       routeElement: { isStatic: true },
       elements: {
-        root: { isStatic: true },
         'layout:/': { isStatic: true },
         [`page:${path}`]: { isStatic: true },
       },
@@ -33,24 +33,22 @@ const router: ReturnType<typeof defineRouter> = defineRouter({
       throw new Error('renderRoute: No such path:' + path);
     }
     return {
+      rootElement: (
+        <html>
+          <head>
+            <title>Waku example</title>
+          </head>
+          <body>
+            <Children />
+          </body>
+        </html>
+      ),
       routeElement: (
-        <Slot id="root">
-          <Slot id="layout:/">
-            <Slot id={`page:${path}`} />
-          </Slot>
+        <Slot id="layout:/">
+          <Slot id={`page:${path}`} />
         </Slot>
       ),
       elements: {
-        root: (
-          <html>
-            <head>
-              <title>Waku example</title>
-            </head>
-            <body>
-              <Children />
-            </body>
-          </html>
-        ),
         'layout:/': (
           <Layout>
             <Children />
diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts
index 5f277bc4..bcfc43c7 100644
--- a/packages/waku/tests/create-pages.test.ts
+++ b/packages/waku/tests/create-pages.test.ts
@@ -493,9 +493,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [{ name: 'test', type: 'literal' }],
@@ -506,8 +506,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test']);
   });
 
   it('creates a simple dynamic page', async () => {
@@ -523,9 +524,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [{ name: 'test', type: 'literal' }],
@@ -535,8 +536,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test']);
   });
 
   it('creates a simple static api', async () => {
@@ -627,9 +629,9 @@ describe('createPages pages and layouts', () => {
       {
         elements: {
           'layout:/': { isStatic: true },
-          root: { isStatic: true },
           'page:/test': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [{ name: 'test', type: 'literal' }],
@@ -639,12 +641,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual([
-      'root',
-      'page:/test',
-      'layout:/',
-    ]);
+    expect(Object.keys(route.elements)).toEqual(['page:/test', 'layout:/']);
   });
 
   it('creates a simple dynamic page with a layout', async () => {
@@ -668,9 +667,9 @@ describe('createPages pages and layouts', () => {
       {
         elements: {
           'layout:/': { isStatic: false },
-          root: { isStatic: true },
           'page:/test': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [{ name: 'test', type: 'literal' }],
@@ -681,12 +680,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual([
-      'root',
-      'page:/test',
-      'layout:/',
-    ]);
+    expect(Object.keys(route.elements)).toEqual(['page:/test', 'layout:/']);
   });
 
   it('creates a nested static page', async () => {
@@ -702,9 +698,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/nested': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -717,8 +713,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test/nested']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/nested']);
   });
 
   it('creates a nested static page with nested layout', async () => {
@@ -739,10 +736,10 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/nested': { isStatic: true },
           'layout:/test/nested': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -755,9 +752,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
     expect(Object.keys(route.elements)).toEqual([
-      'root',
       'page:/test/nested',
       'layout:/test/nested',
     ]);
@@ -776,9 +773,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/nested': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -792,8 +789,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test/nested']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/nested']);
   });
 
   it('creates a static page with slugs', async () => {
@@ -813,9 +811,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/w/x': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -831,9 +829,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/y/z': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -852,8 +850,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test/y/z']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/y/z']);
   });
 
   it('creates a dynamic page with slugs', async () => {
@@ -869,9 +868,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/[a]/[b]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -885,8 +884,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test/[a]/[b]']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/[a]/[b]']);
   });
 
   it('creates a static page with wildcards', async () => {
@@ -903,9 +903,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/a/b': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -923,8 +923,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual(['root', 'page:/test/a/b']);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/a/b']);
   });
 
   it('creates a dynamic page with wildcards', async () => {
@@ -940,9 +941,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/test/[...path]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -955,11 +956,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     expect(route).toBeDefined();
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
-    expect(Object.keys(route.elements)).toEqual([
-      'root',
-      'page:/test/[...path]',
-    ]);
+    expect(Object.keys(route.elements)).toEqual(['page:/test/[...path]']);
   });
 
   it('fails if static paths do not match the slug pattern', async () => {
@@ -997,18 +996,18 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/static': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: true,
         path: [{ name: 'static', type: 'literal' }],
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/dynamic': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: true,
         path: [{ name: 'dynamic', type: 'literal' }],
@@ -1081,9 +1080,9 @@ describe('createPages pages and layouts', () => {
     expect(await getRouteConfig()).toEqual([
       {
         elements: {
-          root: { isStatic: true },
           'page:/client/static': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1093,9 +1092,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/static/static-echo': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1111,9 +1110,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/static/static-echo-2': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1129,9 +1128,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/static/static-echo/static-echo-2': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1149,9 +1148,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/static/hello/hello-2': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1169,9 +1168,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/static/wild/bar': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1187,9 +1186,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/static/wild/hello/hello-2': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1206,9 +1205,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/static/wild/foo/foo-2/foo-3': { isStatic: true },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1226,9 +1225,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/client/dynamic': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1238,9 +1237,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/one/[echo]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1251,9 +1250,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/two/[echo]/[echo2]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1265,9 +1264,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/wild/[...wild]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1278,9 +1277,9 @@ describe('createPages pages and layouts', () => {
       },
       {
         elements: {
-          root: { isStatic: true },
           'page:/server/oneAndWild/[slug]/[...wild]': { isStatic: false },
         },
+        rootElement: { isStatic: true },
         routeElement: { isStatic: true },
         noSsr: false,
         path: [
@@ -1295,9 +1294,9 @@ describe('createPages pages and layouts', () => {
       query: '?skip=[]',
     });
     assert(route);
+    expect(route.rootElement).toBeDefined();
     expect(route.routeElement).toBeDefined();
     expect(Object.keys(route.elements)).toEqual([
-      'root',
       'page:/server/two/[echo]/[echo2]',
     ]);
   });
diff --git a/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts b/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts
index 9d8b48a1..e09e994b 100644
--- a/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts
+++ b/packages/waku/tests/vite-plugin-rsc-transform-internals.test.ts
@@ -29,6 +29,8 @@ export default function App() {
     const code = `
 'use client';
 
+import { Component } from 'react';
+
 export const Empty = () => null;
 
 function Private() {
@@ -39,6 +41,12 @@ export function Greet({ name }: { name: string }) {
   return <>Hello {name}</>;
 }
 
+export class MyComponent extends Component {
+  render() {
+    return <p>Class Component</p>;
+  }
+}
+
 export default function App() {
   return <div>Hello World</div>;
 }
@@ -52,6 +60,8 @@ export default function App() {
 
         export const Greet = registerClientReference(() => { throw new Error('It is not possible to invoke a client function from the server: /src/App.tsx#Greet'); }, '/src/App.tsx', 'Greet');
 
+        export const MyComponent = registerClientReference(() => { throw new Error('It is not possible to invoke a client function from the server: /src/App.tsx#MyComponent'); }, '/src/App.tsx', 'MyComponent');
+
         export default registerClientReference(() => { throw new Error('It is not possible to invoke a client function from the server: /src/App.tsx#default'); }, '/src/App.tsx', 'default');
         "
       `);