├── 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 |
4 |
--------------------------------------------------------------------------------
/public/chevron-up.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/cross.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/checkmark.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
29 |
34 |
35 |
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 | `,
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 |
20 |
21 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | function NotePreviewSkeleton() {
48 | return (
49 |
54 |
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 |
93 |
94 |
95 |
110 | {!isDraft && (
111 |
126 | )}
127 |
128 |
129 | Preview
130 |
131 |
{title}
132 |
133 |
134 |
135 | );
136 | }
137 |
138 | function useMutation({ endpoint, method }) {
139 | const [isSaving, setIsSaving] = useState(false);
140 | const [didError, setDidError] = useState(false);
141 | const [error, setError] = useState(null);
142 | if (didError) {
143 | // Let the nearest error boundary handle errors while saving.
144 | throw error;
145 | }
146 |
147 | async function performMutation(payload, requestedLocation) {
148 | setIsSaving(true);
149 | try {
150 | const response = await fetch(
151 | `${endpoint}?location=${encodeURIComponent(
152 | JSON.stringify(requestedLocation),
153 | )}`,
154 | {
155 | method,
156 | body: JSON.stringify(payload),
157 | headers: {
158 | 'Content-Type': 'application/json',
159 | },
160 | },
161 | );
162 | if (!response.ok) {
163 | throw new Error(await response.text());
164 | }
165 | return response;
166 | } catch (e) {
167 | setDidError(true);
168 | setError(e);
169 | } finally {
170 | setIsSaving(false);
171 | }
172 | }
173 |
174 | return [isSaving, performMutation];
175 | }
176 |
--------------------------------------------------------------------------------
/plugin/__tests__/ReactFlightWebpackPlugin.spec.ts:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const os = require('os');
3 |
4 | function getDependencies(mode) {
5 | jest.resetModuleRegistry();
6 | let webpack;
7 | if (mode === 'wp5') {
8 | webpack = jest.requireActual('webpack5');
9 | // The code we are testing (ReactFlightWebpackPlugin) directly imports `webpack`. It cannot depend upon `webpack5` as
10 | // consumers of `ReactFlightWebpackPlugin` are more likely to have installed wp5 just as `webpack`. So we fix this by mocking the
11 | // `webpack` module, and return the webpack 5 instance that we required.
12 | jest.mock('webpack', () => {
13 | return webpack;
14 | });
15 | // Sanity-check. If the webpack in package.json changes, this should catch that
16 | expect(webpack.version).toMatch(/5\.[0-9]*\.[0-9]*/);
17 | } else {
18 | webpack = jest.requireActual('webpack');
19 | // Sanity-check. If the webpack in package.json changes, this should catch that
20 | expect(webpack.version).toMatch(/4\.[0-9]*\.[0-9]*/);
21 | }
22 |
23 | const FlightPlugin = require('../ReactFlightWebpackPlugin').default;
24 |
25 | console.log({
26 | FlightPlugin,
27 | });
28 | return {
29 | FlightPlugin,
30 | webpack,
31 | };
32 | }
33 |
34 | // const fixturePath = './packages/react-server-dom-webpack/src/__tests__/fixture';
35 | const fixturePath = './plugin/__tests__/fixture';
36 |
37 | describe('ReactFlightWebpackPlugin', () => {
38 | // Running webpack can be slow, so we increase Jest's default timeout. These values are
39 | // "magic", and not backed by any kind of logic or reasoning.
40 | jest.setTimeout(5000 * 5);
41 |
42 | test('produces manifest - webpack v4', (done) => {
43 | const { webpack, FlightPlugin } = getDependencies('wp4');
44 |
45 | const entry = path.resolve(path.join(__dirname, 'fixture', 'entry.js'));
46 |
47 | const plugin = new FlightPlugin({ isServer: false });
48 |
49 | const output = webpack({
50 | entry: {
51 | main: entry,
52 | client: path.resolve(path.join(__dirname, 'fixture', 'Form.client.js')),
53 | },
54 | plugins: [plugin],
55 | output: {
56 | path: path.resolve(path.join(os.tmpdir(), 'output')),
57 | },
58 | mode: 'development',
59 | });
60 |
61 | output.run((err, stats) => {
62 | expect(err).toBeNull();
63 | // in webpack 4, we can read the assets of the compilation object. This doesn't work in webpack 5
64 | // as webpack 5 removes the source from the assets object, to prevent memory leaks.
65 | const fileName = plugin.manifestFilename;
66 | const pluginOutput = stats.compilation.assets[fileName];
67 | const producedManifest = pluginOutput.source();
68 | assert(producedManifest);
69 |
70 | done();
71 | });
72 | });
73 |
74 | test.only('produces manifest - webpack v4 using real code', (done) => {
75 | const { webpack, FlightPlugin } = getDependencies('wp4');
76 |
77 | const entry = path.resolve(
78 | path.join(__dirname, '../../src/', 'index.client.tsx'),
79 | );
80 |
81 | console.log('entry: ', entry);
82 |
83 | const plugin = new FlightPlugin({ isServer: false });
84 |
85 | const output = webpack({
86 | entry: [entry],
87 | plugins: [plugin],
88 | output: {
89 | path: path.resolve(path.join(os.tmpdir(), 'output')),
90 | },
91 | resolve: {
92 | extensions: ['.ts', '.tsx', '.js', '.json', '.mjs'],
93 | },
94 | mode: 'development',
95 | module: {
96 | rules: [
97 | {
98 | test: /\.(js|jsx|ts|tsx)?$/,
99 | // use: 'babel-loader',
100 | use: ['babel-loader?cacheDirectory'],
101 | exclude: /node_modules/,
102 | },
103 | ],
104 | },
105 | });
106 |
107 | output.run((err, stats) => {
108 | expect(err).toBeNull();
109 | // in webpack 4, we can read the assets of the compilation object. This doesn't work in webpack 5
110 | // as webpack 5 removes the source from the assets object, to prevent memory leaks.
111 | const fileName = plugin.manifestFilename;
112 | const pluginOutput = stats.compilation.assets[fileName];
113 | const producedManifest = pluginOutput.source();
114 |
115 | const key =
116 | 'file://' +
117 | path.resolve(path.join(__dirname, '../../src', 'index.client.tsx'));
118 | const manifestObj = JSON.parse(producedManifest);
119 |
120 | console.log({
121 | key,
122 | manifestObj,
123 | m: manifestObj[key],
124 | });
125 | // file:///Users/sibelius/Dev/entria/feedback/rsc/server-components-boilerplate/src/index.client.tsx
126 | // file:///Users/sibelius/Dev/entria/feedback/rsc/server-components-boilerplate/plugin/src/index.client.ts
127 |
128 | const clientPath = './src';
129 |
130 | expect(manifestObj[key]).toStrictEqual(
131 | expect.objectContaining({
132 | '': {
133 | chunks: ['main'],
134 | id: `${clientPath}/index.client.tsx`,
135 | name: '',
136 | },
137 | '*': {
138 | chunks: ['main'],
139 | id: `${clientPath}/index.client.tsx`,
140 | name: '*',
141 | },
142 | }),
143 | );
144 |
145 | done();
146 | });
147 | });
148 |
149 | test.skip('produces manifest - webpack v5', (done) => {
150 | const entry = path.resolve(path.join(__dirname, 'fixture', 'entry.js'));
151 | const { webpack, FlightPlugin } = getDependencies('wp5');
152 |
153 | const plugin = new FlightPlugin({ isServer: false });
154 | const fileName = plugin.manifestFilename;
155 |
156 | const output = webpack({
157 | entry: {
158 | main: entry,
159 | client: path.resolve(path.join(__dirname, 'fixture', 'Form.client.js')),
160 | },
161 | plugins: [plugin],
162 | cache: undefined,
163 | output: {
164 | // Output
165 | path: path.resolve(path.join(os.tmpdir(), 'output+2')),
166 | // Make webpack always want to emit files, regardless if they exist or not
167 | // This aids in development of the tests, as webpack 5 will not emit fi the file is already existing.
168 | compareBeforeEmit: false,
169 | },
170 | mode: 'development',
171 | });
172 |
173 | const originalFileSystem = output.outputFileSystem;
174 |
175 | output.outputFileSystem = {
176 | ...originalFileSystem,
177 | writeFile: jest.fn((dest, contents, cb) => {
178 | // Call the callback, but don't actually write anything.
179 | cb(null);
180 | }),
181 | };
182 |
183 | output.run((err, stats) => {
184 | expect(err).toBeNull();
185 |
186 | expect(output.outputFileSystem.writeFile).toHaveBeenCalledWith(
187 | expect.stringContaining(fileName),
188 | expect.anything(),
189 | expect.anything(),
190 | );
191 | const calls = output.outputFileSystem.writeFile.mock.calls;
192 | // Get the idx that was called with the fileName,
193 | const idx = calls.findIndex((val) => {
194 | return val[0].includes(fileName);
195 | });
196 |
197 | const contents = output.outputFileSystem.writeFile.mock.calls[
198 | idx
199 | ][1].toString();
200 |
201 | // Check that the contents match with what we expect
202 | assert(contents);
203 | done();
204 | });
205 | });
206 | });
207 |
208 | function assert(manifestContents) {
209 | const key =
210 | 'file://' + path.resolve(path.join(__dirname, 'fixture', 'Form.client.js'));
211 | const manifestObj = JSON.parse(manifestContents);
212 |
213 | expect(manifestObj[key]).toStrictEqual(
214 | expect.objectContaining({
215 | '': {
216 | chunks: ['client'],
217 | id: `${fixturePath}/Form.client.js`,
218 | name: '',
219 | },
220 | '*': {
221 | chunks: ['client'],
222 | id: `${fixturePath}/Form.client.js`,
223 | name: '*',
224 | },
225 | Form: {
226 | chunks: ['client'],
227 | id: `${fixturePath}/Form.client.js`,
228 | name: 'Form',
229 | },
230 | }),
231 | );
232 | }
233 |
--------------------------------------------------------------------------------
/plugin/ReactFlightWebpackNodeLoader.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 | import acorn from 'acorn';
11 |
12 | type ResolveContext = {
13 | conditions: Array;
14 | parentURL: string | void;
15 | };
16 |
17 | type ResolveFunction = (
18 | string,
19 | ResolveContext,
20 | ResolveFunction,
21 | ) => { url: string } | Promise<{ url: string }>;
22 |
23 | type GetSourceContext = {
24 | format: string;
25 | };
26 |
27 | type GetSourceFunction = (
28 | string,
29 | GetSourceContext,
30 | GetSourceFunction,
31 | ) => Promise<{ source: Source }>;
32 |
33 | type TransformSourceContext = {
34 | format: string;
35 | url: string;
36 | };
37 |
38 | type TransformSourceFunction = (
39 | Source,
40 | TransformSourceContext,
41 | TransformSourceFunction,
42 | ) => Promise<{ source: Source }>;
43 |
44 | type Source = string | ArrayBuffer | Uint8Array;
45 |
46 | let warnedAboutConditionsFlag = false;
47 |
48 | let stashedGetSource: null | GetSourceFunction = null;
49 | let stashedResolve: null | ResolveFunction = null;
50 |
51 | export async function resolve(
52 | specifier: string,
53 | context: ResolveContext,
54 | defaultResolve: ResolveFunction,
55 | ): Promise<{ url: string }> {
56 | // We stash this in case we end up needing to resolve export * statements later.
57 | stashedResolve = defaultResolve;
58 |
59 | if (!context.conditions.includes('react-server')) {
60 | context = {
61 | ...context,
62 | conditions: [...context.conditions, 'react-server'],
63 | };
64 | if (!warnedAboutConditionsFlag) {
65 | warnedAboutConditionsFlag = true;
66 | // eslint-disable-next-line react-internal/no-production-logging
67 | console.warn(
68 | 'You did not run Node.js with the `--conditions react-server` flag. ' +
69 | 'Any "react-server" override will only work with ESM imports.',
70 | );
71 | }
72 | }
73 | const resolved = await defaultResolve(specifier, context, defaultResolve);
74 | if (resolved.url.endsWith('.server.js')) {
75 | const parentURL = context.parentURL;
76 | if (parentURL && !parentURL.endsWith('.server.js')) {
77 | let reason;
78 | if (specifier.endsWith('.server.js')) {
79 | reason = `"${specifier}"`;
80 | } else {
81 | reason = `"${specifier}" (which expands to "${resolved.url}")`;
82 | }
83 | throw new Error(
84 | `Cannot import ${reason} from "${parentURL}". ` +
85 | 'By react-server convention, .server.js files can only be imported from other .server.js files. ' +
86 | 'That way nobody accidentally sends these to the client by indirectly importing it.',
87 | );
88 | }
89 | }
90 | return resolved;
91 | }
92 |
93 | export async function getSource(
94 | url: string,
95 | context: GetSourceContext,
96 | defaultGetSource: GetSourceFunction,
97 | ) {
98 | // We stash this in case we end up needing to resolve export * statements later.
99 | stashedGetSource = defaultGetSource;
100 | return defaultGetSource(url, context, defaultGetSource);
101 | }
102 |
103 | function addExportNames(names, node) {
104 | switch (node.type) {
105 | case 'Identifier':
106 | names.push(node.name);
107 | return;
108 | case 'ObjectPattern':
109 | for (let i = 0; i < node.properties.length; i++)
110 | addExportNames(names, node.properties[i]);
111 | return;
112 | case 'ArrayPattern':
113 | for (let i = 0; i < node.elements.length; i++) {
114 | const element = node.elements[i];
115 | if (element) addExportNames(names, element);
116 | }
117 | return;
118 | case 'Property':
119 | addExportNames(names, node.value);
120 | return;
121 | case 'AssignmentPattern':
122 | addExportNames(names, node.left);
123 | return;
124 | case 'RestElement':
125 | addExportNames(names, node.argument);
126 | return;
127 | case 'ParenthesizedExpression':
128 | addExportNames(names, node.expression);
129 | return;
130 | }
131 | }
132 |
133 | function resolveClientImport(
134 | specifier: string,
135 | parentURL: string,
136 | ): { url: string } | Promise<{ url: string }> {
137 | // Resolve an import specifier as if it was loaded by the client. This doesn't use
138 | // the overrides that this loader does but instead reverts to the default.
139 | // This resolution algorithm will not necessarily have the same configuration
140 | // as the actual client loader. It should mostly work and if it doesn't you can
141 | // always convert to explicit exported names instead.
142 | const conditions = ['node', 'import'];
143 | if (stashedResolve === null) {
144 | throw new Error(
145 | 'Expected resolve to have been called before transformSource',
146 | );
147 | }
148 | return stashedResolve(specifier, { conditions, parentURL }, stashedResolve);
149 | }
150 |
151 | async function loadClientImport(
152 | url: string,
153 | defaultTransformSource: TransformSourceFunction,
154 | ): Promise<{ source: Source }> {
155 | if (stashedGetSource === null) {
156 | throw new Error(
157 | 'Expected getSource to have been called before transformSource',
158 | );
159 | }
160 | // TODO: Validate that this is another module by calling getFormat.
161 | const { source } = await stashedGetSource(
162 | url,
163 | { format: 'module' },
164 | stashedGetSource,
165 | );
166 | return defaultTransformSource(
167 | source,
168 | { format: 'module', url },
169 | defaultTransformSource,
170 | );
171 | }
172 |
173 | async function parseExportNamesInto(
174 | transformedSource: string,
175 | names: Array,
176 | parentURL: string,
177 | defaultTransformSource,
178 | ): Promise {
179 | const { body } = acorn.parse(transformedSource, {
180 | ecmaVersion: '2019',
181 | sourceType: 'module',
182 | });
183 | for (let i = 0; i < body.length; i++) {
184 | const node = body[i];
185 | switch (node.type) {
186 | case 'ExportAllDeclaration':
187 | if (node.exported) {
188 | addExportNames(names, node.exported);
189 | continue;
190 | } else {
191 | const { url } = await resolveClientImport(
192 | node.source.value,
193 | parentURL,
194 | );
195 | const { source } = await loadClientImport(
196 | url,
197 | defaultTransformSource,
198 | );
199 | if (typeof source !== 'string') {
200 | throw new Error('Expected the transformed source to be a string.');
201 | }
202 | parseExportNamesInto(source, names, url, defaultTransformSource);
203 | continue;
204 | }
205 | case 'ExportDefaultDeclaration':
206 | names.push('default');
207 | continue;
208 | case 'ExportNamedDeclaration':
209 | if (node.declaration) {
210 | if (node.declaration.type === 'VariableDeclaration') {
211 | const declarations = node.declaration.declarations;
212 | for (let j = 0; j < declarations.length; j++) {
213 | addExportNames(names, declarations[j].id);
214 | }
215 | } else {
216 | addExportNames(names, node.declaration.id);
217 | }
218 | }
219 | if (node.specificers) {
220 | const specificers = node.specificers;
221 | for (let j = 0; j < specificers.length; j++) {
222 | addExportNames(names, specificers[j].exported);
223 | }
224 | }
225 | continue;
226 | }
227 | }
228 | }
229 |
230 | export async function transformSource(
231 | source: Source,
232 | context: TransformSourceContext,
233 | defaultTransformSource: TransformSourceFunction,
234 | ): Promise<{ source: Source }> {
235 | const transformed = await defaultTransformSource(
236 | source,
237 | context,
238 | defaultTransformSource,
239 | );
240 | if (context.format === 'module' && context.url.endsWith('.client.js')) {
241 | const transformedSource = transformed.source;
242 | if (typeof transformedSource !== 'string') {
243 | throw new Error('Expected source to have been transformed to a string.');
244 | }
245 |
246 | const names = [];
247 | await parseExportNamesInto(
248 | transformedSource,
249 | names,
250 | context.url,
251 | defaultTransformSource,
252 | );
253 |
254 | let newSrc =
255 | "const MODULE_REFERENCE = Symbol.for('react.module.reference');\n";
256 | for (let i = 0; i < names.length; i++) {
257 | const name = names[i];
258 | if (name === 'default') {
259 | newSrc += 'export default ';
260 | } else {
261 | newSrc += 'export const ' + name + ' = ';
262 | }
263 | newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: ';
264 | newSrc += JSON.stringify(context.url);
265 | newSrc += ', name: ';
266 | newSrc += JSON.stringify(name);
267 | newSrc += '};\n';
268 | }
269 |
270 | return { source: newSrc };
271 | }
272 | return transformed;
273 | }
274 |
--------------------------------------------------------------------------------
/plugin/ReactFlightWebpackPlugin.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 | import { join } from 'path';
11 | import { pathToFileURL } from 'url';
12 |
13 | import asyncLib from 'neo-async';
14 |
15 | import ModuleDependency from 'webpack/lib/dependencies/ModuleDependency';
16 | import NullDependency from 'webpack/lib/dependencies/NullDependency';
17 | import AsyncDependenciesBlock from 'webpack/lib/AsyncDependenciesBlock';
18 | import Template from 'webpack/lib/Template';
19 |
20 | class ClientReferenceDependency extends ModuleDependency {
21 | constructor(request) {
22 | super(request);
23 | }
24 |
25 | get type() {
26 | return 'client-reference';
27 | }
28 | }
29 |
30 | // This is the module that will be used to anchor all client references to.
31 | // I.e. it will have all the client files as async deps from this point on.
32 | // We use the Flight client implementation because you can't get to these
33 | // without the client runtime so it's the first time in the loading sequence
34 | // you might want them.
35 | // TODO - fix this;
36 | // const clientFileName = require.resolve('../');
37 | const clientFileName = join(
38 | __dirname,
39 | '../node_modules/react-server-dom-webpack/index.js',
40 | );
41 |
42 | type ClientReferenceSearchPath = {
43 | directory: string;
44 | recursive?: boolean;
45 | include: RegExp;
46 | exclude?: RegExp;
47 | };
48 |
49 | type ClientReferencePath = string | ClientReferenceSearchPath;
50 |
51 | type Options = {
52 | isServer: boolean;
53 | clientReferences?: ClientReferencePath | ReadonlyArray;
54 | chunkName?: string;
55 | manifestFilename?: string;
56 | };
57 |
58 | const PLUGIN_NAME = 'React Server Plugin';
59 |
60 | export default class ReactFlightWebpackPlugin {
61 | clientReferences: ReadonlyArray;
62 | chunkName: string;
63 | manifestFilename: string;
64 |
65 | constructor(options: Options) {
66 | if (!options || typeof options.isServer !== 'boolean') {
67 | throw new Error(
68 | PLUGIN_NAME + ': You must specify the isServer option as a boolean.',
69 | );
70 | }
71 | if (options.isServer) {
72 | throw new Error('TODO: Implement the server compiler.');
73 | }
74 | if (!options.clientReferences) {
75 | this.clientReferences = [
76 | {
77 | directory: '.',
78 | recursive: true,
79 | include: /\.client\.(js|ts|jsx|tsx)$/,
80 | },
81 | ];
82 | } else if (
83 | typeof options.clientReferences === 'string' ||
84 | !Array.isArray(options.clientReferences)
85 | ) {
86 | this.clientReferences = options.clientReferences;
87 | } else {
88 | this.clientReferences = options.clientReferences;
89 | }
90 | if (typeof options.chunkName === 'string') {
91 | this.chunkName = options.chunkName;
92 | if (!/\[(index|request)\]/.test(this.chunkName)) {
93 | this.chunkName += '[index]';
94 | }
95 | } else {
96 | this.chunkName = 'client[index]';
97 | }
98 | this.manifestFilename =
99 | options.manifestFilename || 'react-client-manifest.json';
100 | }
101 |
102 | apply(compiler: any) {
103 | let resolvedClientReferences;
104 | const run = (params, callback) => {
105 | // First we need to find all client files on the file system. We do this early so
106 | // that we have them synchronously available later when we need them. This might
107 | // not be needed anymore since we no longer need to compile the module itself in
108 | // a special way. So it's probably better to do this lazily and in parallel with
109 | // other compilation.
110 | const contextResolver = compiler.resolverFactory.get('context', {});
111 | this.resolveAllClientFiles(
112 | compiler.context,
113 | contextResolver,
114 | compiler.inputFileSystem,
115 | compiler.createContextModuleFactory(),
116 | (err, resolvedClientRefs) => {
117 | if (err) {
118 | callback(err);
119 | return;
120 | }
121 | resolvedClientReferences = resolvedClientRefs;
122 | callback();
123 | },
124 | );
125 | };
126 |
127 | compiler.hooks.run.tapAsync(PLUGIN_NAME, run);
128 | compiler.hooks.watchRun.tapAsync(PLUGIN_NAME, run);
129 | compiler.hooks.compilation.tap(
130 | PLUGIN_NAME,
131 | (compilation, { normalModuleFactory }) => {
132 | compilation.dependencyFactories.set(
133 | ClientReferenceDependency,
134 | normalModuleFactory,
135 | );
136 | compilation.dependencyTemplates.set(
137 | ClientReferenceDependency,
138 | new NullDependency.Template(),
139 | );
140 |
141 | compilation.hooks.buildModule.tap(PLUGIN_NAME, (module) => {
142 | // We need to add all client references as dependency of something in the graph so
143 | // Webpack knows which entries need to know about the relevant chunks and include the
144 | // map in their runtime. The things that actually resolves the dependency is the Flight
145 | // client runtime. So we add them as a dependency of the Flight client runtime.
146 | // Anything that imports the runtime will be made aware of these chunks.
147 | // TODO: Warn if we don't find this file anywhere in the compilation.
148 | if (module.resource !== clientFileName) {
149 | return;
150 | }
151 | if (resolvedClientReferences) {
152 | for (let i = 0; i < resolvedClientReferences.length; i++) {
153 | const dep = resolvedClientReferences[i];
154 | const chunkName = this.chunkName
155 | .replace(/\[index\]/g, '' + i)
156 | .replace(/\[request\]/g, Template.toPath(dep.userRequest));
157 |
158 | const block = new AsyncDependenciesBlock(
159 | {
160 | name: chunkName,
161 | },
162 | module,
163 | null,
164 | dep.require,
165 | );
166 | block.addDependency(dep);
167 | module.addBlock(block);
168 | }
169 | }
170 | });
171 | },
172 | );
173 |
174 | compiler.hooks.emit.tap(PLUGIN_NAME, (compilation) => {
175 | const json = {};
176 | compilation.chunkGroups.forEach((chunkGroup) => {
177 | const chunkIds = chunkGroup.chunks.map((c) => c.id);
178 |
179 | function recordModule(id, mod) {
180 | // TODO: Hook into deps instead of the target module.
181 | // That way we know by the type of dep whether to include.
182 | // It also resolves conflicts when the same module is in multiple chunks.
183 | if (!/\.client\.(js|jsx|ts|tsx)$/.test(mod.resource)) {
184 | return;
185 | }
186 |
187 | const moduleExports = {};
188 | ['', '*'].concat(mod.buildMeta.providedExports).forEach((name) => {
189 | moduleExports[name] = {
190 | id: id,
191 | chunks: chunkIds,
192 | name: name,
193 | };
194 | });
195 | const href = pathToFileURL(mod.resource).href;
196 | if (href !== undefined) {
197 | json[href] = moduleExports;
198 | }
199 | }
200 |
201 | chunkGroup.chunks.forEach((chunk) => {
202 | chunk.getModules().forEach((mod) => {
203 | recordModule(mod.id, mod);
204 | // If this is a concatenation, register each child to the parent ID.
205 | if (mod.modules) {
206 | mod.modules.forEach((concatenatedMod) => {
207 | recordModule(mod.id, concatenatedMod);
208 | });
209 | }
210 | });
211 | });
212 | });
213 | const output = JSON.stringify(json, null, 2);
214 | compilation.assets[this.manifestFilename] = {
215 | source() {
216 | return output;
217 | },
218 | size() {
219 | return output.length;
220 | },
221 | };
222 | });
223 | }
224 |
225 | // This attempts to replicate the dynamic file path resolution used for other wildcard
226 | // resolution in Webpack is using.
227 | resolveAllClientFiles(
228 | context: string,
229 | contextResolver: any,
230 | fs: any,
231 | contextModuleFactory: any,
232 | callback: (
233 | err: null | Error,
234 | result?: ReadonlyArray,
235 | ) => void,
236 | ) {
237 | asyncLib.map(
238 | this.clientReferences,
239 | (
240 | clientReferencePath: string | ClientReferenceSearchPath,
241 | cb: (
242 | err: null | Error,
243 | result?: ReadonlyArray,
244 | ) => void,
245 | ): void => {
246 | if (typeof clientReferencePath === 'string') {
247 | cb(null, [new ClientReferenceDependency(clientReferencePath)]);
248 | return;
249 | }
250 | const clientReferenceSearch: ClientReferenceSearchPath = clientReferencePath;
251 | contextResolver.resolve(
252 | {},
253 | context,
254 | clientReferencePath.directory,
255 | {},
256 | (err, resolvedDirectory) => {
257 | if (err) return cb(err);
258 | const options = {
259 | resource: resolvedDirectory,
260 | resourceQuery: '',
261 | recursive:
262 | clientReferenceSearch.recursive === undefined
263 | ? true
264 | : clientReferenceSearch.recursive,
265 | regExp: clientReferenceSearch.include,
266 | include: undefined,
267 | exclude: clientReferenceSearch.exclude,
268 | };
269 |
270 | contextModuleFactory.resolveDependencies(
271 | fs,
272 | options,
273 | (err2: null | Error, deps: Array) => {
274 | if (err2) return cb(err2);
275 |
276 | const clientRefDeps = deps.map((dep) => {
277 | const request = join(resolvedDirectory, dep.request);
278 | const clientRefDep = new ClientReferenceDependency(request);
279 | clientRefDep.userRequest = dep.userRequest;
280 | return clientRefDep;
281 | });
282 | cb(null, clientRefDeps);
283 | },
284 | );
285 | },
286 | );
287 | },
288 | (
289 | err: null | Error,
290 | result: ReadonlyArray>,
291 | ): void => {
292 | if (err) return callback(err);
293 | const flat = [];
294 | for (let i = 0; i < result.length; i++) {
295 | flat.push.apply(flat, result[i]);
296 | }
297 | callback(null, flat);
298 | },
299 | );
300 | }
301 | }
302 |
--------------------------------------------------------------------------------
/public/style.css:
--------------------------------------------------------------------------------
1 | /* -------------------------------- CSSRESET --------------------------------*/
2 | /* CSS Reset adapted from https://dev.to/hankchizljaw/a-modern-css-reset-6p3 */
3 | /* Box sizing rules */
4 | *,
5 | *::before,
6 | *::after {
7 | box-sizing: border-box;
8 | }
9 |
10 | /* Remove default padding */
11 | ul[class],
12 | ol[class] {
13 | padding: 0;
14 | }
15 |
16 | /* Remove default margin */
17 | body,
18 | h1,
19 | h2,
20 | h3,
21 | h4,
22 | p,
23 | ul[class],
24 | ol[class],
25 | li,
26 | figure,
27 | figcaption,
28 | blockquote,
29 | dl,
30 | dd {
31 | margin: 0;
32 | }
33 |
34 | /* Set core body defaults */
35 | body {
36 | min-height: 100vh;
37 | scroll-behavior: smooth;
38 | text-rendering: optimizeSpeed;
39 | line-height: 1.5;
40 | }
41 |
42 | /* Remove list styles on ul, ol elements with a class attribute */
43 | ul[class],
44 | ol[class] {
45 | list-style: none;
46 | }
47 |
48 | /* A elements that don't have a class get default styles */
49 | a:not([class]) {
50 | text-decoration-skip-ink: auto;
51 | }
52 |
53 | /* Make images easier to work with */
54 | img {
55 | max-width: 100%;
56 | display: block;
57 | }
58 |
59 | /* Natural flow and rhythm in articles by default */
60 | article > * + * {
61 | margin-block-start: 1em;
62 | }
63 |
64 | /* Inherit fonts for inputs and buttons */
65 | input,
66 | button,
67 | textarea,
68 | select {
69 | font: inherit;
70 | }
71 |
72 | /* Remove all animations and transitions for people that prefer not to see them */
73 | @media (prefers-reduced-motion: reduce) {
74 | * {
75 | animation-duration: 0.01ms !important;
76 | animation-iteration-count: 1 !important;
77 | transition-duration: 0.01ms !important;
78 | scroll-behavior: auto !important;
79 | }
80 | }
81 | /* -------------------------------- /CSSRESET --------------------------------*/
82 |
83 | :root {
84 | /* Colors */
85 | --main-border-color: #ddd;
86 | --primary-border: #037dba;
87 | --gray-20: #404346;
88 | --gray-60: #8a8d91;
89 | --gray-70: #bcc0c4;
90 | --gray-80: #c9ccd1;
91 | --gray-90: #e4e6eb;
92 | --gray-95: #f0f2f5;
93 | --gray-100: #f5f7fa;
94 | --primary-blue: #037dba;
95 | --secondary-blue: #0396df;
96 | --tertiary-blue: #c6efff;
97 | --flash-blue: #4cf7ff;
98 | --outline-blue: rgba(4, 164, 244, 0.6);
99 | --navy-blue: #035e8c;
100 | --red-25: #bd0d2a;
101 | --secondary-text: #65676b;
102 | --white: #fff;
103 | --yellow: #fffae1;
104 |
105 | --outline-box-shadow: 0 0 0 2px var(--outline-blue);
106 | --outline-box-shadow-contrast: 0 0 0 2px var(--navy-blue);
107 |
108 | /* Fonts */
109 | --sans-serif: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto,
110 | Ubuntu, Helvetica, sans-serif;
111 | --monospace: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
112 | monospace;
113 | }
114 |
115 | html {
116 | font-size: 100%;
117 | }
118 |
119 | body {
120 | font-family: var(--sans-serif);
121 | background: var(--gray-100);
122 | font-weight: 400;
123 | line-height: 1.75;
124 | }
125 |
126 | h1,
127 | h2,
128 | h3,
129 | h4,
130 | h5 {
131 | margin: 0;
132 | font-weight: 700;
133 | line-height: 1.3;
134 | }
135 |
136 | h1 {
137 | font-size: 3.052rem;
138 | }
139 | h2 {
140 | font-size: 2.441rem;
141 | }
142 | h3 {
143 | font-size: 1.953rem;
144 | }
145 | h4 {
146 | font-size: 1.563rem;
147 | }
148 | h5 {
149 | font-size: 1.25rem;
150 | }
151 | small,
152 | .text_small {
153 | font-size: 0.8rem;
154 | }
155 | pre,
156 | code {
157 | font-family: var(--monospace);
158 | border-radius: 6px;
159 | }
160 | pre {
161 | background: var(--gray-95);
162 | padding: 12px;
163 | line-height: 1.5;
164 | }
165 | code {
166 | background: var(--yellow);
167 | padding: 0 3px;
168 | font-size: 0.94rem;
169 | word-break: break-word;
170 | }
171 | pre code {
172 | background: none;
173 | }
174 | a {
175 | color: var(--primary-blue);
176 | }
177 |
178 | .text-with-markdown h1,
179 | .text-with-markdown h2,
180 | .text-with-markdown h3,
181 | .text-with-markdown h4,
182 | .text-with-markdown h5 {
183 | margin-block: 2rem 0.7rem;
184 | margin-inline: 0;
185 | }
186 |
187 | .text-with-markdown blockquote {
188 | font-style: italic;
189 | color: var(--gray-20);
190 | border-left: 3px solid var(--gray-80);
191 | padding-left: 10px;
192 | }
193 |
194 | hr {
195 | border: 0;
196 | height: 0;
197 | border-top: 1px solid rgba(0, 0, 0, 0.1);
198 | border-bottom: 1px solid rgba(255, 255, 255, 0.3);
199 | }
200 |
201 | /* ---------------------------------------------------------------------------*/
202 | .main {
203 | display: flex;
204 | height: 100vh;
205 | width: 100%;
206 | overflow: hidden;
207 | }
208 |
209 | .col {
210 | height: 100%;
211 | }
212 | .col:last-child {
213 | flex-grow: 1;
214 | }
215 |
216 | .logo {
217 | height: 20px;
218 | width: 22px;
219 | margin-inline-end: 10px;
220 | }
221 |
222 | .edit-button {
223 | border-radius: 100px;
224 | letter-spacing: 0.12em;
225 | text-transform: uppercase;
226 | padding-block: 6px 8px;
227 | padding-inline: 20px;
228 | cursor: pointer;
229 | font-weight: 700;
230 | outline-style: none;
231 | }
232 | .edit-button--solid {
233 | background: var(--primary-blue);
234 | color: var(--white);
235 | border: none;
236 | margin-inline-start: 6px;
237 | transition: all 0.2s ease-in-out;
238 | }
239 | .edit-button--solid:hover {
240 | background: var(--secondary-blue);
241 | }
242 | .edit-button--solid:focus {
243 | box-shadow: var(--outline-box-shadow-contrast);
244 | }
245 | .edit-button--outline {
246 | background: var(--white);
247 | color: var(--primary-blue);
248 | border: 1px solid var(--primary-blue);
249 | margin-inline-start: 12px;
250 | transition: all 0.1s ease-in-out;
251 | }
252 | .edit-button--outline:disabled {
253 | opacity: 0.5;
254 | }
255 | .edit-button--outline:hover:not([disabled]) {
256 | background: var(--primary-blue);
257 | color: var(--white);
258 | }
259 | .edit-button--outline:focus {
260 | box-shadow: var(--outline-box-shadow);
261 | }
262 |
263 | ul.notes-list {
264 | padding: 16px 0;
265 | }
266 | .notes-list > li {
267 | padding: 0 16px;
268 | }
269 | .notes-empty {
270 | padding: 16px;
271 | }
272 |
273 | .sidebar {
274 | background: var(--white);
275 | box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.1), 0px 2px 2px rgba(0, 0, 0, 0.1);
276 | overflow-y: scroll;
277 | z-index: 1000;
278 | flex-shrink: 0;
279 | max-width: 350px;
280 | min-width: 250px;
281 | width: 30%;
282 | }
283 | .sidebar-header {
284 | letter-spacing: 0.15em;
285 | text-transform: uppercase;
286 | padding-block: 36px 16px;
287 | padding-inline: 16px;
288 | display: flex;
289 | align-items: center;
290 | }
291 | .sidebar-menu {
292 | padding-inline: 16px;
293 | padding-block: 0 16px;
294 | display: flex;
295 | justify-content: space-between;
296 | }
297 | .sidebar-menu > .search {
298 | position: relative;
299 | flex-grow: 1;
300 | }
301 | .sidebar-note-list-item {
302 | position: relative;
303 | margin-bottom: 12px;
304 | padding: 16px;
305 | width: 100%;
306 | display: flex;
307 | justify-content: space-between;
308 | align-items: flex-start;
309 | flex-wrap: wrap;
310 | max-height: 100px;
311 | transition: max-height 250ms ease-out;
312 | transform: scale(1);
313 | }
314 | .sidebar-note-list-item.note-expanded {
315 | max-height: 300px;
316 | transition: max-height 0.5s ease;
317 | }
318 | .sidebar-note-list-item.flash {
319 | animation-name: flash;
320 | animation-duration: 0.6s;
321 | }
322 |
323 | .sidebar-note-open {
324 | position: absolute;
325 | top: 0;
326 | left: 0;
327 | right: 0;
328 | bottom: 0;
329 | width: 100%;
330 | z-index: 0;
331 | border: none;
332 | border-radius: 6px;
333 | text-align: start;
334 | background: var(--gray-95);
335 | cursor: pointer;
336 | outline-style: none;
337 | color: transparent;
338 | font-size: 0px;
339 | }
340 | .sidebar-note-open:focus {
341 | box-shadow: var(--outline-box-shadow);
342 | }
343 | .sidebar-note-open:hover {
344 | background: var(--gray-90);
345 | }
346 | .sidebar-note-header {
347 | z-index: 1;
348 | max-width: 85%;
349 | pointer-events: none;
350 | }
351 | .sidebar-note-header > strong {
352 | display: block;
353 | font-size: 1.25rem;
354 | line-height: 1.2;
355 | white-space: nowrap;
356 | overflow: hidden;
357 | text-overflow: ellipsis;
358 | }
359 | .sidebar-note-toggle-expand {
360 | z-index: 2;
361 | border-radius: 50%;
362 | height: 24px;
363 | border: 1px solid var(--gray-60);
364 | cursor: pointer;
365 | flex-shrink: 0;
366 | visibility: hidden;
367 | opacity: 0;
368 | cursor: default;
369 | transition: visibility 0s linear 20ms, opacity 300ms;
370 | outline-style: none;
371 | }
372 | .sidebar-note-toggle-expand:focus {
373 | box-shadow: var(--outline-box-shadow);
374 | }
375 | .sidebar-note-open:hover + .sidebar-note-toggle-expand,
376 | .sidebar-note-open:focus + .sidebar-note-toggle-expand,
377 | .sidebar-note-toggle-expand:hover,
378 | .sidebar-note-toggle-expand:focus {
379 | visibility: visible;
380 | opacity: 1;
381 | transition: visibility 0s linear 0s, opacity 300ms;
382 | }
383 | .sidebar-note-toggle-expand img {
384 | width: 10px;
385 | height: 10px;
386 | }
387 |
388 | .sidebar-note-excerpt {
389 | pointer-events: none;
390 | z-index: 2;
391 | flex: 1 1 250px;
392 | color: var(--secondary-text);
393 | position: relative;
394 | animation: slideIn 100ms;
395 | }
396 |
397 | .search input {
398 | padding: 0 16px;
399 | border-radius: 100px;
400 | border: 1px solid var(--gray-90);
401 | width: 100%;
402 | height: 100%;
403 | outline-style: none;
404 | }
405 | .search input:focus {
406 | box-shadow: var(--outline-box-shadow);
407 | }
408 | .search .spinner {
409 | position: absolute;
410 | right: 10px;
411 | top: 10px;
412 | }
413 |
414 | .note-viewer {
415 | display: flex;
416 | align-items: center;
417 | justify-content: center;
418 | }
419 | .note {
420 | background: var(--white);
421 | box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1), 0px 0px 1px rgba(0, 0, 0, 0.1);
422 | border-radius: 8px;
423 | height: 95%;
424 | width: 95%;
425 | min-width: 400px;
426 | padding: 8%;
427 | overflow-y: auto;
428 | }
429 | .note--empty-state {
430 | margin-inline: 20px 20px;
431 | }
432 | .note-text--empty-state {
433 | font-size: 1.5rem;
434 | }
435 | .note-header {
436 | display: flex;
437 | justify-content: space-between;
438 | align-items: center;
439 | flex-wrap: wrap-reverse;
440 | margin-inline-start: -12px;
441 | }
442 | .note-menu {
443 | display: flex;
444 | justify-content: space-between;
445 | align-items: center;
446 | flex-grow: 1;
447 | }
448 | .note-title {
449 | line-height: 1.3;
450 | flex-grow: 1;
451 | overflow-wrap: break-word;
452 | margin-inline-start: 12px;
453 | }
454 | .note-updated-at {
455 | color: var(--secondary-text);
456 | white-space: nowrap;
457 | margin-inline-start: 12px;
458 | }
459 | .note-preview {
460 | margin-block-start: 50px;
461 | }
462 |
463 | .note-editor {
464 | background: var(--white);
465 | display: flex;
466 | height: 100%;
467 | width: 100%;
468 | padding: 58px;
469 | overflow-y: auto;
470 | }
471 | .note-editor .label {
472 | margin-bottom: 20px;
473 | }
474 | .note-editor-form {
475 | display: flex;
476 | flex-direction: column;
477 | width: 400px;
478 | flex-shrink: 0;
479 | position: sticky;
480 | top: 0;
481 | }
482 | .note-editor-form input,
483 | .note-editor-form textarea {
484 | background: none;
485 | border: 1px solid var(--gray-70);
486 | border-radius: 2px;
487 | font-family: var(--monospace);
488 | font-size: 0.8rem;
489 | padding: 12px;
490 | outline-style: none;
491 | }
492 | .note-editor-form input:focus,
493 | .note-editor-form textarea:focus {
494 | box-shadow: var(--outline-box-shadow);
495 | }
496 | .note-editor-form input {
497 | height: 44px;
498 | margin-bottom: 16px;
499 | }
500 | .note-editor-form textarea {
501 | height: 100%;
502 | max-width: 400px;
503 | }
504 | .note-editor-menu {
505 | display: flex;
506 | justify-content: flex-end;
507 | align-items: center;
508 | margin-bottom: 12px;
509 | }
510 | .note-editor-preview {
511 | margin-inline-start: 40px;
512 | width: 100%;
513 | }
514 | .note-editor-done,
515 | .note-editor-delete {
516 | display: flex;
517 | justify-content: space-between;
518 | align-items: center;
519 | border-radius: 100px;
520 | letter-spacing: 0.12em;
521 | text-transform: uppercase;
522 | padding-block: 6px 8px;
523 | padding-inline: 20px;
524 | cursor: pointer;
525 | font-weight: 700;
526 | margin-inline-start: 12px;
527 | outline-style: none;
528 | transition: all 0.2s ease-in-out;
529 | }
530 | .note-editor-done:disabled,
531 | .note-editor-delete:disabled {
532 | opacity: 0.5;
533 | }
534 | .note-editor-done {
535 | border: none;
536 | background: var(--primary-blue);
537 | color: var(--white);
538 | }
539 | .note-editor-done:focus {
540 | box-shadow: var(--outline-box-shadow-contrast);
541 | }
542 | .note-editor-done:hover:not([disabled]) {
543 | background: var(--secondary-blue);
544 | }
545 | .note-editor-delete {
546 | border: 1px solid var(--red-25);
547 | background: var(--white);
548 | color: var(--red-25);
549 | }
550 | .note-editor-delete:focus {
551 | box-shadow: var(--outline-box-shadow);
552 | }
553 | .note-editor-delete:hover:not([disabled]) {
554 | background: var(--red-25);
555 | color: var(--white);
556 | }
557 | /* Hack to color our svg */
558 | .note-editor-delete:hover:not([disabled]) img {
559 | filter: grayscale(1) invert(1) brightness(2);
560 | }
561 | .note-editor-done > img {
562 | width: 14px;
563 | }
564 | .note-editor-delete > img {
565 | width: 10px;
566 | }
567 | .note-editor-done > img,
568 | .note-editor-delete > img {
569 | margin-inline-end: 12px;
570 | }
571 | .note-editor-done[disabled],
572 | .note-editor-delete[disabled] {
573 | opacity: 0.5;
574 | }
575 |
576 | .label {
577 | display: inline-block;
578 | border-radius: 100px;
579 | letter-spacing: 0.05em;
580 | text-transform: uppercase;
581 | font-weight: 700;
582 | padding: 4px 14px;
583 | }
584 | .label--preview {
585 | background: rgba(38, 183, 255, 0.15);
586 | color: var(--primary-blue);
587 | }
588 |
589 | .text-with-markdown p {
590 | margin-bottom: 16px;
591 | }
592 | .text-with-markdown img {
593 | width: 100%;
594 | }
595 |
596 | /* https://codepen.io/mandelid/pen/vwKoe */
597 | .spinner {
598 | display: inline-block;
599 | transition: opacity linear 0.1s;
600 | width: 20px;
601 | height: 20px;
602 | border: 3px solid rgba(80, 80, 80, 0.5);
603 | border-radius: 50%;
604 | border-top-color: #fff;
605 | animation: spin 1s ease-in-out infinite;
606 | opacity: 0;
607 | }
608 | .spinner--active {
609 | opacity: 1;
610 | }
611 |
612 | .skeleton::after {
613 | content: 'Loading...';
614 | }
615 | .skeleton {
616 | height: 100%;
617 | background-color: #eee;
618 | background-image: linear-gradient(90deg, #eee, #f5f5f5, #eee);
619 | background-size: 200px 100%;
620 | background-repeat: no-repeat;
621 | border-radius: 4px;
622 | display: block;
623 | line-height: 1;
624 | width: 100%;
625 | animation: shimmer 1.2s ease-in-out infinite;
626 | color: transparent;
627 | }
628 | .skeleton:first-of-type {
629 | margin: 0;
630 | }
631 | .skeleton--button {
632 | border-radius: 100px;
633 | padding-block: 6px 8px;
634 | padding-inline: 20px;
635 | width: auto;
636 | }
637 | .v-stack + .v-stack {
638 | margin-block-start: 0.8em;
639 | }
640 |
641 | .offscreen {
642 | border: 0;
643 | clip: rect(0, 0, 0, 0);
644 | height: 1px;
645 | margin: -1px;
646 | overflow: hidden;
647 | padding: 0;
648 | width: 1px;
649 | position: absolute;
650 | }
651 |
652 | /* ---------------------------------------------------------------------------*/
653 | @keyframes spin {
654 | to {
655 | transform: rotate(360deg);
656 | }
657 | }
658 | @keyframes spin {
659 | to {
660 | transform: rotate(360deg);
661 | }
662 | }
663 |
664 | @keyframes shimmer {
665 | 0% {
666 | background-position: -200px 0;
667 | }
668 | 100% {
669 | background-position: calc(200px + 100%) 0;
670 | }
671 | }
672 |
673 | @keyframes slideIn {
674 | 0% {
675 | top: -10px;
676 | opacity: 0;
677 | }
678 | 100% {
679 | top: 0;
680 | opacity: 1;
681 | }
682 | }
683 |
684 | @keyframes flash {
685 | 0% {
686 | transform: scale(1);
687 | opacity: 1;
688 | }
689 | 50% {
690 | transform: scale(1.05);
691 | opacity: 0.9;
692 | }
693 | 100% {
694 | transform: scale(1);
695 | opacity: 1;
696 | }
697 | }
--------------------------------------------------------------------------------