Как правильно определить компонент как клиентский (RSC)?

Допустим, есть три компонента:
Offers.tsx

import { OffersList } from './OffersList.tsx'
export const Offers = async({ search } : { search: string }) => {
    const data = await fetch('https://fakestoreapi.com/products').then(r => r.json()).then(data => (
        search ? data.filter(({ title } : { title: string }) => title.toLowerCase().startsWith(search.toLowerCase())) : data
    ))
    return (
        <OffersList data={ data }/>
    )
}


OffersList.tsx

import { OfferActions } from './OfferActions.tsx'

export const OffersList = ({ data } : { data: any }) => {
    return (
        <ul>
            <title>OffersList</title>
            <h1>Offers / { data.length } total</h1>
            { data.map((offer: any) => (
                <li key={ offer.id }>
                    <div id="offer">
                        <h2>{ offer.title }</h2>
                        <span>{ offer.price }</span>$
                        <p>{ offer.description }</p>
                        <OfferActions/>
                    </div>
                </li>
            )) }
        </ul>
    )
}


OfferActions.tsx

'use client'

export const OfferActions = () => {
    return (
        <>
            <button onClick={ () => alert(123) } >Click me</button>
        </>
    )
}



Ну и родительские компоненты компонента Offers, в которых ничего не происходит.

RSC пэйлоад при рендере отдаёт следующее:
...

0:D{"name":"OffersLayout","env":"Server"}
1:D{"name":"Offers","env":"Server"}
0:["$","main",null,{"children":"$L1"}]
1:D{"name":"OffersList","env":"Server"}
2:D{"name":"OfferActions","env":"Server"}
3:D{"name":"OfferActions","env":"Server"}
4:D{"name":"OfferActions","env":"Server"}
5:D{"name":"OfferActions","env":"Server"}
6:D{"name":"OfferActions","env":"Server"}
7:D{"name":"OfferActions","env":"Server"}
8:D{"name":"OfferActions","env":"Server"}
9:D{"name":"OfferActions","env":"Server"}
a:D{"name":"OfferActions","env":"Server"}
b:D{"name":"OfferActions","env":"Server"}
c:D{"name":"OfferActions","env":"Server"}
d:D{"name":"OfferActions","env":"Server"}
e:D{"name":"OfferActions","env":"Server"}
f:D{"name":"OfferActions","env":"Server"}
10:D{"name":"OfferActions","env":"Server"}
11:D{"name":"OfferActions","env":"Server"}
12:D{"name":"OfferActions","env":"Server"}
13:D{"name":"OfferActions","env":"Server"}
14:D{"name":"OfferActions","env":"Server"}
15:D{"name":"OfferActions","env":"Server"}
...рендер элементов прокинутых в OffersList


И ошибку в конце: Event handlers cannot be passed to Client Component props. If you need interactivity, consider converting part of this to a Client Component, что в принципе можно понять как "нельзя прокидывать обработчики событий в клиентские компоненты, выдели такие компоненты как отдельные клиентские компоненты".

Вопрос первый: что я делаю не так?
Вопрос второй: почему в пэйлоаде у "OfferActions" - "env" равняется "Server"? Это нормально?

Сбандленный OfferActions:
OfferActions.js

"use client";

// src/offers/OfferActions.tsx
import { Fragment, jsx } from "react/jsx-runtime";
var OfferActions = () => {
  return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx("button", { onClick: () => alert(123), children: "Click me" }) });
};
export {
  OfferActions
};
OfferActions.$$id="FkXBmVQRFwS54BoBzTjf8";OfferActions.$$typeof=Symbol.for("react.client.reference");


И серверный манифест:
manifest.ts

export const manifest = {
  "FkXBmVQRFwS54BoBzTjf8": {
    "id": "/dist/chunks/FkXBmVQRFwS54BoBzTjf8.js",
    "name": "OfferActions",
    "chunks": [],
    "async": false
  }
}


Всё происходящее до:
bundler.ts

import './env.ts'

import { writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, readFileSync } from 'fs'
import { build } from 'esbuild'
import { nanoid } from 'nanoid'

const manifest = { /*...*/ }

if(!existsSync('./dist/chunks')) mkdirSync('./dist/chunks', { recursive: true })
else for(const entry of readdirSync('./dist/chunks')) unlinkSync(`./dist/chunks/${ entry }`)

