dai-shi / waku

⛩️ The minimal React framework
https://waku.gg
MIT License
4.49k stars 120 forks source link

Can Client components render Server components (yet)? #51

Closed jwbay closed 1 year ago

jwbay commented 1 year ago

I created a test app with npm create waku@latest to play around with RSC without the baggage of next.js. I ran into an issue trying to render server components inside client components, but it's quite possible I'm missing something, too. Here's the code:

App.tsx

import * as fs from 'node:fs';
import { FilePicker } from './FilePicker.js';

const App = () => {
  const entries = fs.readdirSync('assets', { withFileTypes: true });
  const files = entries.filter(e => e.isFile()).map(e => e.name).sort();
  return (
    <div style={{ border: "3px red dashed", margin: "1em", padding: "1em" }}>
      <p>(Server component)</p>
      <FilePicker files={files} />
    </div>
  );
};

export default App;

FilePicker.tsx

'use client';
import React from 'react';
import { FileRenderer } from './FileRenderer.js';

export function FilePicker({ files }: { files: string[] }) {
    'use client';
    const initial = files[0]!;
    const [selected, setSelected] = React.useState(initial)
    return <>
        <div style={{ border: "3px blue dashed", margin: "1em", padding: "1em" }}>
            <p>(Client component)</p>
            Pick a file: <br />
            <select value={selected} onChange={e => setSelected(e.target.value)}>
                {files.map(f => <option key={f} value={f}>{f}</option>)}
            </select>
            <FileRenderer path={selected} />
        </div>
    </>
}

FileRenderer.tsx

'use server';
import * as fs from 'fs';

export function FileRenderer({ path }: { path: string }) {
    // 💥
    const file = path //  fs.readFileSync(`assets/${path}`);

    return <div style={{ border: "3px red dashed", margin: "1em", padding: "1em" }}>
        <p>(Server component)</p>
        <p>Content of {path}:</p>
        <p>{file.toString()}</p>
    </div>
}

That code renders like this: image

However, if I uncomment the fs reference in FileRenderer to actually use "server" code inside FileRenderer, the UI fails with this error:

Uncaught TypeError: fs.readFileSync is not a function ``` FileRenderer@http://localhost:3000/src/FileRenderer.tsx?t=1685134414661:21:19 renderWithHooks@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:8179:35 mountIndeterminateComponent@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:11861:21 beginWork$1@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:12863:22 callCallback2@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:14207:22 invokeGuardedCallbackImpl@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:14232:24 invokeGuardedCallback@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:14272:37 beginWork@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:18290:36 performUnitOfWork@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:17579:20 workLoopSync@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:17405:30 renderRootSync@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:17382:17 performConcurrentWorkOnRoot@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:16861:83 workLoop@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:193:50 flushWork@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:172:22 performWorkUntilDeadline@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:382:29 EventHandlerNonNull*node_modules/scheduler/cjs/scheduler.development.js/<@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:403:11 node_modules/scheduler/cjs/scheduler.development.js@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:450:9 __require@http://localhost:3000/node_modules/.vite/deps/chunk-B46TJ7UL.js?v=1f8b7987:3:50 node_modules/scheduler/index.js@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:462:24 __require@http://localhost:3000/node_modules/.vite/deps/chunk-B46TJ7UL.js?v=1f8b7987:3:50 node_modules/react-dom/cjs/react-dom.development.js/<@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:478:25 node_modules/react-dom/cjs/react-dom.development.js@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:25920:9 __require@http://localhost:3000/node_modules/.vite/deps/chunk-B46TJ7UL.js?v=1f8b7987:3:50 node_modules/react-dom/index.js@http://localhost:3000/node_modules/.vite/deps/chunk-LJQUZ74A.js?v=1f8b7987:25933:24 __require@http://localhost:3000/node_modules/.vite/deps/chunk-B46TJ7UL.js?v=1f8b7987:3:50 node_modules/react-dom/client.js@http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=1f8b7987:11:13 __require@http://localhost:3000/node_modules/.vite/deps/chunk-B46TJ7UL.js?v=1f8b7987:3:50 @http://localhost:3000/node_modules/.vite/deps/react-dom_client.js?v=1f8b7987:37:16 ```

As a sanity check, some Next.js docs for server components mention this kind of interleaving explicitly:

On the client, React renders Client Components and slots in the rendered result of Server Components, merging the work done on the server and client. If any Server Components are nested inside a Client Component, their rendered content will be placed correctly within the Client Component.

