Skip to content

Commit

Permalink
[Flight Fixture] Show SSR Support with CSS (#26263)
Browse files Browse the repository at this point in the history
Builds on #26257.

To do this we need access to a manifest for which scripts and CSS are
used for each "page" (entrypoint).

The initial script to bootstrap the app is inserted with
`bootstrapScripts`. Subsequent content are loaded using the chunks
mechanism built-in.

The stylesheets for each pages are prepended to each RSC payload and
rendered using Float. This doesn't yet support styles imported in
components that are also SSR:ed nor imported through Server Components.
That's more complex and not implemented in the node loader.

HMR doesn't work after reloads right now because the SSR renderer isn't
hot reloaded because there's no idiomatic way to hot reload ESM modules
in Node.js yet. Without killing the HMR server. This leads to hydration
mismatches when reloading the page after a hot reload.

Notably this doesn't show serializing the stream through the HTML like
real implementations do. This will lead to possible hydration mismatches
based on the data. However, manually serializing the stream as a string
isn't exactly correct due to binary data. It's not the idiomatic way
this is supposed to work. This will all be built-in which will make this
automatic in the future.
  • Loading branch information
sebmarkbage committed Mar 1, 2023
1 parent 40755c0 commit 67a61d5
Show file tree
Hide file tree
Showing 15 changed files with 312 additions and 183 deletions.
1 change: 0 additions & 1 deletion fixtures/flight/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

# production
/build
/dist
.eslintcache

# misc
Expand Down
1 change: 0 additions & 1 deletion fixtures/flight/config/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ module.exports = {
appPath: resolveApp('.'),
appBuild: resolveApp(buildPath),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
Expand Down
85 changes: 36 additions & 49 deletions fixtures/flight/config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,10 @@ const {createHash} = require('crypto');
const path = require('path');
const webpack = require('webpack');
const resolve = require('resolve');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const ESLintPlugin = require('eslint-webpack-plugin');
Expand All @@ -28,6 +25,7 @@ const ForkTsCheckerWebpackPlugin =
? require('react-dev-utils/ForkTsCheckerWarningWebpackPlugin')
: require('react-dev-utils/ForkTsCheckerWebpackPlugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const {WebpackManifestPlugin} = require('webpack-manifest-plugin');

function createEnvironmentHash(env) {
const hash = createHash('md5');
Expand Down Expand Up @@ -116,7 +114,7 @@ module.exports = function (webpackEnv) {
const getStyleLoaders = (cssOptions, preProcessor) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
{
loader: MiniCssExtractPlugin.loader,
// css is located in `static/css`, use '../../' to locate index.html folder
// in production `paths.publicUrlOrPath` can be a relative path
Expand Down Expand Up @@ -578,44 +576,6 @@ module.exports = function (webpackEnv) {
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// It will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
Expand All @@ -636,13 +596,40 @@ module.exports = function (webpackEnv) {
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css',
}),
// Generate a manifest containing the required script / css for each entry.
new WebpackManifestPlugin({
fileName: 'entrypoint-manifest.json',
publicPath: paths.publicUrlOrPath,
generate: (seed, files, entrypoints) => {
const entrypointFiles = entrypoints.main.filter(
fileName => !fileName.endsWith('.map')
);

const processedEntrypoints = {};
for (let key in entrypoints) {
processedEntrypoints[key] = {
js: entrypoints[key].filter(
filename =>
// Include JS assets but ignore hot updates because they're not
// safe to include as async script tags.
filename.endsWith('.js') &&
!filename.endsWith('.hot-update.js')
),
css: entrypoints[key].filter(filename =>
filename.endsWith('.css')
),
};
}

return processedEntrypoints;
},
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
Expand Down
51 changes: 51 additions & 0 deletions fixtures/flight/loader/global.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import babel from '@babel/core';

const babelOptions = {
babelrc: false,
ignore: [/\/(build|node_modules)\//],
plugins: [
'@babel/plugin-syntax-import-meta',
'@babel/plugin-transform-react-jsx',
],
};

export async function load(url, context, defaultLoad) {
const {format} = context;
const result = await defaultLoad(url, context, defaultLoad);
if (result.format === 'module') {
const opt = Object.assign({filename: url}, babelOptions);
const newResult = await babel.transformAsync(result.source, opt);
if (!newResult) {
if (typeof result.source === 'string') {
return result;
}
return {
source: Buffer.from(result.source).toString('utf8'),
format: 'module',
};
}
return {source: newResult.code, format: 'module'};
}
return defaultLoad(url, context, defaultLoad);
}

async function babelTransformSource(source, context, defaultTransformSource) {
const {format} = context;
if (format === 'module') {
const opt = Object.assign({filename: context.url}, babelOptions);
const newResult = await babel.transformAsync(source, opt);
if (!newResult) {
if (typeof source === 'string') {
return {source};
}
return {
source: Buffer.from(source).toString('utf8'),
};
}
return {source: newResult.code};
}
return defaultTransformSource(source, context, defaultTransformSource);
}

export const transformSource =
process.version < 'v16' ? babelTransformSource : undefined;
File renamed without changes.
15 changes: 8 additions & 7 deletions fixtures/flight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
"postcss-normalize": "^10.0.1",
"postcss-preset-env": "^7.0.1",
"prompts": "^2.4.2",
"react": "^18.2.0",
"react": "experimental",
"react-app-polyfill": "^3.0.0",
"react-dev-utils": "^12.0.1",
"react-dom": "^18.2.0",
"react-dom": "experimental",
"react-refresh": "^0.11.0",
"resolve": "^1.20.0",
"resolve-url-loader": "^4.0.0",
Expand All @@ -59,17 +59,18 @@
"undici": "^5.20.0",
"webpack": "^5.64.4",
"webpack-dev-middleware": "^5.3.1",
"webpack-hot-middleware": "^2.25.3"
"webpack-hot-middleware": "^2.25.3",
"webpack-manifest-plugin": "^4.0.2"
},
"scripts": {
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
"dev:global": "NODE_ENV=development BUILD_PATH=dist node server/global",
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/index.js --conditions=react-server server/region",
"dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global",
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region",
"start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"",
"start:global": "NODE_ENV=production node server/global",
"start:region": "NODE_ENV=production node --experimental-loader ./loader/index.js --conditions=react-server server/region",
"start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global",
"start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom"
},
Expand Down
11 changes: 0 additions & 11 deletions fixtures/flight/public/index.html

This file was deleted.

3 changes: 1 addition & 2 deletions fixtures/flight/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;

// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
if (!checkRequiredFiles([paths.appIndexJs])) {
process.exit(1);
}

Expand Down Expand Up @@ -204,6 +204,5 @@ function build(previousFileSizes) {
function copyPublicFolder() {
fs.copySync('public', 'build', {
dereference: true,
filter: file => file !== paths.appHtml,
});
}
Loading

0 comments on commit 67a61d5

Please sign in to comment.