Skip to content

Commit

Permalink
Implement react-server-dom-parcel (facebook#31725)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
devongovett and sebmarkbage authored Dec 12, 2024
1 parent a496498 commit ca58742
Show file tree
Hide file tree
Showing 70 changed files with 5,212 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ module.exports = {
'packages/react-server-dom-esm/**/*.js',
'packages/react-server-dom-webpack/**/*.js',
'packages/react-server-dom-turbopack/**/*.js',
'packages/react-server-dom-parcel/**/*.js',
'packages/react-server-dom-fb/**/*.js',
'packages/react-test-renderer/**/*.js',
'packages/react-debug-tools/**/*.js',
Expand Down Expand Up @@ -481,6 +482,12 @@ module.exports = {
__turbopack_require__: 'readonly',
},
},
{
files: ['packages/react-server-dom-parcel/**/*.js'],
globals: {
parcelRequire: 'readonly',
},
},
{
files: ['packages/scheduler/**/*.js'],
globals: {
Expand Down
1 change: 1 addition & 0 deletions ReactVersions.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const stablePackages = {
'react-dom': ReactVersion,
'react-server-dom-webpack': ReactVersion,
'react-server-dom-turbopack': ReactVersion,
'react-server-dom-parcel': ReactVersion,
'react-is': ReactVersion,
'react-reconciler': '0.31.0',
'react-refresh': '0.16.0',
Expand Down
5 changes: 5 additions & 0 deletions fixtures/flight-parcel/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.parcel-cache
.DS_Store
node_modules
dist
todos.json
4 changes: 4 additions & 0 deletions fixtures/flight-parcel/.parcelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "@parcel/config-default",
"runtimes": ["...", "@parcel/runtime-rsc"]
}
51 changes: 51 additions & 0 deletions fixtures/flight-parcel/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "flight-parcel",
"private": true,
"workspaces": [
"examples/*"
],
"server": "dist/server.js",
"targets": {
"server": {
"source": "src/server.tsx",
"context": "react-server",
"outputFormat": "commonjs",
"includeNodeModules": {
"express": false
}
}
},
"scripts": {
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
"dev": "concurrently \"npm run dev:watch\" \"npm run dev:start\"",
"dev:watch": "NODE_ENV=development parcel watch",
"dev:start": "NODE_ENV=development node dist/server.js",
"build": "parcel build",
"start": "node dist/server.js"
},
"@parcel/resolver-default": {
"packageExports": true
},
"dependencies": {
"@parcel/config-default": "2.0.0-dev.1789",
"@parcel/runtime-rsc": "2.13.3-dev.3412",
"@types/parcel-env": "^0.0.6",
"@types/express": "*",
"@types/node": "^22.10.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^7.3.0",
"express": "^4.18.2",
"parcel": "2.0.0-dev.1787",
"process": "^0.11.10",
"react": "experimental",
"react-dom": "experimental",
"react-server-dom-parcel": "experimental",
"rsc-html-stream": "^0.0.4",
"ws": "^8.8.1"
},
"@parcel/bundler-default": {
"minBundleSize": 0
}
}
21 changes: 21 additions & 0 deletions fixtures/flight-parcel/src/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import {ReactNode, useRef} from 'react';

export function Dialog({
trigger,
children,
}: {
trigger: ReactNode;
children: ReactNode;
}) {
let ref = useRef<HTMLDialogElement | null>(null);
return (
<>
<button onClick={() => ref.current?.showModal()}>{trigger}</button>
<dialog ref={ref} onSubmit={() => ref.current?.close()}>
{children}
</dialog>
</>
);
}
18 changes: 18 additions & 0 deletions fixtures/flight-parcel/src/TodoCreate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {createTodo} from './actions';

export function TodoCreate() {
return (
<form action={createTodo}>
<label>
Title: <input name="title" />
</label>
<label>
Description: <textarea name="description" />
</label>
<label>
Due date: <input type="date" name="dueDate" />
</label>
<button>Add todo</button>
</form>
);
}
25 changes: 25 additions & 0 deletions fixtures/flight-parcel/src/TodoDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {getTodo, updateTodo} from './actions';

export async function TodoDetail({id}: {id: number}) {
let todo = await getTodo(id);
if (!todo) {
return <p>Todo not found</p>;
}

return (
<form className="todo" action={updateTodo.bind(null, todo.id)}>
<label>
Title: <input name="title" defaultValue={todo.title} />
</label>
<label>
Description:{' '}
<textarea name="description" defaultValue={todo.description} />
</label>
<label>
Due date:{' '}
<input type="date" name="dueDate" defaultValue={todo.dueDate} />
</label>
<button type="submit">Update todo</button>
</form>
);
}
37 changes: 37 additions & 0 deletions fixtures/flight-parcel/src/TodoItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import {startTransition, useOptimistic} from 'react';
import {deleteTodo, setTodoComplete, type Todo as ITodo} from './actions';

export function TodoItem({
todo,
isSelected,
}: {
todo: ITodo;
isSelected: boolean;
}) {
let [isOptimisticComplete, setOptimisticComplete] = useOptimistic(
todo.isComplete,
);

return (
<li data-selected={isSelected || undefined}>
<input
type="checkbox"
checked={isOptimisticComplete}
onChange={e => {
startTransition(async () => {
setOptimisticComplete(e.target.checked);
await setTodoComplete(todo.id, e.target.checked);
});
}}
/>
<a
href={`/todos/${todo.id}`}
aria-current={isSelected ? 'page' : undefined}>
{todo.title}
</a>
<button onClick={() => deleteTodo(todo.id)}>x</button>
</li>
);
}
13 changes: 13 additions & 0 deletions fixtures/flight-parcel/src/TodoList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {TodoItem} from './TodoItem';
import {getTodos} from './actions';

export async function TodoList({id}: {id: number | undefined}) {
let todos = await getTodos();
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} isSelected={todo.id === id} />
))}
</ul>
);
}
63 changes: 63 additions & 0 deletions fixtures/flight-parcel/src/Todos.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
body {
font-family: system-ui;
color-scheme: light dark;
}