build({
    entryPoints: [
        './src/client.ts'
    ],
    outfile: './dist/client.min.js',
    bundle: false,
    minify: process.env.NODE_ENV === 'production',
    packages: 'external',
    jsx: 'automatic',
    jsxImportSource: 'react',
    target: 'esnext',
    platform: 'browser',
    format: 'esm'
})

build({
    entryPoints: [
        './src/*/**'
    ],
    bundle: true,
    minify: process.env.NODE_ENV === 'production',
    splitting: false,
    treeShaking: true,
    write: false,
    outdir: './dist/chunks',
    packages: 'external',
    jsx: 'automatic',
    legalComments: 'none',
    target: 'esnext',
    platform: 'browser',
    format: 'esm'
}).then(result => {
    result.outputFiles.forEach(entry => {
        if(entry.text.startsWith(`"use client"`)){
            const { text, path } = entry
            const [ id, name ] = [ nanoid(), path.substring(path.lastIndexOf('\\') + 1, path.lastIndexOf('.')) ]
            const absolute = `/dist/chunks/${ id }.js`
            const output = `${ text + name }.$$id="${ id }";${ name }.$$typeof=Symbol.for("react.client.reference");`
            manifest[id] = {
                id: absolute,
                name: name,
                chunks: [],
                async: true
            }
            writeFileSync(`.${ absolute }`, output, { encoding: 'utf-8' })
        }
    })
    writeFileSync('./manifest.ts', `export const manifest = ${ JSON.stringify(manifest, null, 2) }`, { encoding: 'utf-8' })
})


serializer.tsx

import './env.ts'

import { createServer } from 'http'

import { renderToPipeableStream } from 'react-server-dom-webpack/server'

import { Layout } from './src/Layout.tsx' 
import { OffersLayout } from './src/offers/OffersLayout.tsx'

import { manifest } from './manifest.ts'

const serializer = createServer(async(request, response) => {
    // @ts-ignore
    const url = new URL(request.url, `http://${ request.headers.host }`)
    renderToPipeableStream(
        <Layout>
            <OffersLayout search={ url.searchParams.get('search') ?? '' }/>
        </Layout>, manifest, {
            bootstrapScriptContent: `window.__webpack_require__ = (x) => import(x)`
        }
    ).pipe(response)
})

serializer.listen(4000, () => {
    console.log('Watching RSC...')
})


server.tsx

import './env.ts'
import './bundler.ts'

import { createServer } from 'http'
import { readFile } from 'fs/promises'

import { renderToPipeableStream } from 'react-dom/server'

import { Layout } from './src/Layout.tsx'
import { OffersLayout } from './src/offers/OffersLayout.tsx'

const server = createServer(async(request, response) => {
    if(request.method === 'GET'){
        // @ts-ignore
        const url = new URL(request.url, `http://${ request.headers.host }`)
        if(url.pathname.startsWith('/favicon.ico')){
            response.writeHead(404, 'Not Found').end()
        } else if(url.pathname.startsWith('/dist')){
            readFile(`.${ url.pathname }`).then(raw => {
                response.writeHead(200, {
                    'content-type': 'text/javascript'
                }).end(raw)
            }).catch(() => {
                response.writeHead(404, 'Not Found').end()
            })
        } else if(url.searchParams.has('payload')){
            fetch('http://127.0.0.1:4000').then(r => r.text()).then(raw => (
                response.writeHead(200, {
                    'content-type': 'text/plain'
                }).end(raw)
            ))
        } else {
            renderToPipeableStream(
                <Layout>
                    <OffersLayout search={ url.searchParams.get('search') ?? '' }/>
                </Layout>, {
                    bootstrapScriptContent: `window.__webpack_require__ = (x) => import(x)`
                }
            ).pipe(response)
        }
    } else {
        response.writeHead(418, `I'm a teapot `).end()
    }
})

server.listen(3000, () => {
    console.log('Listening...')
})

  • Вопрос задан
  • 237 просмотров
Решения вопроса 1
muscimolus
@muscimolus Автор вопроса
Проблема была в том, что сериализатор при рендере не видел марки $$id и $$typeof в исходных файлах, поэтому не воспринимал их должных образом, как клиентские.
Ответ написан
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Войти через центр авторизации
Похожие вопросы