├── nodemon.json ├── plugin ├── __tests__ │ ├── fixture │ │ ├── package.json │ │ ├── Form.client.js │ │ ├── FormServer.server.js │ │ └── entry.js │ └── ReactFlightWebpackPlugin.spec.ts ├── ReactFlightWebpackLoader.ts ├── ReactFlightWebpackNodeRegister.ts ├── ReactFlightWebpackNodeLoader.ts └── ReactFlightWebpackPlugin.ts ├── prettier.config.js ├── public ├── favicon.ico ├── chevron-down.svg ├── chevron-up.svg ├── cross.svg ├── checkmark.svg ├── logo.svg ├── index.html └── style.css ├── webpack.config.js ├── src ├── index.client.tsx ├── LocationContext.client.tsx ├── NotePreview.tsx ├── Spinner.tsx ├── Cache.client.tsx ├── EditButton.client.tsx ├── TextWithMarkdown.tsx ├── NoteListSkeleton.tsx ├── Root.client.tsx ├── NoteList.server.tsx ├── SidebarNote.tsx ├── SearchField.client.tsx ├── App.server.tsx ├── Note.server.tsx ├── SidebarNote.client.tsx ├── db.server.ts ├── NoteSkeleton.tsx ├── reactServer.ts └── NoteEditor.client.tsx ├── babel.server.config.js ├── jest.config.js ├── .prettierignore ├── .gitignore ├── babel.config.js ├── scripts ├── seed.js └── build.js ├── webpack ├── ReloadServerPlugin.js └── webpack.config.js ├── README.md ├── webpack.client.config.js ├── webpackx.ts ├── webpack.server.config.js ├── package.json └── tsconfig.json /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "build/*" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /plugin/__tests__/fixture/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixture" 3 | } 4 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /plugin/__tests__/fixture/Form.client.js: -------------------------------------------------------------------------------- 1 | export function Form() { 2 | console.log('Form Rendered!'); 3 | } 4 | -------------------------------------------------------------------------------- /plugin/__tests__/fixture/FormServer.server.js: -------------------------------------------------------------------------------- 1 | export default function Foo() { 2 | console.log('Form!'); 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaearon/react-server-components-boilerplate/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const clientConfig = require('./webpack.client.config'); 2 | const serverConfig = require('./webpack.server.config'); 3 | 4 | module.exports = [clientConfig, serverConfig]; 5 | -------------------------------------------------------------------------------- /plugin/__tests__/fixture/entry.js: -------------------------------------------------------------------------------- 1 | const ClientComponent = require('./Form.client').Form; 2 | const ServerComponent = require('./FormServer.server.js'); 3 | 4 | console.log(ClientComponent, ServerComponent); 5 | -------------------------------------------------------------------------------- /public/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/chevron-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/index.client.tsx: -------------------------------------------------------------------------------- 1 | import { unstable_createRoot } from 'react-dom'; 2 | import Root from './Root.client'; 3 | 4 | const initialCache = new Map(); 5 | const root = unstable_createRoot(document.getElementById('root')); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /babel.server.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-react', 5 | { 6 | runtime: 'automatic', 7 | }, 8 | ], 9 | '@babel/preset-typescript', 10 | ], 11 | plugins: ['@babel/transform-modules-commonjs'], 12 | }; 13 | -------------------------------------------------------------------------------- /src/LocationContext.client.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | console.log({ 4 | createContext, 5 | useContext, 6 | }); 7 | 8 | export const LocationContext = createContext(); 9 | export function useLocation() { 10 | return useContext(LocationContext); 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'css', 'ts', 'tsx', 'json'], 3 | transform: { 4 | '^.testEv+\\.(js|ts|tsx)?$': 'babel-jest', 5 | }, 6 | testEnvironment: 'jest-environment-jsdom-sixteen', 7 | testRegex: '/__tests__/[^/]*(\\.ts|\\.coffee|[^d]\\.ts)$', 8 | }; 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | .eslintcache 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | *.html 26 | *.json 27 | *.md 28 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/NotePreview.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import TextWithMarkdown from './TextWithMarkdown'; 10 | 11 | export default function NotePreview({ body }) { 12 | return ( 13 |
14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/Spinner.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export default function Spinner({ active = true }) { 10 | return ( 11 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # notes 16 | notes/*.md 17 | 18 | # misc 19 | .DS_Store 20 | .eslintcache 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # vscode 31 | .vscode 32 | .webpack 33 | 34 | # .js is the compiled typescript version 35 | plugin/*.js 36 | -------------------------------------------------------------------------------- /plugin/ReactFlightWebpackLoader.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | export default function (source) { 3 | let newSrc = 4 | "const MODULE_REFERENCE = Symbol.for('react.module.reference');\n"; 5 | 6 | // TODO - extract names (export names) using acorn, check ReactFlightWebpackNodeLoader 7 | const names = ['default']; 8 | 9 | for (let i = 0; i < names.length; i++) { 10 | const name = names[i]; 11 | if (name === 'default') { 12 | newSrc += 'export default '; 13 | } else { 14 | newSrc += 'export const ' + name + ' = '; 15 | } 16 | newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: '; 17 | newSrc += `'file://${this.resourcePath}'`; 18 | newSrc += ', name: '; 19 | newSrc += JSON.stringify(name); 20 | newSrc += '};\n'; 21 | } 22 | 23 | console.log({ 24 | newSrc, 25 | }); 26 | 27 | return newSrc; 28 | } 29 | -------------------------------------------------------------------------------- /src/Cache.client.tsx: -------------------------------------------------------------------------------- 1 | import { unstable_getCacheForType, unstable_useCacheRefresh } from 'react'; 2 | import { createFromFetch } from 'react-server-dom-webpack'; 3 | 4 | function createResponseCache() { 5 | return new Map(); 6 | } 7 | 8 | export function useRefresh() { 9 | const refreshCache = unstable_useCacheRefresh(); 10 | return function refresh(key, seededResponse) { 11 | refreshCache(createResponseCache, new Map([[key, seededResponse]])); 12 | }; 13 | } 14 | 15 | export function useServerResponse(location) { 16 | const key = JSON.stringify(location); 17 | const cache = unstable_getCacheForType(createResponseCache); 18 | let response = cache.get(key); 19 | if (response) { 20 | return response; 21 | } 22 | response = createFromFetch( 23 | fetch('/react?location=' + encodeURIComponent(key)), 24 | ); 25 | cache.set(key, response); 26 | return response; 27 | } 28 | -------------------------------------------------------------------------------- /src/EditButton.client.tsx: -------------------------------------------------------------------------------- 1 | import { unstable_useTransition } from 'react'; 2 | 3 | import { useLocation } from './LocationContext.client'; 4 | 5 | export default function EditButton({ noteId, children }) { 6 | const [, setLocation] = useLocation(); 7 | const [startTransition, isPending] = unstable_useTransition(); 8 | const isDraft = noteId == null; 9 | return ( 10 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache.using(() => process.env.NODE_ENV); 3 | 4 | // const enableFastRefresh = !api.env('production') && !api.env('test'); 5 | const enableFastRefresh = false; 6 | 7 | return { 8 | presets: [ 9 | [ 10 | '@babel/preset-react', 11 | { 12 | runtime: 'automatic', 13 | }, 14 | ], 15 | [ 16 | '@babel/preset-env', 17 | { 18 | targets: { 19 | node: 'current', 20 | }, 21 | }, 22 | ], 23 | '@babel/preset-typescript', 24 | ], 25 | plugins: [ 26 | '@babel/plugin-proposal-class-properties', 27 | '@babel/plugin-proposal-export-default-from', 28 | '@babel/plugin-proposal-export-namespace-from', 29 | '@babel/plugin-proposal-nullish-coalescing-operator', 30 | '@babel/plugin-proposal-optional-chaining', 31 | ], 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/TextWithMarkdown.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import marked from 'marked'; 10 | import sanitizeHtml from 'sanitize-html'; 11 | 12 | const allowedTags = sanitizeHtml.defaults.allowedTags.concat([ 13 | 'img', 14 | 'h1', 15 | 'h2', 16 | 'h3', 17 | ]); 18 | const allowedAttributes = Object.assign( 19 | {}, 20 | sanitizeHtml.defaults.allowedAttributes, 21 | { 22 | img: ['alt', 'src'], 23 | }, 24 | ); 25 | 26 | export default function TextWithMarkdown({ text }) { 27 | return ( 28 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/NoteListSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | export default function NoteListSkeleton() { 10 | return ( 11 |
12 |
    13 |
  • 14 |
    18 |
  • 19 |
  • 20 |
    24 |
  • 25 |
  • 26 |
    30 |
  • 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/Root.client.tsx: -------------------------------------------------------------------------------- 1 | import { useState, Suspense } from 'react'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | 4 | import { useServerResponse } from './Cache.client'; 5 | import { LocationContext } from './LocationContext.client'; 6 | 7 | export default function Root({ initialCache }) { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | function Content() { 18 | const [location, setLocation] = useState({ 19 | selectedId: null, 20 | isEditing: false, 21 | searchText: '', 22 | }); 23 | const response = useServerResponse(location); 24 | return ( 25 | 26 | {response.readRoot()} 27 | 28 | ); 29 | } 30 | 31 | function Error({ error }) { 32 | return ( 33 |
34 |

Application Error

35 |
{error.stack}
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/NoteList.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { fetch } from 'react-fetch'; 10 | 11 | import { searchNotes } from './db.server'; 12 | import SidebarNote from './SidebarNote'; 13 | 14 | export default function NoteList({ searchText }) { 15 | // const notes = fetch('http://localhost:4000/notes').json(); 16 | 17 | const notes = searchNotes(searchText); 18 | 19 | // Now let's see how the Suspense boundary above lets us not block on this. 20 | // fetch('http://localhost:4000/sleep/3000'); 21 | 22 | return notes.length > 0 ? ( 23 |
    24 | {notes.map((note) => ( 25 |
  • 26 | 27 |
  • 28 | ))} 29 |
30 | ) : ( 31 |
32 | {searchText 33 | ? `Couldn't find any notes titled "${searchText}".` 34 | : 'No notes created yet!'}{' '} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /scripts/seed.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | 'use strict'; 10 | 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | const { readdir, unlink, writeFile } = require('fs/promises'); 14 | const { db } = require('../src/db.server'); 15 | 16 | const NOTES_PATH = './notes'; 17 | 18 | async function seed() { 19 | const oldNotes = await readdir(path.resolve(NOTES_PATH)); 20 | await Promise.all( 21 | oldNotes 22 | .filter((filename) => filename.endsWith('.md')) 23 | .map((filename) => unlink(path.resolve(NOTES_PATH, filename))), 24 | ); 25 | 26 | await Promise.all( 27 | db.map((note) => { 28 | const id = note.id; 29 | const content = note.body; 30 | const data = new Uint8Array(Buffer.from(content)); 31 | return writeFile(path.resolve(NOTES_PATH, `${id}.md`), data, (err) => { 32 | if (err) { 33 | throw err; 34 | } 35 | }); 36 | }), 37 | ); 38 | } 39 | 40 | seed(); 41 | -------------------------------------------------------------------------------- /src/SidebarNote.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { format, isToday } from 'date-fns'; 10 | import excerpts from 'excerpts'; 11 | import marked from 'marked'; 12 | 13 | import ClientSidebarNote from './SidebarNote.client'; 14 | 15 | export default function SidebarNote({ note }) { 16 | const updatedAt = new Date(note.updated_at); 17 | const lastUpdatedAt = isToday(updatedAt) 18 | ? format(updatedAt, 'h:mm bb') 19 | : format(updatedAt, 'M/d/yy'); 20 | const summary = excerpts(marked(note.body), { words: 20 }); 21 | return ( 22 | {summary || (No content)}

27 | } 28 | > 29 |
30 | {note.title} 31 | {lastUpdatedAt} 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Server Components Boilerplate 9 | 10 | 11 |
12 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /webpack/ReloadServerPlugin.js: -------------------------------------------------------------------------------- 1 | const cluster = require('cluster'); 2 | const path = require('path'); 3 | 4 | const defaultOptions = { 5 | script: 'server.js', 6 | }; 7 | 8 | class ReloadServerPlugin { 9 | constructor({ script } = defaultOptions) { 10 | this.done = null; 11 | this.workers = []; 12 | 13 | cluster.setupMaster({ 14 | exec: path.resolve(process.cwd(), script), 15 | execArgv: ['--conditions=react-server'], 16 | }); 17 | 18 | cluster.on('online', (worker) => { 19 | this.workers.push(worker); 20 | 21 | if (this.done) { 22 | this.done(); 23 | } 24 | }); 25 | } 26 | 27 | apply(compiler) { 28 | compiler.hooks.afterEmit.tap( 29 | { 30 | name: 'reload-server', 31 | }, 32 | (compilation, callback) => { 33 | this.done = callback; 34 | this.workers.forEach((worker) => { 35 | try { 36 | process.kill(worker.process.pid, 'SIGTERM'); 37 | } catch (e) { 38 | // eslint-disable-next-line 39 | console.warn(`Unable to kill process #${worker.process.pid}`); 40 | } 41 | }); 42 | 43 | this.workers = []; 44 | 45 | cluster.fork(); 46 | }, 47 | ); 48 | } 49 | } 50 | 51 | module.exports = ReloadServerPlugin; 52 | -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | const cwd = process.cwd(); 6 | 7 | export const outputPath = path.join(cwd, '.webpack'); 8 | export const outputFilename = 'bundle.js'; 9 | 10 | export default { 11 | context: cwd, 12 | mode: 'development', 13 | devtool: false, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 16 | }, 17 | output: { 18 | libraryTarget: 'commonjs2', 19 | path: outputPath, 20 | filename: outputFilename, 21 | futureEmitAssets: true, 22 | }, 23 | target: 'node', 24 | externals: [ 25 | nodeExternals({ 26 | allowlist: [], 27 | }), 28 | ], 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.mjs$/, 33 | type: 'javascript/auto', 34 | }, 35 | { 36 | test: /\.(js|jsx|ts|tsx)?$/, 37 | use: { 38 | loader: 'babel-loader?cacheDirectory', 39 | }, 40 | exclude: [ 41 | /node_modules/, 42 | path.resolve(__dirname, '.serverless'), 43 | path.resolve(__dirname, '.webpack'), 44 | ], 45 | }, 46 | ], 47 | }, 48 | plugins: [], 49 | node: { 50 | __dirname: false, 51 | __filename: false, 52 | fs: 'empty', 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/SearchField.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useState, unstable_useTransition } from 'react'; 10 | 11 | import { useLocation } from './LocationContext.client'; 12 | import Spinner from './Spinner'; 13 | 14 | export default function SearchField() { 15 | const [text, setText] = useState(''); 16 | const [startSearching, isSearching] = unstable_useTransition(false); 17 | const [, setLocation] = useLocation(); 18 | return ( 19 |
e.preventDefault()}> 20 | 23 | { 28 | const newText = e.target.value; 29 | setText(newText); 30 | startSearching(() => { 31 | setLocation((loc) => ({ 32 | ...loc, 33 | searchText: newText, 34 | })); 35 | }); 36 | }} 37 | /> 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOT PRODUCTION READY** 2 | 3 | # Server Components Boilerplate 4 | 5 | This is a React Server Components Boilerplate to make it easy starting building app using Server Components 6 | 7 | it is based on [server-components-demo](https://github.com/reactjs/server-components-demo) 8 | 9 | ## How to start 10 | 11 | Run the plugin and loader transpilation using babel 12 | 13 | ```bash 14 | yarn plugin 15 | ``` 16 | 17 | Run the Client and Server Bundler at the same time 18 | ```bash 19 | yarn start 20 | ``` 21 | 22 | ## Some explanations 23 | 24 | [./plugin](./plugin) folder has some copied and modified react-server-dom-webpack files 25 | 26 | ### ReactFlightWebpackPlugin modifications 27 | - Be able to have client references using Typescript 28 | 29 | ## ReactFlightWebpackLoader 30 | - A loader to be used on the server to transform client references 31 | - It is similar to ReactFlightWebpackNodeRegister 32 | - This enable avoiding transpiling on the fly in production 33 | 34 | ```jsx 35 | { 36 | test: /\.client.(js|jsx|ts|tsx)?$/, 37 | use: [{ 38 | loader: require.resolve('./plugin/ReactFlightWebpackLoader'), 39 | }, { 40 | loader: 'babel-loader?cacheDirectory', 41 | }], 42 | exclude: [ 43 | /node_modules/, 44 | path.resolve(__dirname, '.serverless'), 45 | path.resolve(__dirname, '.webpack'), 46 | ], 47 | }, 48 | ``` 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/App.server.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import Note from './Note.server'; 4 | import NoteList from './NoteList.server'; 5 | import EditButton from './EditButton.client'; 6 | import SearchField from './SearchField.client'; 7 | import NoteSkeleton from './NoteSkeleton'; 8 | import NoteListSkeleton from './NoteListSkeleton'; 9 | 10 | export default function App({ selectedId, isEditing, searchText }) { 11 | return ( 12 |
13 |
14 |
15 | 23 | React Notes 24 |
25 |
26 | 27 | New 28 |
29 | 34 |
35 |
36 | }> 37 | 38 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /webpack.client.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | // const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin'); 4 | const ReactServerWebpackPlugin = require('./plugin/ReactFlightWebpackPlugin') 5 | .default; 6 | // const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 7 | 8 | const isProduction = process.env.NODE_ENV === 'production'; 9 | 10 | const cwd = process.cwd(); 11 | 12 | module.exports = { 13 | context: path.resolve(cwd, './'), 14 | mode: isProduction ? 'production' : 'development', 15 | devtool: isProduction ? 'source-map' : 'cheap-module-source-map', 16 | entry: [path.resolve(__dirname, './src/index.client.tsx')], 17 | output: { 18 | path: path.resolve(__dirname, './build'), 19 | filename: 'main.js', 20 | pathinfo: false, 21 | futureEmitAssets: true, 22 | }, 23 | resolve: { 24 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.(js|jsx|ts|tsx)?$/, 30 | // use: 'babel-loader', 31 | use: ['babel-loader?cacheDirectory'], 32 | exclude: /node_modules/, 33 | }, 34 | ], 35 | }, 36 | plugins: [ 37 | new HtmlWebpackPlugin({ 38 | inject: true, 39 | template: path.resolve(__dirname, './public/index.html'), 40 | }), 41 | // new ReactRefreshPlugin(), 42 | new ReactServerWebpackPlugin({ 43 | isServer: false, 44 | }), 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | 'use strict'; 10 | 11 | const path = require('path'); 12 | const rimraf = require('rimraf'); 13 | const webpack = require('webpack'); 14 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 15 | const ReactServerWebpackPlugin = require('react-server-dom-webpack/plugin'); 16 | 17 | const isProduction = process.env.NODE_ENV === 'production'; 18 | rimraf.sync(path.resolve(__dirname, '../build')); 19 | webpack( 20 | { 21 | mode: isProduction ? 'production' : 'development', 22 | devtool: isProduction ? 'source-map' : 'cheap-module-source-map', 23 | entry: [path.resolve(__dirname, '../src/index.client.js')], 24 | output: { 25 | path: path.resolve(__dirname, '../build'), 26 | filename: 'main.js', 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | use: 'babel-loader', 33 | exclude: /node_modules/, 34 | }, 35 | ], 36 | }, 37 | plugins: [ 38 | new HtmlWebpackPlugin({ 39 | inject: true, 40 | template: path.resolve(__dirname, '../public/index.html'), 41 | }), 42 | new ReactServerWebpackPlugin({ isServer: false }), 43 | ], 44 | }, 45 | (err, stats) => { 46 | if (err) { 47 | console.error(err.stack || err); 48 | if (err.details) { 49 | console.error(err.details); 50 | } 51 | process.exit(1); 52 | return; 53 | } 54 | const info = stats.toJson(); 55 | if (stats.hasErrors()) { 56 | console.log('Finished running webpack with errors.'); 57 | info.errors.forEach((e) => console.error(e)); 58 | process.exit(1); 59 | } else { 60 | console.log('Finished running webpack.'); 61 | } 62 | }, 63 | ); 64 | -------------------------------------------------------------------------------- /webpackx.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { ChildProcess, spawn } from 'child_process'; 3 | 4 | import webpack from 'webpack'; 5 | 6 | import config, { outputPath, outputFilename } from './webpack/webpack.config'; 7 | 8 | const compilerRunPromise = (compiler) => 9 | new Promise((resolve, reject) => { 10 | compiler.run((err, stats) => { 11 | if (err) { 12 | return reject(err); 13 | } 14 | 15 | if (stats && stats.hasErrors()) { 16 | reject(err || stats.toString()); 17 | } 18 | 19 | resolve(stats); 20 | }); 21 | }); 22 | 23 | export function onExit(childProcess: ChildProcess): Promise { 24 | return new Promise((resolve, reject) => { 25 | childProcess.once('exit', (code: number) => { 26 | if (code === 0) { 27 | resolve(undefined); 28 | } else { 29 | reject(new Error(`Exit with error code: ${code}`)); 30 | } 31 | }); 32 | childProcess.once('error', (err: Error) => { 33 | reject(err); 34 | }); 35 | }); 36 | } 37 | 38 | const runProgram = async () => { 39 | const outputFile = path.join(outputPath, outputFilename); 40 | 41 | console.log({ 42 | execPath: process.execPath, 43 | outputFile, 44 | }); 45 | const childProcess = spawn( 46 | process.execPath, 47 | ['--conditions=react-server', outputFile], 48 | { 49 | stdio: [process.stdin, process.stdout, process.stderr], 50 | }, 51 | ); 52 | 53 | await onExit(childProcess); 54 | }; 55 | 56 | (async () => { 57 | try { 58 | const wpConfig = { 59 | ...config, 60 | entry: path.join(__dirname, process.argv[2]), 61 | }; 62 | 63 | const compiler = webpack(wpConfig); 64 | 65 | const stats = await compilerRunPromise(compiler); 66 | 67 | // eslint-disable-next-line 68 | console.log(stats.toString()); 69 | 70 | await runProgram(); 71 | } catch (err) { 72 | // eslint-disable-next-line 73 | console.log('err: ', err); 74 | process.exit(1); 75 | } 76 | process.exit(0); 77 | })(); 78 | -------------------------------------------------------------------------------- /webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const nodeExternals = require('webpack-node-externals'); 4 | const ReloadServerPlugin = require('./webpack/ReloadServerPlugin'); 5 | const webpack = require('webpack'); 6 | 7 | const cwd = process.cwd(); 8 | 9 | const filename = 'api.js'; 10 | 11 | module.exports = { 12 | context: cwd, 13 | mode: 'development', 14 | devtool: false, 15 | resolve: { 16 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'], 17 | }, 18 | entry: { 19 | server: ['./src/reactServer.ts'], 20 | }, 21 | output: { 22 | // libraryTarget: 'commonjs2', 23 | path: path.resolve('build'), 24 | filename, 25 | futureEmitAssets: true, 26 | }, 27 | watch: true, 28 | target: 'node', 29 | externals: [ 30 | nodeExternals({ 31 | allowlist: [], 32 | }), 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.mjs$/, 38 | type: 'javascript/auto', 39 | }, 40 | { 41 | test: /\.client.(js|jsx|ts|tsx)?$/, 42 | use: [ 43 | { 44 | loader: require.resolve('./plugin/ReactFlightWebpackLoader'), 45 | }, 46 | { 47 | loader: 'babel-loader?cacheDirectory', 48 | }, 49 | ], 50 | exclude: [ 51 | /node_modules/, 52 | path.resolve(__dirname, '.serverless'), 53 | path.resolve(__dirname, '.webpack'), 54 | ], 55 | }, 56 | { 57 | test: /\.(js|jsx|ts|tsx)?$/, 58 | use: { 59 | loader: 'babel-loader?cacheDirectory', 60 | }, 61 | exclude: [ 62 | /node_modules/, 63 | path.resolve(__dirname, '.serverless'), 64 | path.resolve(__dirname, '.webpack'), 65 | ], 66 | }, 67 | ], 68 | }, 69 | plugins: [ 70 | new webpack.HotModuleReplacementPlugin(), 71 | new ReloadServerPlugin({ 72 | script: path.resolve('build', filename), 73 | }), 74 | ], 75 | node: { 76 | __dirname: false, 77 | __filename: false, 78 | fs: 'empty', 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /src/Note.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { fetch } from 'react-fetch'; 10 | import { readFile } from 'react-fs'; 11 | import { format } from 'date-fns'; 12 | import path from 'path'; 13 | 14 | import { db } from './db.server'; 15 | import NotePreview from './NotePreview'; 16 | import EditButton from './EditButton.client'; 17 | import NoteEditor from './NoteEditor.client'; 18 | 19 | export default function Note({ selectedId, isEditing }) { 20 | const note = 21 | selectedId != null 22 | ? fetch(`http://localhost:4000/notes/${selectedId}`).json() 23 | : null; 24 | 25 | if (note === null) { 26 | if (isEditing) { 27 | return ( 28 | 29 | ); 30 | } else { 31 | return ( 32 |
33 | 34 | Click a note on the left to view something! 🥺 35 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | let { id, title, body, updated_at } = note; 42 | const updatedAt = new Date(updated_at); 43 | 44 | // We could also read from a file instead. 45 | // body = readFile(path.resolve(`./notes/${note.id}.md`), 'utf8'); 46 | 47 | // Now let's see how the Suspense boundary above lets us not block on this. 48 | // fetch('http://localhost:4000/sleep/3000'); 49 | 50 | if (isEditing) { 51 | return ; 52 | } else { 53 | return ( 54 |
55 |
56 |

{title}

57 |
58 | 59 | Last updated on {format(updatedAt, "d MMM yyyy 'at' h:mm bb")} 60 | 61 | Edit 62 |
63 |
64 | 65 |
66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/SidebarNote.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useState, useRef, useEffect, unstable_useTransition } from 'react'; 10 | 11 | import { useLocation } from './LocationContext.client'; 12 | 13 | export default function SidebarNote({ id, title, children, expandedChildren }) { 14 | const [location, setLocation] = useLocation(); 15 | const [startTransition, isPending] = unstable_useTransition(); 16 | const [isExpanded, setIsExpanded] = useState(false); 17 | const isActive = id === location.selectedId; 18 | 19 | // Animate after title is edited. 20 | const itemRef = useRef(null); 21 | const prevTitleRef = useRef(title); 22 | useEffect(() => { 23 | if (title !== prevTitleRef.current) { 24 | prevTitleRef.current = title; 25 | itemRef.current.classList.add('flash'); 26 | } 27 | }, [title]); 28 | 29 | return ( 30 |
{ 33 | itemRef.current.classList.remove('flash'); 34 | }} 35 | className={[ 36 | 'sidebar-note-list-item', 37 | isExpanded ? 'note-expanded' : '', 38 | ].join(' ')} 39 | > 40 | {children} 41 | 65 | 83 | {isExpanded && expandedChildren} 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/db.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | const startOfYear = require('date-fns/startOfYear'); 10 | 11 | const now = new Date(); 12 | const startOfThisYear = startOfYear(now); 13 | // Thanks, https://stackoverflow.com/a/9035732 14 | function randomDateBetween(start, end) { 15 | return new Date( 16 | start.getTime() + Math.random() * (end.getTime() - start.getTime()), 17 | ); 18 | } 19 | 20 | const db = [ 21 | { 22 | id: 0, 23 | created_at: randomDateBetween(startOfThisYear, now), 24 | updated_at: now, 25 | title: 'Meeting Notes', 26 | body: 'This is an example note. It contains **Markdown**!', 27 | }, 28 | { 29 | id: 1, 30 | created_at: randomDateBetween(startOfThisYear, now), 31 | updated_at: now, 32 | title: 'Make a thing', 33 | body: `It's very easy to make some words **bold** and other words *italic* with Markdown. You can even [link to React's website!](https://www.reactjs.org).`, 34 | }, 35 | { 36 | id: 2, 37 | created_at: randomDateBetween(startOfThisYear, now), 38 | updated_at: now, 39 | title: 40 | 'A note with a very long title because sometimes you need more words', 41 | body: `You can write all kinds of [amazing](https://en.wikipedia.org/wiki/The_Amazing) 42 | notes in this app! These note live on the server in the \`notes\` folder. 43 | 44 | ![This app is powered by React](https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/React_Native_Logo.png/800px-React_Native_Logo.png)`, 45 | }, 46 | { 47 | id: 3, 48 | created_at: now, 49 | updated_at: now, 50 | title: 'I wrote this note today', 51 | body: 'It was an excellent note.', 52 | }, 53 | ]; 54 | 55 | let nextId = db.length; 56 | 57 | function insertNote(title, body) { 58 | const now = new Date(); 59 | const note = { id: nextId++, title, body, created_at: now, updated_at: now }; 60 | db.push(note); 61 | return note; 62 | } 63 | 64 | function editNote(id, title, body) { 65 | const now = new Date(); 66 | const note = findNote(id); 67 | note.title = title; 68 | note.body = body; 69 | note.updated_at = now; 70 | } 71 | 72 | function findNote(id) { 73 | return db.find((note) => note.id == id); 74 | } 75 | 76 | function deleteNote(id) { 77 | const index = db.findIndex((note) => note.id == id); 78 | if (index > -1) { 79 | db.splice(index, 1); 80 | } 81 | } 82 | 83 | function searchNotes(text) { 84 | return db.filter( 85 | (note) => note.title.includes(text) || note.body.includes(text), 86 | ); 87 | } 88 | 89 | module.exports = { 90 | db, 91 | insertNote, 92 | findNote, 93 | editNote, 94 | deleteNote, 95 | searchNotes, 96 | }; 97 | -------------------------------------------------------------------------------- /src/NoteSkeleton.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | function NoteEditorSkeleton() { 10 | return ( 11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
26 |
30 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | function NotePreviewSkeleton() { 48 | return ( 49 |
54 |
55 |
59 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | ); 73 | } 74 | 75 | export default function NoteSkeleton({ isEditing }) { 76 | return isEditing ? : ; 77 | } 78 | -------------------------------------------------------------------------------- /src/reactServer.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import compress from 'compression'; 3 | import { readFileSync } from 'fs'; 4 | import { unlink, writeFile } from 'fs/promises'; 5 | import { pipeToNodeWritable } from 'react-server-dom-webpack/writer'; 6 | import path from 'path'; 7 | import React from 'react'; 8 | import ReactApp from './App.server'; 9 | 10 | const PORT = 4000; 11 | const app = express(); 12 | 13 | app.use(compress()); 14 | app.use(express.json()); 15 | 16 | app.listen(PORT, () => { 17 | console.log('React Notes listening at 4000...'); 18 | }); 19 | 20 | function handleErrors(fn) { 21 | return async function (req, res, next) { 22 | try { 23 | return await fn(req, res); 24 | } catch (x) { 25 | next(x); 26 | } 27 | }; 28 | } 29 | 30 | app.get( 31 | '/', 32 | handleErrors(async function (_req, res) { 33 | await waitForWebpack(); 34 | const html = readFileSync( 35 | path.resolve(__dirname, '../build/index.html'), 36 | 'utf8', 37 | ); 38 | 39 | // Note: this is sending an empty HTML shell, like a client-side-only app. 40 | // However, the intended solution (which isn't built out yet) is to read 41 | // from the Server endpoint and turn its response into an HTML stream. 42 | res.send(html); 43 | }), 44 | ); 45 | 46 | async function renderReactTree(res, props) { 47 | await waitForWebpack(); 48 | const manifest = readFileSync( 49 | path.resolve(__dirname, '../build/react-client-manifest.json'), 50 | 'utf8', 51 | ); 52 | const moduleMap = JSON.parse(manifest); 53 | pipeToNodeWritable(React.createElement(ReactApp, props), res, moduleMap); 54 | } 55 | 56 | function sendResponse(req, res, redirectToId) { 57 | const location = JSON.parse(req.query.location); 58 | if (redirectToId) { 59 | location.selectedId = redirectToId; 60 | } 61 | res.set('X-Location', JSON.stringify(location)); 62 | 63 | renderReactTree(res, { 64 | selectedId: location.selectedId, 65 | isEditing: location.isEditing, 66 | searchText: location.searchText, 67 | }); 68 | } 69 | 70 | app.get('/react', function (req, res) { 71 | sendResponse(req, res, null); 72 | }); 73 | 74 | app.get('/sleep/:ms', function (req, res) { 75 | setTimeout(() => { 76 | res.json({ ok: true }); 77 | }, req.params.ms); 78 | }); 79 | 80 | app.use(express.static('build')); 81 | app.use(express.static('public')); 82 | 83 | app.on('error', function (error) { 84 | if (error.syscall !== 'listen') { 85 | throw error; 86 | } 87 | var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; 88 | switch (error.code) { 89 | case 'EACCES': 90 | console.error(bind + ' requires elevated privileges'); 91 | process.exit(1); 92 | break; 93 | case 'EADDRINUSE': 94 | console.error(bind + ' is already in use'); 95 | process.exit(1); 96 | break; 97 | default: 98 | throw error; 99 | } 100 | }); 101 | 102 | async function waitForWebpack() { 103 | while (true) { 104 | try { 105 | readFileSync(path.resolve(__dirname, '../build/index.html')); 106 | return; 107 | } catch (err) { 108 | console.log( 109 | 'Could not find webpack build output. Will retry in a second...', 110 | ); 111 | await new Promise((resolve) => setTimeout(resolve, 1000)); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-components-boilerplate", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "compression": "^1.7.4", 6 | "excerpts": "^0.0.3", 7 | "express": "^4.17.1", 8 | "marked": "^1.2.5", 9 | "react": "0.0.0-experimental-3310209d0", 10 | "react-dom": "0.0.0-experimental-3310209d0", 11 | "react-error-boundary": "^3.1.0", 12 | "react-fetch": "0.0.0-experimental-3310209d0", 13 | "react-fs": "0.0.0-experimental-3310209d0", 14 | "react-server-dom-webpack": "0.0.0-experimental-3310209d0", 15 | "sanitize-html": "^2.2.0" 16 | }, 17 | "devDependencies": { 18 | "@babel/cli": "7.12.10", 19 | "@babel/core": "7.12.3", 20 | "@babel/node": "7.12.10", 21 | "@babel/plugin-proposal-class-properties": "7.12.1", 22 | "@babel/plugin-proposal-export-default-from": "7.12.1", 23 | "@babel/plugin-proposal-export-namespace-from": "7.12.1", 24 | "@babel/plugin-proposal-nullish-coalescing-operator": "7.12.1", 25 | "@babel/plugin-proposal-optional-chaining": "7.12.7", 26 | "@babel/plugin-transform-flow-strip-types": "7.12.10", 27 | "@babel/plugin-transform-react-jsx-source": "7.12.1", 28 | "@babel/preset-react": "7.12.10", 29 | "@babel/preset-typescript": "7.12.7", 30 | "@babel/register": "^7.12.1", 31 | "@pmmmwh/react-refresh-webpack-plugin": "0.4.3", 32 | "@types/babel__core": "7.1.12", 33 | "@types/compression": "^1.7.0", 34 | "@types/concurrently": "5.2.1", 35 | "@types/express": "^4.17.9", 36 | "@types/jest": "26.0.19", 37 | "@types/marked": "^1.2.1", 38 | "@types/nodemon": "1.19.0", 39 | "@types/prettier": "2.1.6", 40 | "@types/react": "17.0.0", 41 | "@types/react-dom": "17.0.0", 42 | "@types/rimraf": "3.0.0", 43 | "@types/sanitize-html": "^1.27.0", 44 | "@types/webpack": "4.41.25", 45 | "@types/webpack-node-externals": "2.5.0", 46 | "babel-jest": "26.6.3", 47 | "babel-loader": "8.1.0", 48 | "babel-preset-react-app": "10.0.0", 49 | "concurrently": "5.3.0", 50 | "html-webpack-plugin": "4.5.0", 51 | "husky": "4.3.6", 52 | "jest": "26.6.3", 53 | "jest-environment-jsdom-sixteen": "1.0.3", 54 | "lint-staged": "10.5.2", 55 | "nodemon": "2.0.6", 56 | "prettier": "2.2.1", 57 | "rimraf": "3.0.2", 58 | "ts-node": "9.1.1", 59 | "typescript": "4.1.3", 60 | "webpack": "4.44.2", 61 | "webpack-cli": "4.2.0", 62 | "webpack-node-externals": "2.5.2" 63 | }, 64 | "husky": { 65 | "hooks": { 66 | "pre-commit": "lint-staged" 67 | } 68 | }, 69 | "license": "MIT", 70 | "lint-staged": { 71 | "*.{js,ts,tsx}": [ 72 | "yarn prettier" 73 | ], 74 | "*.yml": [ 75 | "yarn prettier" 76 | ] 77 | }, 78 | "scripts": { 79 | "b": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\"", 80 | "b-server": "babel-node --extensions \".es6,.js,.es,.jsx,.mjs,.ts,.tsx\" --conditions=react-server", 81 | "bundler:dev": "NODE_ENV=development nodemon -- scripts/build.js", 82 | "client": "NODE_ENV=development webpack --watch --progress --config webpack.client.config.js", 83 | "plugin": "babel --extensions \\\".es6,.js,.es,.jsx,.mjs,.ts,.tsx\\\" --watch plugin/**.ts --out-dir ./plugin", 84 | "prettier": "prettier --write", 85 | "server": "webpack --watch --progress --config webpack.server.config.js", 86 | "start": "webpack --watch --progress --config webpack.config.js", 87 | "start2": "concurrently \"npm run server:dev\" \"npm run bundler:dev\"", 88 | "w": "yarn b webpackx.ts" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /plugin/ReactFlightWebpackNodeRegister.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | * @flow 8 | */ 9 | 10 | const url = require('url'); 11 | 12 | // $FlowFixMe 13 | const Module = require('module'); 14 | 15 | module.exports = function register() { 16 | const MODULE_REFERENCE = Symbol.for('react.module.reference'); 17 | const proxyHandlers = { 18 | get: function (target, name, receiver) { 19 | switch (name) { 20 | // These names are read by the Flight runtime if you end up using the exports object. 21 | case '$$typeof': 22 | // These names are a little too common. We should probably have a way to 23 | // have the Flight runtime extract the inner target instead. 24 | return target.$$typeof; 25 | case 'filepath': 26 | return target.filepath; 27 | case 'name': 28 | return target.name; 29 | // We need to special case this because createElement reads it if we pass this 30 | // reference. 31 | case 'defaultProps': 32 | return undefined; 33 | case '__esModule': 34 | // Something is conditionally checking which export to use. We'll pretend to be 35 | // an ESM compat module but then we'll check again on the client. 36 | target.default = { 37 | $$typeof: MODULE_REFERENCE, 38 | filepath: target.filepath, 39 | // This a placeholder value that tells the client to conditionally use the 40 | // whole object or just the default export. 41 | name: '', 42 | }; 43 | return true; 44 | } 45 | let cachedReference = target[name]; 46 | if (!cachedReference) { 47 | cachedReference = target[name] = { 48 | $$typeof: MODULE_REFERENCE, 49 | filepath: target.filepath, 50 | name: name, 51 | }; 52 | } 53 | return cachedReference; 54 | }, 55 | set: function () { 56 | throw new Error('Cannot assign to a client module from a server module.'); 57 | }, 58 | }; 59 | 60 | require.extensions['.client.js'] = function (module, path) { 61 | const moduleId = url.pathToFileURL(path).href; 62 | const moduleReference: { [string]: any } = { 63 | $$typeof: MODULE_REFERENCE, 64 | filepath: moduleId, 65 | name: '*', // Represents the whole object instead of a particular import. 66 | }; 67 | module.exports = new Proxy(moduleReference, proxyHandlers); 68 | }; 69 | 70 | const originalResolveFilename = Module._resolveFilename; 71 | 72 | Module._resolveFilename = function (request, parent, isMain, options) { 73 | const resolved = originalResolveFilename.apply(this, arguments); 74 | if (resolved.endsWith('.server.js')) { 75 | if ( 76 | parent && 77 | parent.filename && 78 | !parent.filename.endsWith('.server.js') 79 | ) { 80 | let reason; 81 | if (request.endsWith('.server.js')) { 82 | reason = `"${request}"`; 83 | } else { 84 | reason = `"${request}" (which expands to "${resolved}")`; 85 | } 86 | throw new Error( 87 | `Cannot import ${reason} from "${parent.filename}". ` + 88 | 'By react-server convention, .server.js files can only be imported from other .server.js files. ' + 89 | 'That way nobody accidentally sends these to the client by indirectly importing it.', 90 | ); 91 | } 92 | } 93 | return resolved; 94 | }; 95 | }; 96 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "moduleResolution": "node", 7 | "lib": [ /* Specify library files to be included in the compilation. */ 8 | "esnext", 9 | "dom", 10 | "dom.iterable" 11 | ], 12 | // "allowJs": true, /* Allow javascript files to be compiled. */ 13 | // "checkJs": true, /* Report errors in .js files. */ 14 | // "jsx": "react-jsxdev", 15 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./distTs", /* Redirect output structure to the directory. */ 21 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | // "composite": true, /* Enable project compilation */ 23 | // "removeComments": true, /* Do not emit comments to output. */ 24 | "noEmit": true, /* Do not emit outputs. */ 25 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 26 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 27 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 28 | 29 | /* Strict Type-Checking Options */ 30 | "strict": true, /* Enable all strict type-checking options. */ 31 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 32 | // "strictNullChecks": true, /* Enable strict null checks. */ 33 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // }, 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "resolveJsonModule": true, 54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | "skipLibCheck": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/NoteEditor.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | * 7 | */ 8 | 9 | import { useState, unstable_useTransition } from 'react'; 10 | import { createFromReadableStream } from 'react-server-dom-webpack'; 11 | 12 | import NotePreview from './NotePreview'; 13 | import { useRefresh } from './Cache.client'; 14 | import { useLocation } from './LocationContext.client'; 15 | 16 | export default function NoteEditor({ noteId, initialTitle, initialBody }) { 17 | const refresh = useRefresh(); 18 | const [title, setTitle] = useState(initialTitle); 19 | const [body, setBody] = useState(initialBody); 20 | const [location, setLocation] = useLocation(); 21 | const [startNavigating, isNavigating] = unstable_useTransition(); 22 | const [isSaving, saveNote] = useMutation({ 23 | endpoint: noteId !== null ? `/notes/${noteId}` : `/notes`, 24 | method: noteId !== null ? 'PUT' : 'POST', 25 | }); 26 | const [isDeleting, deleteNote] = useMutation({ 27 | endpoint: `/notes/${noteId}`, 28 | method: 'DELETE', 29 | }); 30 | 31 | async function handleSave() { 32 | const payload = { title, body }; 33 | const requestedLocation = { 34 | selectedId: noteId, 35 | isEditing: false, 36 | searchText: location.searchText, 37 | }; 38 | const response = await saveNote(payload, requestedLocation); 39 | navigate(response); 40 | } 41 | 42 | async function handleDelete() { 43 | const payload = {}; 44 | const requestedLocation = { 45 | selectedId: null, 46 | isEditing: false, 47 | searchText: location.searchText, 48 | }; 49 | const response = await deleteNote(payload, requestedLocation); 50 | navigate(response); 51 | } 52 | 53 | function navigate(response) { 54 | const cacheKey = response.headers.get('X-Location'); 55 | const nextLocation = JSON.parse(cacheKey); 56 | const seededResponse = createFromReadableStream(response.body); 57 | startNavigating(() => { 58 | refresh(cacheKey, seededResponse); 59 | setLocation(nextLocation); 60 | }); 61 | } 62 | 63 | const isDraft = noteId === null; 64 | return ( 65 |
66 |
e.preventDefault()} 70 | > 71 | 74 | { 79 | setTitle(e.target.value); 80 | }} 81 | /> 82 | 85 |