form {
display: grid;
grid-template-columns: auto 1fr;
flex-direction: column;
max-width: 400px;
gap: 8px;
}

label {
display: contents;
}

main {
display: flex;
gap: 32px;
}

.todo-column {
width: 250px;
}

header {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 250px;
padding: 8px;
padding-right: 40px;
box-sizing: border-box;
}

.todo-list {
max-width: 250px;
padding: 0;
list-style: none;
padding-right: 32px;
border-right: 1px solid gray;

li {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 8px;
accent-color: light-dark(black, white);

a {
color: inherit;
text-decoration: none;
width: 100%;
}

&[data-selected] {
background-color: light-dark(#222, #ddd);
color: light-dark(#ddd, #222);
accent-color: light-dark(white, black);
}
}
}
35 changes: 35 additions & 0 deletions fixtures/flight-parcel/src/Todos.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use server-entry';

import './client';
import './Todos.css';
import {Resources} from '@parcel/runtime-rsc';
import {Dialog} from './Dialog';
import {TodoDetail} from './TodoDetail';
import {TodoCreate} from './TodoCreate';
import {TodoList} from './TodoList';

export async function Todos({id}: {id?: number}) {
return (
<html style={{colorScheme: 'dark light'}}>
<head>
<title>Todos</title>
<Resources />
</head>
<body>
<header>
<h1>Todos</h1>
<Dialog trigger="+">
<h2>Add todo</h2>
<TodoCreate />
</Dialog>
</header>
<main>
<div className="todo-column">
<TodoList id={id} />
</div>
{id != null ? <TodoDetail key={id} id={id} /> : <p>Select a todo</p>}
</main>
</body>
</html>
);
}
75 changes: 75 additions & 0 deletions fixtures/flight-parcel/src/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use server';

import fs from 'fs/promises';

export interface Todo {
id: number;
title: string;
description: string;
dueDate: string;
isComplete: boolean;
}

export async function getTodos(): Promise<Todo[]> {
try {
let contents = await fs.readFile('todos.json', 'utf8');
return JSON.parse(contents);
} catch {
await fs.writeFile('todos.json', '[]');
return [];
}
}

export async function getTodo(id: number): Promise<Todo | undefined> {
let todos = await getTodos();
return todos.find(todo => todo.id === id);
}

export async function createTodo(formData: FormData) {
let todos = await getTodos();
let title = formData.get('title');
let description = formData.get('description');
let dueDate = formData.get('dueDate');
let id = todos.length > 0 ? Math.max(...todos.map(todo => todo.id)) + 1 : 0;
todos.push({
id,
title: typeof title === 'string' ? title : '',
description: typeof description === 'string' ? description : '',
dueDate: typeof dueDate === 'string' ? dueDate : new Date().toISOString(),
isComplete: false,
});
await fs.writeFile('todos.json', JSON.stringify(todos));
}

export async function updateTodo(id: number, formData: FormData) {
let todos = await getTodos();
let title = formData.get('title');
let description = formData.get('description');
let dueDate = formData.get('dueDate');
let todo = todos.find(todo => todo.id === id);
if (todo) {
todo.title = typeof title === 'string' ? title : '';
todo.description = typeof description === 'string' ? description : '';
todo.dueDate =
typeof dueDate === 'string' ? dueDate : new Date().toISOString();
await fs.writeFile('todos.json', JSON.stringify(todos));
}
}

export async function setTodoComplete(id: number, isComplete: boolean) {
let todos = await getTodos();
let todo = todos.find(todo => todo.id === id);
if (todo) {
todo.isComplete = isComplete;
await fs.writeFile('todos.json', JSON.stringify(todos));
}
}

export async function deleteTodo(id: number) {
let todos = await getTodos();
let index = todos.findIndex(todo => todo.id === id);
if (index >= 0) {
todos.splice(index, 1);
await fs.writeFile('todos.json', JSON.stringify(todos));
}
}
Loading

0 comments on commit ca58742

Please sign in to comment.