lubieowoce commented 1 year ago

This won't work because it's fundamentally unsupported by react. you can't render a server component within a client component like this. Waku might have a method of doing something like this, but it's gonna be more involved than just rendering a component, because it requires sending a request to the server -- that's where the rendering needs to happen, and react has no way of doing that "automagically".

So, about those Next docs. The "nesting" they're referring to is about something like this:

function OuterServerComponent() {
  return (
    <ClientComponent someProp={<InnerServerComponent1>}>
      <InnerServerComponent2 />
    </ClientComponent>
  )
}

Note that in this case the client component receives server components as children/props. that works fine. but it can't render them directly, i.e. you can't just do <InnerServerComponent2 /> from within a client component. this is a subtle but important distinction.

But why?

To understand why it's like this, we need to look at the RSC rendering process. Simplifying a lot, it goes something like this:

Server

  1. Render a server component
    • if it outputs primitives like <div className="foo" />, nothing more to do there, we can just serialize that
    • If it outputs a client component, don't ACTUALLY render it. instead, render a placeholder. (you can imagine it as a <React.ClientComponentPlaceholder id="src/FilePicker.tsx#FilePicker" props={YOUR_PROPS_HERE} />, but there's no API like this, that's just an illustration)
    • also, if it's receiving any RSCs as props/children, render those so that they can get serialized and later passed to it on the client. there's special handling in react for this case, that's why we can pass them in like that
  2. Repeat recursively until we've handled all the server components and created a JSX tree of primitives + client placeholders
  3. serialize the resulting tree into that funky JSON format and send that to the client.

Client

  1. interpret the RSC JSON payload and turn it into a regular react tree
    • if we see a primitive (e.g. <div>), we can basically just pass that to React.createElement, simple stuff
    • if we see a client component reference, import() the corresponding client-side JS and put that in the react tree instead. meaning that that imaginary <React.ClientComponentPlaceholder id="src/FilePicker.tsx#FilePicker" props={YOUR_PROPS_HERE} /> turns back into <FilePicker {...YOUR_PROPS_HERE} />
  2. render the tree and mount it into the DOM. this is the first time that <FilePicker> is actually rendered at all.

You'll notice that your FilePicker gets rendered very late in this process -- really, at the very end of it, after the request is already completed. So then we'd need to immediately send a second request so that the server can RSC-render <FileRenderer path="file1.txt"> for us. This is basically a pretty heavy network waterfall, and AFAIU that's why the react team chose to not allow doing this directly. (and, of course, if the state changes, and we pick a new file, we need to do another request to render <FileRenderer path="file2.png" />)

Now, like i said at the start, a framework can technically expose methods of doing something like this. In fact i think Waku already does it internally. but it'll be more involved than just rendering a component -- for one, displaying <FileRenderer path="..."> from a client component has to be asynchronous, because we have to call back to the server to render it. Also, the actual component code lives on the server, so we have to refer to the component by some id, same as we did with React.ClientComponentPlaceholder. so essentially you'd need to do some variation of

<Suspense fallback="Waiting for rendered content...">
  <Waku.GetFromServer
    // we need to use some kind of component id that the server will understand
    component="src/FileRenderer.tsx#FileRenderer"
    props={YOUR_PROPS_HERE}
  />
</Suspense>

where an (imaginary) Waku.GetFromServer wrapper has some internals that handle calling an endpoint like /_waku/render/FileRenderer?props=... on your server + suspending while we wait for the rendered response. I don't know much about Waku's internals, so there might already be a method of doing something like this, but you need @dai-shi for that

Anyway, sorry that this is a bit long, but i don't think there's currently any official docs explaining this, and idk how to explain it w/o going into these technical details. I'll try and look for some @gaearon twitter threads on this, he's probably got something better

jwbay commented 1 year ago

That's a great write-up, thank you! The constraints outlined make sense. I was under the impression RSC in general had more magic behind it, I guess 😆 Not that it's not already without some.

So a central tenant seems to be that server-sourced stuff can be "injected" into client components, but not imported and rendered.

I adopted this with my sample app by adding a server action/function that just reads the file, then passing that to and calling it from from a client component. This works well enough 👍

dai-shi commented 1 year ago

Wow, nice to see discussions go on here.

One note to add is we should interpret "use client" directive as "mark this file as client boundary". It's not about a client component. So is "use server".