Update Flight Fixture to "use client" instead of .client.js (#26118)

This updates the Flight fixture to support the new ESM loaders in newer
versions of Node.js.

It also uses native fetch since react-fetch is gone now. (This part
requires Node 18 to run the fixture.)

I also updated everything to use the `"use client"` convention instead
of file name based convention.

The biggest hack here is that the Webpack plugin now just writes every
`.js` file in the manifest. This needs to be more scoped. In practice,
this new convention effectively requires you to traverse the server
graph first to find the actual used files. This is enough to at least
run our own fixture though.

I didn't update the "blocks" fixture.

More details in each commit message.
This commit is contained in:
Sebastian Markbåge 2023-02-07 12:09:29 -05:00 committed by GitHub
parent 13f4ccfdba
commit f0cf832e1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 247 additions and 171 deletions

View File

@ -20,7 +20,7 @@
<script src="../../build/node_modules/react-dom/umd/react-dom.development.js"></script>
<script src="../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js"></script>
<script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack-server.browser.development.js"></script>
<script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack.development.js"></script>
<script src="../../build/node_modules/react-server-dom-webpack/umd/react-server-dom-webpack-client.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.js"></script>
<script type="text/babel">
let Suspense = React.Suspense;
@ -60,7 +60,7 @@
content: <HTML />,
};
let stream = ReactServerDOMWriter.renderToReadableStream(model);
let stream = ReactServerDOMServer.renderToReadableStream(model);
let response = new Response(stream, {
headers: {'Content-Type': 'text/html'},
});
@ -70,13 +70,13 @@
let blob = await responseToDisplay.blob();
let url = URL.createObjectURL(blob);
let data = ReactServerDOMReader.createFromFetch(
let data = ReactServerDOMClient.createFromFetch(
fetch(url)
);
// The client also supports XHR streaming.
// var xhr = new XMLHttpRequest();
// xhr.open('GET', url);
// let data = ReactServerDOMReader.createFromXHR(xhr);
// let data = ReactServerDOMClient.createFromXHR(xhr);
// xhr.send();
renderResult(data);

View File

@ -752,7 +752,14 @@ module.exports = function (webpackEnv) {
// },
// }),
// Fork Start
new ReactFlightWebpackPlugin({isServer: false}),
new ReactFlightWebpackPlugin({
isServer: false,
clientReferences: {
directory: './src',
recursive: true,
include: /\.(js|ts|jsx|tsx)$/,
},
}),
// Fork End
].filter(Boolean),
// Turn off performance processing because we utilize

View File

@ -1,10 +1,11 @@
import {
resolve,
getSource,
load as reactLoad,
getSource as getSourceImpl,
transformSource as reactTransformSource,
} from 'react-server-dom-webpack/node-loader';
export {resolve, getSource};
export {resolve};
import babel from '@babel/core';
@ -17,6 +18,23 @@ const babelOptions = {
],
};
async function babelLoad(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 {code} = await babel.transformAsync(result.source, opt);
return {source: code, format: 'module'};
}
return defaultLoad(url, context, defaultLoad);
}
export async function load(url, context, defaultLoad) {
return await reactLoad(url, context, (u, c) => {
return babelLoad(u, c, defaultLoad);
});
}
async function babelTransformSource(source, context, defaultTransformSource) {
const {format} = context;
if (format === 'module') {
@ -27,8 +45,12 @@ async function babelTransformSource(source, context, defaultTransformSource) {
return defaultTransformSource(source, context, defaultTransformSource);
}
export async function transformSource(source, context, defaultTransformSource) {
return reactTransformSource(source, context, (s, c) => {
async function transformSourceImpl(source, context, defaultTransformSource) {
return await reactTransformSource(source, context, (s, c) => {
return babelTransformSource(s, c, defaultTransformSource);
});
}
export const transformSource =
process.version < 'v16' ? transformSourceImpl : undefined;
export const getSource = process.version < 'v16' ? getSourceImpl : undefined;

View File

@ -29,7 +29,7 @@ const app = express();
// Application
app.get('/', function (req, res) {
require('./handler.server.js')(req, res);
require('./handler.js')(req, res);
});
app.get('/todos', function (req, res) {

View File

@ -6,8 +6,8 @@ const {resolve} = require('path');
const React = require('react');
module.exports = function (req, res) {
// const m = require('../src/App.server.js');
import('../src/App.server.js').then(m => {
// const m = require('../src/App.js');
import('../src/App.js').then(m => {
const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build';
readFile(
resolve(__dirname, `../${dist}/react-client-manifest.json`),

View File

@ -1,4 +1,4 @@
{
"type": "commonjs",
"main": "./cli.server.js"
"main": "./cli.js"
}

View File

@ -1,15 +1,15 @@
import * as React from 'react';
import {fetch} from 'react-fetch';
import Container from './Container.js';
import {Counter} from './Counter.client.js';
import {Counter as Counter2} from './Counter2.client.js';
import {Counter} from './Counter.js';
import {Counter as Counter2} from './Counter2.js';
import ShowMore from './ShowMore.client.js';
import ShowMore from './ShowMore.js';
export default function App() {
const todos = fetch('http://localhost:3001/todos').json();
export default async function App() {
const res = await fetch('http://localhost:3001/todos');
const todos = await res.json();
return (
<Container>
<h1>Hello, world</h1>

View File

@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import Container from './Container.js';

View File

@ -1 +0,0 @@
export * from './Counter.client.js';

View File

@ -0,0 +1,3 @@
'use client';
export * from './Counter.js';

View File

@ -1,3 +1,5 @@
'use client';
import * as React from 'react';
import Container from './Container.js';

View File

@ -41,6 +41,18 @@ type TransformSourceFunction = (
TransformSourceFunction,
) => Promise<{source: Source}>;
type LoadContext = {
conditions: Array<string>,
format: string | null | void,
importAssertions: Object,
};
type LoadFunction = (
string,
LoadContext,
LoadFunction,
) => Promise<{format: string, shortCircuit?: boolean, source: Source}>;
type Source = string | ArrayBuffer | Uint8Array;
let warnedAboutConditionsFlag = false;
@ -70,24 +82,7 @@ export async function resolve(
);
}
}
const resolved = await defaultResolve(specifier, context, defaultResolve);
if (resolved.url.endsWith('.server.js')) {
const parentURL = context.parentURL;
if (parentURL && !parentURL.endsWith('.server.js')) {
let reason;
if (specifier.endsWith('.server.js')) {
reason = `"${specifier}"`;
} else {
reason = `"${specifier}" (which expands to "${resolved.url}")`;
}
throw new Error(
`Cannot import ${reason} from "${parentURL}". ` +
'By react-server convention, .server.js files can only be imported from other .server.js files. ' +
'That way nobody accidentally sends these to the client by indirectly importing it.',
);
}
}
return resolved;
return await defaultResolve(specifier, context, defaultResolve);
}
export async function getSource(
@ -148,38 +143,12 @@ function resolveClientImport(
return stashedResolve(specifier, {conditions, parentURL}, stashedResolve);
}
async function loadClientImport(
url: string,
defaultTransformSource: TransformSourceFunction,
): Promise<{source: Source}> {
if (stashedGetSource === null) {
throw new Error(
'Expected getSource to have been called before transformSource',
);
}
// TODO: Validate that this is another module by calling getFormat.
const {source} = await stashedGetSource(
url,
{format: 'module'},
stashedGetSource,
);
return defaultTransformSource(
source,
{format: 'module', url},
defaultTransformSource,
);
}
async function parseExportNamesInto(
transformedSource: string,
body: any,
names: Array<string>,
parentURL: string,
defaultTransformSource: TransformSourceFunction,
loader: LoadFunction,
): Promise<void> {
const {body} = acorn.parse(transformedSource, {
ecmaVersion: '2019',
sourceType: 'module',
});
for (let i = 0; i < body.length; i++) {
const node = body[i];
switch (node.type) {
@ -189,11 +158,19 @@ async function parseExportNamesInto(
continue;
} else {
const {url} = await resolveClientImport(node.source.value, parentURL);
const {source} = await loadClientImport(url, defaultTransformSource);
const {source} = await loader(
url,
{format: 'module', conditions: [], importAssertions: {}},
loader,
);
if (typeof source !== 'string') {
throw new Error('Expected the transformed source to be a string.');
}
parseExportNamesInto(source, names, url, defaultTransformSource);
const {body: childBody} = acorn.parse(source, {
ecmaVersion: '2019',
sourceType: 'module',
});
await parseExportNamesInto(childBody, names, url, loader);
continue;
}
case 'ExportDefaultDeclaration':
@ -221,6 +198,102 @@ async function parseExportNamesInto(
}
}
async function transformClientModule(
source: string,
url: string,
loader: LoadFunction,
): Promise<string> {
const names: Array<string> = [];
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (source.indexOf('use client') === -1) {
return source;
}
const {body} = acorn.parse(source, {
ecmaVersion: '2019',
sourceType: 'module',
});
let useClient = false;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
break;
}
if (node.directive === 'use client') {
useClient = true;
break;
}
}
if (!useClient) {
return source;
}
await parseExportNamesInto(body, names, url, loader);
let newSrc =
"const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n";
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (name === 'default') {
newSrc += 'export default ';
newSrc += 'Object.defineProperties(function() {';
newSrc +=
'throw new Error(' +
JSON.stringify(
`Attempted to call the default export of ${url} from the server` +
`but it's on the client. It's not possible to invoke a client function from ` +
`the server, it can only be rendered as a Component or passed to props of a` +
`Client Component.`,
) +
');';
} else {
newSrc += 'export const ' + name + ' = ';
newSrc += 'Object.defineProperties(function() {';
newSrc +=
'throw new Error(' +
JSON.stringify(
`Attempted to call ${name}() from the server but ${name} is on the client. ` +
`It's not possible to invoke a client function from the server, it can ` +
`only be rendered as a Component or passed to props of a Client Component.`,
) +
');';
}
newSrc += '},{';
newSrc += 'name: { value: ' + JSON.stringify(name) + '},';
newSrc += '$$typeof: {value: CLIENT_REFERENCE},';
newSrc += 'filepath: {value: ' + JSON.stringify(url) + '}';
newSrc += '});\n';
}
return newSrc;
}
async function loadClientImport(
url: string,
defaultTransformSource: TransformSourceFunction,
): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
if (stashedGetSource === null) {
throw new Error(
'Expected getSource to have been called before transformSource',
);
}
// TODO: Validate that this is another module by calling getFormat.
const {source} = await stashedGetSource(
url,
{format: 'module'},
stashedGetSource,
);
const result = await defaultTransformSource(
source,
{format: 'module', url},
defaultTransformSource,
);
return {format: 'module', source: result.source};
}
export async function transformSource(
source: Source,
context: TransformSourceContext,
@ -231,57 +304,35 @@ export async function transformSource(
context,
defaultTransformSource,
);
if (context.format === 'module' && context.url.endsWith('.client.js')) {
if (context.format === 'module') {
const transformedSource = transformed.source;
if (typeof transformedSource !== 'string') {
throw new Error('Expected source to have been transformed to a string.');
}
const names: Array<string> = [];
await parseExportNamesInto(
const newSrc = await transformClientModule(
transformedSource,
names,
context.url,
defaultTransformSource,
(url: string, ctx: LoadContext, defaultLoad: LoadFunction) => {
return loadClientImport(url, defaultTransformSource);
},
);
let newSrc =
"const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n";
for (let i = 0; i < names.length; i++) {
const name = names[i];
if (name === 'default') {
newSrc += 'export default ';
newSrc += 'Object.defineProperties(function() {';
newSrc +=
'throw new Error(' +
JSON.stringify(
`Attempted to call the default export of ${context.url} from the server` +
`but it's on the client. It's not possible to invoke a client function from ` +
`the server, it can only be rendered as a Component or passed to props of a` +
`Client Component.`,
) +
');';
} else {
newSrc += 'export const ' + name + ' = ';
newSrc += 'export default ';
newSrc += 'Object.defineProperties(function() {';
newSrc +=
'throw new Error(' +
JSON.stringify(
`Attempted to call ${name}() from the server but ${name} is on the client. ` +
`It's not possible to invoke a client function from the server, it can ` +
`only be rendered as a Component or passed to props of a Client Component.`,
) +
');';
}
newSrc += '},{';
newSrc += 'name: { value: ' + JSON.stringify(name) + '},';
newSrc += '$$typeof: {value: CLIENT_REFERENCE},';
newSrc += 'filepath: {value: ' + JSON.stringify(context.url) + '}';
newSrc += '});\n';
}
return {source: newSrc};
}
return transformed;
}
export async function load(
url: string,
context: LoadContext,
defaultLoad: LoadFunction,
): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
if (context.format === 'module') {
const result = await defaultLoad(url, context, defaultLoad);
if (typeof result.source !== 'string') {
throw new Error('Expected source to have been loaded into a string.');
}
const newSrc = await transformClientModule(result.source, url, defaultLoad);
return {format: 'module', source: newSrc};
}
return defaultLoad(url, context, defaultLoad);
}

View File

@ -7,6 +7,8 @@
* @flow
*/
const acorn = require('acorn');
const url = require('url');
const Module = require('module');
@ -204,8 +206,42 @@ module.exports = function register() {
};
// $FlowFixMe[prop-missing] found when upgrading Flow
Module._extensions['.client.js'] = function (module, path) {
const moduleId: string = (url.pathToFileURL(path).href: any);
const originalCompile = Module.prototype._compile;
// $FlowFixMe[prop-missing] found when upgrading Flow
Module.prototype._compile = function (
this: any,
content: string,
filename: string,
): void {
// Do a quick check for the exact string. If it doesn't exist, don't
// bother parsing.
if (content.indexOf('use client') === -1) {
return originalCompile.apply(this, arguments);
}
const {body} = acorn.parse(content, {
ecmaVersion: '2019',
sourceType: 'source',
});
let useClient = false;
for (let i = 0; i < body.length; i++) {
const node = body[i];
if (node.type !== 'ExpressionStatement' || !node.directive) {
break;
}
if (node.directive === 'use client') {
useClient = true;
break;
}
}
if (!useClient) {
return originalCompile.apply(this, arguments);
}
const moduleId: string = (url.pathToFileURL(filename).href: any);
const clientReference = Object.defineProperties(({}: any), {
// Represents the whole Module object instead of a particular import.
name: {value: '*'},
@ -214,35 +250,6 @@ module.exports = function register() {
async: {value: false},
});
// $FlowFixMe[incompatible-call] found when upgrading Flow
module.exports = new Proxy(clientReference, proxyHandlers);
};
// $FlowFixMe[prop-missing] found when upgrading Flow
const originalResolveFilename = Module._resolveFilename;
// $FlowFixMe[prop-missing] found when upgrading Flow
// $FlowFixMe[missing-this-annot]
Module._resolveFilename = function (request, parent, isMain, options) {
const resolved = originalResolveFilename.apply(this, arguments);
if (resolved.endsWith('.server.js')) {
if (
parent &&
parent.filename &&
!parent.filename.endsWith('.server.js')
) {
let reason;
if (request.endsWith('.server.js')) {
reason = `"${request}"`;
} else {
reason = `"${request}" (which expands to "${resolved}")`;
}
throw new Error(
`Cannot import ${reason} from "${parent.filename}". ` +
'By react-server convention, .server.js files can only be imported from other .server.js files. ' +
'That way nobody accidentally sends these to the client by indirectly importing it.',
);
}
}
return resolved;
this.exports = new Proxy(clientReference, proxyHandlers);
};
};

View File

@ -79,7 +79,7 @@ export default class ReactFlightWebpackPlugin {
{
directory: '.',
recursive: true,
include: /\.client\.(js|ts|jsx|tsx)$/,
include: /\.(js|ts|jsx|tsx)$/,
},
];
} else if (
@ -231,7 +231,7 @@ export default class ReactFlightWebpackPlugin {
// That way we know by the type of dep whether to include.
// It also resolves conflicts when the same module is in multiple chunks.
if (!/\.client\.(js|ts)x?$/.test(module.resource)) {
if (!/\.(js|ts)x?$/.test(module.resource)) {
return;
}

View File

@ -21,21 +21,21 @@ global.__webpack_require__ = function (id) {
return webpackModules[id];
};
const previousLoader = Module._extensions['.client.js'];
const previousCompile = Module.prototype._compile;
const register = require('react-server-dom-webpack/node-register');
// Register node loader
// Register node compile
register();
const nodeLoader = Module._extensions['.client.js'];
const nodeCompile = Module.prototype._compile;
if (previousLoader === nodeLoader) {
if (previousCompile === nodeCompile) {
throw new Error(
'Expected the Node loader to register the .client.js extension',
'Expected the Node loader to register the _compile extension',
);
}
Module._extensions['.client.js'] = previousLoader;
Module.prototype._compile = previousCompile;
exports.webpackMap = webpackMap;
exports.webpackModules = webpackModules;
@ -57,7 +57,7 @@ exports.clientModuleError = function clientModuleError(moduleError) {
},
};
const mod = {exports: {}};
nodeLoader(mod, idx);
nodeCompile.call(mod, '"use client"', idx);
return mod.exports;
};
@ -99,6 +99,6 @@ exports.clientExports = function clientExports(moduleExports) {
};
}
const mod = {exports: {}};
nodeLoader(mod, idx);
nodeCompile.call(mod, '"use client"', idx);
return mod.exports;
};

View File

@ -950,7 +950,7 @@ deepFreeze(bundles);
deepFreeze(bundleTypes);
deepFreeze(moduleTypes);
function getOriginalFilename(bundle, bundleType) {
function getFilename(bundle, bundleType) {
let name = bundle.name || bundle.entry;
const globalName = bundle.global;
// we do this to replace / to -, for react-dom/server
@ -993,23 +993,6 @@ function getOriginalFilename(bundle, bundleType) {
}
}
function getFilename(bundle, bundleType) {
const originalFilename = getOriginalFilename(bundle, bundleType);
// Ensure .server.js or .client.js is the final suffix.
// This is important for the Server tooling convention.
if (originalFilename.indexOf('.server.') !== -1) {
return originalFilename
.replace('.server.', '.')
.replace('.js', '.server.js');
}
if (originalFilename.indexOf('.client.') !== -1) {
return originalFilename
.replace('.client.', '.')
.replace('.js', '.client.js');
}
return originalFilename;
}
module.exports = {
bundleTypes,
moduleTypes,