Skip to content

Commit ca58742

Browse files
Implement react-server-dom-parcel (#31725)
This adds a new `react-server-dom-parcel-package`, which is an RSC integration for the Parcel bundler. It is mostly copied from the existing webpack/turbopack integrations, with some changes to utilize Parcel runtime APIs for loading and executing bundles/modules. See parcel-bundler/parcel#10043 for the Parcel side of this, which includes the plugin needed to generate client and server references. https://github.com/parcel-bundler/rsc-examples also includes examples of various ways to use RSCs with Parcel. Differences from other integrations: * Client and server modules are all part of the same graph, and we use Parcel's [environments](https://parceljs.org/plugin-system/transformer/#the-environment) to distinguish them. The server is the Parcel build entry point, and it imports and renders server components in route handlers. When a `"use client"` directive is seen, the environment changes and Parcel creates a new client bundle for the page, combining all client modules together. CSS from both client and server components are also combined automatically. * There is no separate manifest file that needs to be passed around by the user. A [Runtime](https://parceljs.org/plugin-system/runtime/) plugin injects client and server references as needed into the relevant bundles, and registers server action ids using `react-server-dom-parcel` automatically. * A special `<Resources>` component is also generated by Parcel to render the `<script>` and `<link rel="stylesheet">` elements needed for a page, using the relevant info from the bundle graph. Note: I've already published a 0.0.x version of this package to npm for testing purposes but happy to add whoever needs access to it as well. ### Questions * How to test this in the React repo. I'll have integration tests in Parcel, but setting up all the different mocks and environments to simulate that here seems challenging. I could try to copy how Webpack/Turbopack do it but it's a bit different. * Where to put TypeScript types. Right now I have some ambient types in my [example repo](https://github.com/parcel-bundler/rsc-examples/blob/main/types.d.ts) but it would be nice for users not to copy and paste these. Can I include them in the package or do they need to maintained separately in definitelytyped? I would really prefer not to have to maintain code in three different repos ideally. --------- Co-authored-by: Sebastian Markbage <[email protected]>
1 parent a496498 commit ca58742

File tree

70 files changed

+5212
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+5212
-0
lines changed

.eslintrc.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ module.exports = {
330330
'packages/react-server-dom-esm/**/*.js',
331331
'packages/react-server-dom-webpack/**/*.js',
332332
'packages/react-server-dom-turbopack/**/*.js',
333+
'packages/react-server-dom-parcel/**/*.js',
333334
'packages/react-server-dom-fb/**/*.js',
334335
'packages/react-test-renderer/**/*.js',
335336
'packages/react-debug-tools/**/*.js',
@@ -481,6 +482,12 @@ module.exports = {
481482
__turbopack_require__: 'readonly',
482483
},
483484
},
485+
{
486+
files: ['packages/react-server-dom-parcel/**/*.js'],
487+
globals: {
488+
parcelRequire: 'readonly',
489+
},
490+
},
484491
{
485492
files: ['packages/scheduler/**/*.js'],
486493
globals: {

ReactVersions.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const stablePackages = {
4040
'react-dom': ReactVersion,
4141
'react-server-dom-webpack': ReactVersion,
4242
'react-server-dom-turbopack': ReactVersion,
43+
'react-server-dom-parcel': ReactVersion,
4344
'react-is': ReactVersion,
4445
'react-reconciler': '0.31.0',
4546
'react-refresh': '0.16.0',

fixtures/flight-parcel/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.parcel-cache
2+
.DS_Store
3+
node_modules
4+
dist
5+
todos.json

fixtures/flight-parcel/.parcelrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "@parcel/config-default",
3+
"runtimes": ["...", "@parcel/runtime-rsc"]
4+
}

fixtures/flight-parcel/package.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "flight-parcel",
3+
"private": true,
4+
"workspaces": [
5+
"examples/*"
6+
],
7+
"server": "dist/server.js",
8+
"targets": {
9+
"server": {
10+
"source": "src/server.tsx",
11+
"context": "react-server",
12+
"outputFormat": "commonjs",
13+
"includeNodeModules": {
14+
"express": false
15+
}
16+
}
17+
},
18+
"scripts": {
19+
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
20+
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
21+
"dev": "concurrently \"npm run dev:watch\" \"npm run dev:start\"",
22+
"dev:watch": "NODE_ENV=development parcel watch",
23+
"dev:start": "NODE_ENV=development node dist/server.js",
24+
"build": "parcel build",
25+
"start": "node dist/server.js"
26+
},
27+
"@parcel/resolver-default": {
28+
"packageExports": true
29+
},
30+
"dependencies": {
31+
"@parcel/config-default": "2.0.0-dev.1789",
32+
"@parcel/runtime-rsc": "2.13.3-dev.3412",
33+
"@types/parcel-env": "^0.0.6",
34+
"@types/express": "*",
35+
"@types/node": "^22.10.1",
36+
"@types/react": "^19",
37+
"@types/react-dom": "^19",
38+
"concurrently": "^7.3.0",
39+
"express": "^4.18.2",
40+
"parcel": "2.0.0-dev.1787",
41+
"process": "^0.11.10",
42+
"react": "experimental",
43+
"react-dom": "experimental",
44+
"react-server-dom-parcel": "experimental",
45+
"rsc-html-stream": "^0.0.4",
46+
"ws": "^8.8.1"
47+
},
48+
"@parcel/bundler-default": {
49+
"minBundleSize": 0
50+
}
51+
}

fixtures/flight-parcel/src/Dialog.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client';
2+
3+
import {ReactNode, useRef} from 'react';
4+
5+
export function Dialog({
6+
trigger,
7+
children,
8+
}: {
9+
trigger: ReactNode;
10+
children: ReactNode;
11+
}) {
12+
let ref = useRef<HTMLDialogElement | null>(null);
13+
return (
14+
<>
15+
<button onClick={() => ref.current?.showModal()}>{trigger}</button>
16+
<dialog ref={ref} onSubmit={() => ref.current?.close()}>
17+
{children}
18+
</dialog>
19+
</>
20+
);
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {createTodo} from './actions';
2+
3+
export function TodoCreate() {
4+
return (
5+
<form action={createTodo}>
6+
<label>
7+
Title: <input name="title" />
8+
</label>
9+
<label>
10+
Description: <textarea name="description" />
11+
</label>
12+
<label>
13+
Due date: <input type="date" name="dueDate" />
14+
</label>
15+
<button>Add todo</button>
16+
</form>
17+
);
18+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {getTodo, updateTodo} from './actions';
2+
3+
export async function TodoDetail({id}: {id: number}) {
4+
let todo = await getTodo(id);
5+
if (!todo) {
6+
return <p>Todo not found</p>;
7+
}
8+
9+
return (
10+
<form className="todo" action={updateTodo.bind(null, todo.id)}>
11+
<label>
12+
Title: <input name="title" defaultValue={todo.title} />
13+
</label>
14+
<label>
15+
Description:{' '}
16+
<textarea name="description" defaultValue={todo.description} />
17+
</label>
18+
<label>
19+
Due date:{' '}
20+
<input type="date" name="dueDate" defaultValue={todo.dueDate} />
21+
</label>
22+
<button type="submit">Update todo</button>
23+
</form>
24+
);
25+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client';
2+
3+
import {startTransition, useOptimistic} from 'react';
4+
import {deleteTodo, setTodoComplete, type Todo as ITodo} from './actions';
5+
6+
export function TodoItem({
7+
todo,
8+
isSelected,
9+
}: {
10+
todo: ITodo;
11+
isSelected: boolean;
12+
}) {
13+
let [isOptimisticComplete, setOptimisticComplete] = useOptimistic(
14+
todo.isComplete,
15+
);
16+
17+
return (
18+
<li data-selected={isSelected || undefined}>
19+
<input
20+
type="checkbox"
21+
checked={isOptimisticComplete}
22+
onChange={e => {
23+
startTransition(async () => {
24+
setOptimisticComplete(e.target.checked);
25+
await setTodoComplete(todo.id, e.target.checked);
26+
});
27+
}}
28+
/>
29+
<a
30+
href={`/todos/${todo.id}`}
31+
aria-current={isSelected ? 'page' : undefined}>
32+
{todo.title}
33+
</a>
34+
<button onClick={() => deleteTodo(todo.id)}>x</button>
35+
</li>
36+
);
37+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {TodoItem} from './TodoItem';
2+
import {getTodos} from './actions';
3+
4+
export async function TodoList({id}: {id: number | undefined}) {
5+
let todos = await getTodos();
6+
return (
7+
<ul className="todo-list">
8+
{todos.map(todo => (
9+
<TodoItem key={todo.id} todo={todo} isSelected={todo.id === id} />
10+
))}
11+
</ul>
12+
);
13+
}

0 commit comments

Comments
 (0)