Update base for Update on "compiler: super early exploration of instruction reordering"

See comments in InstructionReordering.ts. This needs substantial iteration before landing in some form, just putting up to share for discussion.

[ghstack-poisoned]
This commit is contained in:
Joe Savona
2024-06-07 10:28:37 -07:00
166 changed files with 6298 additions and 1610 deletions
+10
View File
@@ -507,6 +507,10 @@ workflows:
- "-r=www-modern --env=production --variant=false"
- "-r=www-modern --env=development --variant=true"
- "-r=www-modern --env=production --variant=true"
- "-r=xplat --env=development --variant=false"
- "-r=xplat --env=development --variant=true"
- "-r=xplat --env=production --variant=false"
- "-r=xplat --env=production --variant=true"
# TODO: Test more persistent configurations?
- '-r=stable --env=development --persistent'
@@ -552,6 +556,12 @@ workflows:
# - "-r=www-modern --env=development --variant=true"
# - "-r=www-modern --env=production --variant=true"
# TODO: Update test config to support xplat build tests
# - "-r=xplat --env=development --variant=false"
# - "-r=xplat --env=development --variant=true"
# - "-r=xplat --env=production --variant=false"
# - "-r=xplat --env=production --variant=true"
# TODO: Test more persistent configurations?
- download_base_build_for_sizebot:
filters:
+1
View File
@@ -486,6 +486,7 @@ module.exports = {
$ReadOnlyArray: 'readonly',
$ArrayBufferView: 'readonly',
$Shape: 'readonly',
CallSite: 'readonly',
ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be.
ReturnType: 'readonly',
AnimationFrameID: 'readonly',
+1
View File
@@ -1,3 +1,4 @@
blank_issues_enabled: false
contact_links:
- name: 📃 Documentation Issue
url: https://github.com/reactjs/react.dev/issues/new/choose
+128 -8
View File
@@ -10,7 +10,36 @@ jobs:
outputs:
www_branch_count: ${{ steps.check_branches.outputs.www_branch_count }}
fbsource_branch_count: ${{ steps.check_branches.outputs.fbsource_branch_count }}
last_version_classic: ${{ steps.get_last_version_www.outputs.last_version_classic }}
last_version_modern: ${{ steps.get_last_version_www.outputs.last_version_modern }}
last_version_rn: ${{ steps.get_last_version_rn.outputs.last_version_rn }}
current_version_classic: ${{ steps.get_current_version.outputs.current_version_classic }}
current_version_modern: ${{ steps.get_current_version.outputs.current_version_modern }}
current_version_rn: ${{ steps.get_current_version.outputs.current_version_rn }}
steps:
- uses: actions/checkout@v4
with:
ref: builds/facebook-www
- name: "Get last version string for www"
id: get_last_version_www
run: |
# Empty checks only needed for backwards compatibility,can remove later.
VERSION_CLASSIC=$( [ -f ./compiled/facebook-www/VERSION_CLASSIC ] && cat ./compiled/facebook-www/VERSION_CLASSIC || echo '' )
VERSION_MODERN=$( [ -f ./compiled/facebook-www/VERSION_MODERN ] && cat ./compiled/facebook-www/VERSION_MODERN || echo '' )
echo "Last classic version is $VERSION_CLASSIC"
echo "Last modern version is $VERSION_MODERN"
echo "last_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT"
echo "last_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: builds/facebook-fbsource
- name: "Get last version string for rn"
id: get_last_version_rn
run: |
# Empty checks only needed for backwards compatibility,can remove later.
VERSION_NATIVE_FB=$( [ -f ./compiled-rn/VERSION_NATIVE_FB ] && cat ./compiled-rn/VERSION_NATIVE_FB || echo '' )
echo "Last rn version is $VERSION_NATIVE_FB"
echo "last_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
- name: "Check branches"
id: check_branches
@@ -147,7 +176,7 @@ jobs:
mkdir -p ${BASE_FOLDER}/react-native-github/Libraries/Renderer/
mkdir -p ${BASE_FOLDER}/RKJSModules/vendor/react/{scheduler,react,react-is,react-test-renderer}/
# Move React Native renderer
# Move React Native renderer
mv build/react-native/implementations/ $BASE_FOLDER/react-native-github/Libraries/Renderer/
mv build/react-native/shims/ $BASE_FOLDER/react-native-github/Libraries/Renderer/
mv build/facebook-react-native/scheduler/cjs/ $BASE_FOLDER/RKJSModules/vendor/react/scheduler/
@@ -160,11 +189,27 @@ jobs:
rm $RENDERER_FOLDER/ReactFabric-{dev,prod,profiling}.js
rm $RENDERER_FOLDER/ReactNativeRenderer-{dev,prod,profiling}.js
ls -R ./compiled
- name: Add REVISION file
# Move React Native version file
mv build/facebook-react-native/VERSION_NATIVE_FB ./compiled-rn/VERSION_NATIVE_FB
ls -R ./compiled-rn
- name: Add REVISION files
run: |
echo ${{ github.sha }} >> ./compiled/facebook-www/REVISION
cp ./compiled/facebook-www/REVISION ./compiled/facebook-www/REVISION_TRANSFORMS
echo ${{ github.sha }} >> ./compiled-rn/facebook-fbsource/xplat/js/react-native-github/Libraries/Renderer/REVISION
- name: "Get current version string"
id: get_current_version
run: |
VERSION_CLASSIC=$(cat ./compiled/facebook-www/VERSION_CLASSIC)
VERSION_MODERN=$(cat ./compiled/facebook-www/VERSION_MODERN)
VERSION_NATIVE_FB=$(cat ./compiled-rn/VERSION_NATIVE_FB)
echo "Current classic version is $VERSION_CLASSIC"
echo "Current modern version is $VERSION_MODERN"
echo "Current rn version is $VERSION_NATIVE_FB"
echo "current_version_classic=$VERSION_CLASSIC" >> "$GITHUB_OUTPUT"
echo "current_version_modern=$VERSION_MODERN" >> "$GITHUB_OUTPUT"
echo "current_version_rn=$VERSION_NATIVE_FB" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v3
with:
name: compiled
@@ -188,15 +233,58 @@ jobs:
with:
name: compiled
path: compiled/
- run: git status -u
- name: Revert version changes
if: needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != ''
env:
CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }}
CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }}
LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }}
LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }}
run: |
echo "Reverting $CURRENT_VERSION_CLASSIC to $LAST_VERSION_CLASSIC"
grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "No files found with $CURRENT_VERSION_CLASSIC"
grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_CLASSIC/$LAST_VERSION_CLASSIC/g"
grep -rl "$CURRENT_VERSION_CLASSIC" ./compiled || echo "Classic version reverted"
echo "===================="
echo "Reverting $CURRENT_VERSION_MODERN to $LAST_VERSION_MODERN"
grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "No files found with $CURRENT_VERSION_MODERN"
grep -rl "$CURRENT_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$CURRENT_VERSION_MODERN/$LAST_VERSION_MODERN/g"
grep -rl "$CURRENT_VERSION_MODERN" ./compiled || echo "Modern version reverted"
- name: Check if only the REVISION file has changed
id: check_should_commit
run: |
if git status --porcelain | grep -qv '/REVISION$'; then
echo "Full git status"
git status
echo "===================="
if git status --porcelain | grep -qv '/REVISION'; then
echo "Changes detected"
echo "should_commit=true" >> "$GITHUB_OUTPUT"
else
echo "No Changes detected"
echo "should_commit=false" >> "$GITHUB_OUTPUT"
fi
- name: Re-apply version changes
if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_classic != '' && needs.download_artifacts.outputs.last_version_modern != ''
env:
CURRENT_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.current_version_classic }}
CURRENT_VERSION_MODERN: ${{ needs.download_artifacts.outputs.current_version_modern }}
LAST_VERSION_CLASSIC: ${{ needs.download_artifacts.outputs.last_version_classic }}
LAST_VERSION_MODERN: ${{ needs.download_artifacts.outputs.last_version_modern }}
run: |
echo "Re-applying $LAST_VERSION_CLASSIC to $CURRENT_VERSION_CLASSIC"
grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "No files found with $LAST_VERSION_CLASSIC"
grep -rl "$LAST_VERSION_CLASSIC" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_CLASSIC/$CURRENT_VERSION_CLASSIC/g"
grep -rl "$LAST_VERSION_CLASSIC" ./compiled || echo "Classic version re-applied"
echo "===================="
echo "Re-applying $LAST_VERSION_MODERN to $CURRENT_VERSION_MODERN"
grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "No files found with $LAST_VERSION_MODERN"
grep -rl "$LAST_VERSION_MODERN" ./compiled | xargs -r sed -i -e "s/$LAST_VERSION_MODERN/$CURRENT_VERSION_MODERN/g"
grep -rl "$LAST_VERSION_MODERN" ./compiled || echo "Classic version re-applied"
- name: Will commit these changes
if: steps.check_should_commit.outputs.should_commit == 'true'
run: |
echo ":"
git status -u
- name: Commit changes to branch
if: steps.check_should_commit.outputs.should_commit == 'true'
uses: stefanzweifel/git-auto-commit-action@v4
@@ -219,20 +307,52 @@ jobs:
with:
ref: builds/facebook-fbsource
- name: Ensure clean directory
run: rm -rf compiled
run: rm -rf compiled-rn
- uses: actions/download-artifact@v3
with:
name: compiled-rn
path: compiled-rn/
- run: git status -u
- name: Revert version changes
if: needs.download_artifacts.outputs.last_version_rn != ''
env:
CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }}
LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }}
run: |
echo "Reverting $CURRENT_VERSION to $LAST_VERSION"
grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "No files found with $CURRENT_VERSION"
grep -rl "$CURRENT_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$CURRENT_VERSION/$LAST_VERSION/g"
grep -rl "$CURRENT_VERSION" ./compiled-rn || echo "Version reverted"
- name: Check if only the REVISION file has changed
id: check_should_commit
run: |
if git status --porcelain | grep -qv '/REVISION$'; then
echo "Full git status"
git status
echo "===================="
echo "Checking for changes"
# Check if there are changes in the files other than REVISION or @generated headers
# We also filter out the file name lines with "---" and "+++".
if git diff -- . ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|@generated SignedSource)" | grep "^[+-]" > /dev/null; then
echo "Changes detected"
echo "should_commit=true" >> "$GITHUB_OUTPUT"
else
echo "No Changes detected"
echo "should_commit=false" >> "$GITHUB_OUTPUT"
fi
- name: Re-apply version changes
if: steps.check_should_commit.outputs.should_commit == 'true' && needs.download_artifacts.outputs.last_version_rn != ''
env:
CURRENT_VERSION: ${{ needs.download_artifacts.outputs.current_version_rn }}
LAST_VERSION: ${{ needs.download_artifacts.outputs.last_version_rn }}
run: |
echo "Re-applying $LAST_VERSION to $CURRENT_VERSION"
grep -rl "$LAST_VERSION" ./compiled-rn || echo "No files found with $LAST_VERSION"
grep -rl "$LAST_VERSION" ./compiled-rn | xargs -r sed -i -e "s/$LAST_VERSION/$CURRENT_VERSION/g"
grep -rl "$LAST_VERSION" ./compiled-rn || echo "Version re-applied"
- name: Will commit these changes
if: steps.check_should_commit.outputs.should_commit == 'true'
run: |
echo ":"
git status -u
- name: Commit changes to branch
if: steps.check_should_commit.outputs.should_commit == 'true'
uses: stefanzweifel/git-auto-commit-action@v4
+5
View File
@@ -28,6 +28,10 @@ const ReactVersion = '19.0.0';
// npm dist tags used during publish, refer to .circleci/config.yml.
const canaryChannelLabel = 'rc';
// If the canaryChannelLabel is "rc", the build pipeline will use this to build
// an RC version of the packages.
const rcNumber = 0;
const stablePackages = {
'eslint-plugin-react-hooks': '5.1.0',
'jest-react': '0.16.0',
@@ -53,6 +57,7 @@ const experimentalPackages = [];
module.exports = {
ReactVersion,
canaryChannelLabel,
rcNumber,
stablePackages,
experimentalPackages,
};
@@ -1,6 +1,6 @@
{
"name": "babel-plugin-react-compiler",
"version": "0.0.0-experimental-487cb0e-20240529",
"version": "0.0.0-experimental-938cd9a-20240601",
"description": "Babel plugin for React Compiler.",
"main": "dist/index.js",
"license": "MIT",
@@ -30,10 +30,16 @@ export default function BabelPluginReactCompiler(
*/
Program(prog, pass): void {
let opts = parsePluginOptions(pass.opts);
if (pipelineUsesReanimatedPlugin(pass.file.opts.plugins)) {
const isDev =
(typeof __DEV__ !== "undefined" && __DEV__ === true) ||
process.env["NODE_ENV"] === "development";
if (
opts.enableReanimatedCheck === true &&
pipelineUsesReanimatedPlugin(pass.file.opts.plugins)
) {
opts = injectReanimatedFlag(opts);
}
if (process.env["NODE_ENV"] === "development") {
if (isDev) {
opts = {
...opts,
environment: {
@@ -111,6 +111,12 @@ export type PluginOptions = {
ignoreUseNoForget: boolean;
sources?: Array<string> | ((filename: string) => boolean) | null;
/**
* The compiler has customized support for react-native-reanimated, intended as a temporary workaround.
* Set this flag (on by default) to automatically check for this library and activate the support.
*/
enableReanimatedCheck: boolean;
};
const CompilationModeSchema = z.enum([
@@ -188,6 +194,7 @@ export const defaultOptions: PluginOptions = {
sources: (filename) => {
return filename.indexOf("node_modules") === -1;
},
enableReanimatedCheck: true,
} as const;
export function parsePluginOptions(obj: unknown): PluginOptions {
@@ -91,6 +91,7 @@ import {
validatePreservedManualMemoization,
validateUseMemo,
} from "../Validation";
import pruneInitializationDependencies from "../ReactiveScopes/PruneInitializationDependencies";
export type CompilerPipelineValue =
| { kind: "ast"; name: string; value: CodegenFunction }
@@ -147,8 +148,14 @@ function* runWithEnvironment(
validateContextVariableLValues(hir);
validateUseMemo(hir);
dropManualMemoization(hir);
yield log({ kind: "hir", name: "DropManualMemoization", value: hir });
if (
!env.config.enablePreserveExistingManualUseMemo &&
!env.config.disableMemoizationForDebugging &&
!env.config.enableChangeDetectionForDebugging
) {
dropManualMemoization(hir);
yield log({ kind: "hir", name: "DropManualMemoization", value: hir });
}
inlineImmediatelyInvokedFunctionExpressions(hir);
yield log({
@@ -373,6 +380,15 @@ function* runWithEnvironment(
value: reactiveFunction,
});
if (env.config.enableChangeDetectionForDebugging != null) {
pruneInitializationDependencies(reactiveFunction);
yield log({
kind: "reactive",
name: "PruneInitializationDependencies",
value: reactiveFunction,
});
}
propagateEarlyReturns(reactiveFunction);
yield log({
kind: "reactive",
@@ -421,6 +421,13 @@ export function compileProgram(
);
externalFunctions.push(enableEmitHookGuards);
}
if (options.environment?.enableChangeDetectionForDebugging != null) {
const enableChangeDetectionForDebugging = tryParseExternalFunction(
options.environment.enableChangeDetectionForDebugging
);
externalFunctions.push(enableChangeDetectionForDebugging);
}
} catch (err) {
handleError(err, pass, null);
return;
@@ -124,7 +124,7 @@ export function lower(
) {
const place: Place = {
kind: "Identifier",
identifier: builder.makeTemporary(),
identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource),
effect: Effect.Unknown,
reactive: false,
loc: param.node.loc ?? GeneratedSource,
@@ -141,7 +141,7 @@ export function lower(
} else if (param.isRestElement()) {
const place: Place = {
kind: "Identifier",
identifier: builder.makeTemporary(),
identifier: builder.makeTemporary(param.node.loc ?? GeneratedSource),
effect: Effect.Unknown,
reactive: false,
loc: param.node.loc ?? GeneratedSource,
@@ -1256,7 +1256,9 @@ function lowerStatement(
if (hasNode(handlerBindingPath)) {
const place: Place = {
kind: "Identifier",
identifier: builder.makeTemporary(),
identifier: builder.makeTemporary(
handlerBindingPath.node.loc ?? GeneratedSource
),
effect: Effect.Unknown,
reactive: false,
loc: handlerBindingPath.node.loc ?? GeneratedSource,
@@ -3301,7 +3303,7 @@ function lowerIdentifier(
function buildTemporaryPlace(builder: HIRBuilder, loc: SourceLocation): Place {
const place: Place = {
kind: "Identifier",
identifier: builder.makeTemporary(),
identifier: builder.makeTemporary(loc),
effect: Effect.Unknown,
reactive: false,
loc,
@@ -165,6 +165,13 @@ const EnvironmentConfigSchema = z.object({
*/
validatePreserveExistingMemoizationGuarantees: z.boolean().default(true),
/**
* When this is true, rather than pruning existing manual memoization but ensuring or validating
* that the memoized values remain memoized, the compiler will simply not prune existing calls to
* useMemo/useCallback.
*/
enablePreserveExistingManualUseMemo: z.boolean().default(false),
// 🌲
enableForest: z.boolean().default(false),
@@ -343,6 +350,23 @@ const EnvironmentConfigSchema = z.object({
*/
enableTreatFunctionDepsAsConditional: z.boolean().default(false),
/**
* When true, always act as though the dependencies of a memoized value
* have changed. This makes the compiler not actually perform any optimizations,
* but is useful for debugging. Implicitly also sets
* @enablePreserveExistingManualUseMemo, because otherwise memoization in the
* original source will be disabled as well.
*/
disableMemoizationForDebugging: z.boolean().default(false),
/**
* When true, rather using memoized values, the compiler will always re-compute
* values, and then use a heuristic to compare the memoized value to the newly
* computed one. This detects cases where rules of react violations may cause the
* compiled code to behave differently than the original.
*/
enableChangeDetectionForDebugging: ExternalFunctionSchema.nullish(),
/**
* The react native re-animated library uses custom Babel transforms that
* requires the calls to library API remain unmodified.
@@ -462,6 +486,18 @@ export class Environment {
this.#shapes = new Map(DEFAULT_SHAPES);
this.#globals = new Map(DEFAULT_GLOBALS);
if (
config.disableMemoizationForDebugging &&
config.enableChangeDetectionForDebugging != null
) {
CompilerError.throwInvalidConfig({
reason: `Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together`,
description: null,
loc: null,
suggestions: null,
});
}
for (const [hookName, hook] of this.config.customHooks) {
CompilerError.invariant(!this.#globals.has(hookName), {
reason: `[Globals] Found existing definition in global registry for custom hook ${hookName}`,
@@ -13,6 +13,7 @@ import {
BuiltInUseInsertionEffectHookId,
BuiltInUseLayoutEffectHookId,
BuiltInUseOperatorId,
BuiltInUseReducerId,
BuiltInUseRefId,
BuiltInUseStateId,
ShapeRegistry,
@@ -265,6 +266,18 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnValueReason: ValueReason.State,
}),
],
[
"useReducer",
addHook(DEFAULT_SHAPES, {
positionalParams: [],
restParam: Effect.Freeze,
returnType: { kind: "Object", shapeId: BuiltInUseReducerId },
calleeEffect: Effect.Read,
hookKind: "useReducer",
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.ReducerState,
}),
],
[
"useRef",
addHook(DEFAULT_SHAPES, {
@@ -1144,6 +1144,7 @@ export type Identifier = {
*/
scope: ReactiveScope | null;
type: Type;
loc: SourceLocation;
};
export type IdentifierName = ValidatedIdentifier | PromotedIdentifier;
@@ -1253,6 +1254,11 @@ export enum ValueReason {
*/
State = "state",
/**
* A value returned from `useReducer`
*/
ReducerState = "reducer-state",
/**
* Props of a component or arguments of a hook.
*/
@@ -1376,6 +1382,8 @@ export type ReactiveScope = {
* no longer exist due to being pruned.
*/
merged: Set<ScopeId>;
loc: SourceLocation;
};
export type ReactiveScopeDependencies = Set<ReactiveScopeDependency>;
@@ -1490,6 +1498,14 @@ export function isSetStateType(id: Identifier): boolean {
return id.type.kind === "Function" && id.type.shapeId === "BuiltInSetState";
}
export function isUseReducerType(id: Identifier): boolean {
return id.type.kind === "Function" && id.type.shapeId === "BuiltInUseReducer";
}
export function isDispatcherType(id: Identifier): boolean {
return id.type.kind === "Function" && id.type.shapeId === "BuiltInDispatch";
}
export function isUseEffectHookType(id: Identifier): boolean {
return (
id.type.kind === "Function" && id.type.shapeId === "BuiltInUseEffectHook"
@@ -21,6 +21,7 @@ import {
IdentifierId,
Instruction,
Place,
SourceLocation,
Terminal,
VariableBinding,
makeBlockId,
@@ -174,7 +175,7 @@ export default class HIRBuilder {
return handler ?? null;
}
makeTemporary(): Identifier {
makeTemporary(loc: SourceLocation): Identifier {
const id = this.nextIdentifierId;
return {
id,
@@ -182,6 +183,7 @@ export default class HIRBuilder {
mutableRange: { start: makeInstructionId(0), end: makeInstructionId(0) },
scope: null,
type: makeType(),
loc,
};
}
@@ -320,6 +322,7 @@ export default class HIRBuilder {
},
scope: null,
type: makeType(),
loc: node.loc ?? GeneratedSource,
};
this.#bindings.set(name, { node, identifier });
return identifier;
@@ -877,7 +880,10 @@ export function removeUnnecessaryTryCatch(fn: HIR): void {
}
}
export function createTemporaryPlace(env: Environment): Place {
export function createTemporaryPlace(
env: Environment,
loc: SourceLocation
): Place {
return {
kind: "Identifier",
identifier: {
@@ -886,6 +892,7 @@ export function createTemporaryPlace(env: Environment): Place {
name: null,
scope: null,
type: makeType(),
loc,
},
reactive: false,
effect: Effect.Unknown,
@@ -118,6 +118,7 @@ function addShape(
export type HookKind =
| "useContext"
| "useState"
| "useReducer"
| "useRef"
| "useEffect"
| "useLayoutEffect"
@@ -200,6 +201,8 @@ export const BuiltInUseEffectHookId = "BuiltInUseEffectHook";
export const BuiltInUseLayoutEffectHookId = "BuiltInUseLayoutEffectHook";
export const BuiltInUseInsertionEffectHookId = "BuiltInUseInsertionEffectHook";
export const BuiltInUseOperatorId = "BuiltInUseOperator";
export const BuiltInUseReducerId = "BuiltInUseReducer";
export const BuiltInDispatchId = "BuiltInDispatch";
// ShapeRegistry with default definitions for built-ins.
export const BUILTIN_SHAPES: ShapeRegistry = new Map();
@@ -387,6 +390,25 @@ addObject(BUILTIN_SHAPES, BuiltInUseStateId, [
],
]);
addObject(BUILTIN_SHAPES, BuiltInUseReducerId, [
["0", { kind: "Poly" }],
[
"1",
addFunction(
BUILTIN_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Primitive,
},
BuiltInDispatchId
),
],
]);
addObject(BUILTIN_SHAPES, BuiltInUseRefId, [
["current", { kind: "Object", shapeId: BuiltInRefValueId }],
]);
@@ -178,7 +178,7 @@ function makeManualMemoizationMarkers(
return [
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env),
lvalue: createTemporaryPlace(env, fnExpr.loc),
value: {
kind: "StartMemoize",
manualMemoId,
@@ -193,7 +193,7 @@ function makeManualMemoizationMarkers(
},
{
id: makeInstructionId(0),
lvalue: createTemporaryPlace(env),
lvalue: createTemporaryPlace(env, fnExpr.loc),
value: {
kind: "FinishMemoize",
manualMemoId,
@@ -15,6 +15,7 @@ import {
Place,
computePostDominatorTree,
getHookKind,
isDispatcherType,
isSetStateType,
isUseOperator,
} from "../HIR";
@@ -219,7 +220,10 @@ export function inferReactivePlaces(fn: HIRFunction): void {
if (hasReactiveInput) {
for (const lvalue of eachInstructionLValue(instruction)) {
if (isSetStateType(lvalue.identifier)) {
if (
isSetStateType(lvalue.identifier) ||
isDispatcherType(lvalue.identifier)
) {
continue;
}
reactiveIdentifiers.markReactive(lvalue);
@@ -2117,6 +2117,8 @@ function getWriteErrorReason(abstractValue: AbstractValue): string {
return "Mutating component props or hook arguments is not allowed. Consider using a local variable instead";
} else if (abstractValue.reason.has(ValueReason.State)) {
return "Mutating a value returned from 'useState()', which should not be mutated. Use the setter function to update instead";
} else if (abstractValue.reason.has(ValueReason.ReducerState)) {
return "Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead";
} else {
return "This mutates a variable that React considers immutable";
}
@@ -236,6 +236,7 @@ function rewriteBlock(
name: null,
scope: null,
type: makeType(),
loc: terminal.loc,
},
kind: "Identifier",
reactive: false,
@@ -277,6 +278,7 @@ function declareTemporary(
name: null,
scope: null,
type: makeType(),
loc: result.loc,
},
kind: "Identifier",
reactive: false,
@@ -369,6 +369,58 @@ function evaluateInstruction(
}
break;
}
case "|": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs | rhs, loc: value.loc };
}
break;
}
case "&": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs & rhs, loc: value.loc };
}
break;
}
case "^": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs ^ rhs, loc: value.loc };
}
break;
}
case "<<": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs << rhs, loc: value.loc };
}
break;
}
case ">>": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs >> rhs, loc: value.loc };
}
break;
}
case ">>>": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = {
kind: "Primitive",
value: lhs >>> rhs,
loc: value.loc,
};
}
break;
}
case "%": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs % rhs, loc: value.loc };
}
break;
}
case "**": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs ** rhs, loc: value.loc };
}
break;
}
case "<": {
if (typeof lhs === "number" && typeof rhs === "number") {
result = { kind: "Primitive", value: lhs < rhs, loc: value.loc };
@@ -461,6 +461,11 @@ function codegenReactiveScope(
): void {
const cacheStoreStatements: Array<t.Statement> = [];
const cacheLoadStatements: Array<t.Statement> = [];
const cacheLoads: Array<{
name: t.Identifier;
index: number;
value: t.Expression;
}> = [];
const changeExpressions: Array<t.Expression> = [];
const changeExpressionComments: Array<string> = [];
const outputComments: Array<string> = [];
@@ -488,6 +493,10 @@ function codegenReactiveScope(
} else {
changeExpressions.push(comparison);
}
/*
* Adding directly to cacheStoreStatements rather than cacheLoads, because there
* is no corresponding cacheLoadStatement for dependencies
*/
cacheStoreStatements.push(
t.expressionStatement(
t.assignmentExpression(
@@ -523,32 +532,7 @@ function codegenReactiveScope(
t.variableDeclaration("let", [t.variableDeclarator(name)])
);
}
cacheStoreStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
),
wrapCacheDep(cx, name)
)
)
);
cacheLoadStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
name,
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
)
)
)
);
cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) });
cx.declare(identifier);
}
for (const reassignment of scope.reassignments) {
@@ -558,34 +542,9 @@ function codegenReactiveScope(
}
const name = convertIdentifier(reassignment);
outputComments.push(name.name);
cacheStoreStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
),
wrapCacheDep(cx, name)
)
)
);
cacheLoadStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
name,
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
)
)
)
);
cacheLoads.push({ name, index, value: wrapCacheDep(cx, name) });
}
let testCondition = (changeExpressions as Array<t.Expression>).reduce(
(acc: t.Expression | null, ident: t.Expression) => {
if (acc == null) {
@@ -616,15 +575,139 @@ function codegenReactiveScope(
);
}
if (cx.env.config.disableMemoizationForDebugging) {
CompilerError.invariant(
cx.env.config.enableChangeDetectionForDebugging == null,
{
reason: `Expected to not have both change detection enabled and memoization disabled`,
description: `Incompatible config options`,
loc: null,
}
);
testCondition = t.logicalExpression(
"||",
testCondition,
t.booleanLiteral(true)
);
}
let computationBlock = codegenBlock(cx, block);
computationBlock.body.push(...cacheStoreStatements);
const memoBlock = t.blockStatement(cacheLoadStatements);
const memoStatement = t.ifStatement(
testCondition,
computationBlock,
memoBlock
);
let memoStatement;
if (
cx.env.config.enableChangeDetectionForDebugging != null &&
changeExpressions.length > 0
) {
const loc =
typeof scope.loc === "symbol"
? "unknown location"
: `(${scope.loc.start.line}:${scope.loc.end.line})`;
const detectionFunction =
cx.env.config.enableChangeDetectionForDebugging.importSpecifierName;
const cacheLoadOldValueStatements: Array<t.Statement> = [];
const changeDetectionStatements: Array<t.Statement> = [];
const idempotenceDetectionStatements: Array<t.Statement> = [];
for (const { name, index, value } of cacheLoads) {
const loadName = cx.synthesizeName(`old$${name.name}`);
const slot = t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
);
cacheStoreStatements.push(
t.expressionStatement(t.assignmentExpression("=", slot, value))
);
cacheLoadOldValueStatements.push(
t.variableDeclaration("let", [
t.variableDeclarator(t.identifier(loadName), slot),
])
);
changeDetectionStatements.push(
t.expressionStatement(
t.callExpression(t.identifier(detectionFunction), [
t.identifier(loadName),
name,
t.stringLiteral(name.name),
t.stringLiteral(cx.fnName),
t.stringLiteral("cached"),
t.stringLiteral(loc),
])
)
);
idempotenceDetectionStatements.push(
t.expressionStatement(
t.callExpression(t.identifier(detectionFunction), [
slot,
name,
t.stringLiteral(name.name),
t.stringLiteral(cx.fnName),
t.stringLiteral("recomputed"),
t.stringLiteral(loc),
])
)
);
idempotenceDetectionStatements.push(
t.expressionStatement(t.assignmentExpression("=", name, slot))
);
}
const condition = cx.synthesizeName("condition");
memoStatement = t.blockStatement([
...computationBlock.body,
t.variableDeclaration("let", [
t.variableDeclarator(t.identifier(condition), testCondition),
]),
t.ifStatement(
t.unaryExpression("!", t.identifier(condition)),
t.blockStatement([
...cacheLoadOldValueStatements,
...changeDetectionStatements,
])
),
...cacheStoreStatements,
t.ifStatement(
t.identifier(condition),
t.blockStatement([
...computationBlock.body,
...idempotenceDetectionStatements,
])
),
]);
} else {
for (const { name, index, value } of cacheLoads) {
cacheStoreStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
),
value
)
)
);
cacheLoadStatements.push(
t.expressionStatement(
t.assignmentExpression(
"=",
name,
t.memberExpression(
t.identifier(cx.synthesizeName("$")),
t.numericLiteral(index),
true
)
)
)
);
}
computationBlock.body.push(...cacheStoreStatements);
memoStatement = t.ifStatement(
testCondition,
computationBlock,
t.blockStatement(cacheLoadStatements)
);
}
if (cx.env.config.enableMemoizationComments) {
if (changeExpressionComments.length) {
@@ -665,9 +748,9 @@ function codegenReactiveScope(
true
);
}
if (memoBlock.body.length > 0) {
if (cacheLoadStatements.length > 0) {
t.addComment(
memoBlock.body[0]!,
cacheLoadStatements[0]!,
"leading",
` Inputs did not change, use cached value`,
true
@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import { CompilerError } from "..";
import { CompilerError, SourceLocation } from "..";
import { Environment } from "../HIR";
import {
GeneratedSource,
@@ -110,6 +110,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void {
reassignments: new Set(),
earlyReturnValue: null,
merged: new Set(),
loc: identifier.loc,
};
scopes.set(groupIdentifier, scope);
} else {
@@ -119,6 +120,7 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void {
scope.range.end = makeInstructionId(
Math.max(scope.range.end, identifier.mutableRange.end)
);
scope.loc = mergeLocation(scope.loc, identifier.loc);
}
identifier.scope = scope;
identifier.mutableRange = scope.range;
@@ -159,6 +161,25 @@ export function inferReactiveScopeVariables(fn: HIRFunction): void {
}
}
function mergeLocation(l: SourceLocation, r: SourceLocation): SourceLocation {
if (l === GeneratedSource) {
return r;
} else if (r === GeneratedSource) {
return l;
} else {
return {
start: {
line: Math.min(l.start.line, r.start.line),
column: Math.min(l.start.column, r.start.column),
},
end: {
line: Math.max(l.end.line, r.end.line),
column: Math.max(l.end.column, r.end.column),
},
};
}
}
// Is the operand mutable at this given instruction
export function isMutable({ id }: Instruction, place: Place): boolean {
const range = place.identifier.mutableRange;
@@ -153,10 +153,10 @@ class Transform extends ReactiveFunctionTransform<State> {
const instructions = scopeBlock.instructions;
const loc = earlyReturnValue.loc;
const sentinelTemp = createTemporaryPlace(this.env);
const symbolTemp = createTemporaryPlace(this.env);
const forTemp = createTemporaryPlace(this.env);
const argTemp = createTemporaryPlace(this.env);
const sentinelTemp = createTemporaryPlace(this.env, loc);
const symbolTemp = createTemporaryPlace(this.env, loc);
const forTemp = createTemporaryPlace(this.env, loc);
const argTemp = createTemporaryPlace(this.env, loc);
scopeBlock.instructions = [
{
kind: "instruction",
@@ -274,7 +274,7 @@ class Transform extends ReactiveFunctionTransform<State> {
if (state.earlyReturnValue !== null) {
earlyReturnValue = state.earlyReturnValue;
} else {
const identifier = createTemporaryPlace(this.env).identifier;
const identifier = createTemporaryPlace(this.env, loc).identifier;
promoteTemporary(identifier);
earlyReturnValue = {
label: this.env.nextBlockId,
@@ -0,0 +1,290 @@
/**
* 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 { CompilerError } from "../CompilerError";
import {
Environment,
Identifier,
IdentifierId,
InstructionId,
Place,
ReactiveBlock,
ReactiveFunction,
ReactiveInstruction,
ReactiveScopeBlock,
ReactiveTerminalStatement,
getHookKind,
isUseRefType,
isUseStateType,
} from "../HIR";
import { eachCallArgument, eachInstructionLValue } from "../HIR/visitors";
import DisjointSet from "../Utils/DisjointSet";
import { assertExhaustive } from "../Utils/utils";
import { ReactiveFunctionVisitor, visitReactiveFunction } from "./visitors";
/**
* This pass is built based on the observation by @jbrown215 that arguments
* to useState and useRef are only used the first time a component is rendered.
* Any subsequent times, the arguments will be evaluated but ignored. In this pass,
* we use this fact to improve the output of the compiler by not recomputing values that
* are only used as arguments (or inputs to arguments to) useState and useRef.
*
* This pass isn't yet stress-tested so it's not enabled by default. It's only enabled
* to support certain debug modes that detect non-idempotent code, since non-idempotent
* code can "safely" be used if its only passed to useState and useRef. We plan to rewrite
* this pass in HIR and enable it as an optimization in the future.
*
* Algorithm:
* We take two passes over the reactive function AST. In the first pass, we gather
* aliases and build relationships between property accesses--the key thing we need
* to do here is to find that, e.g., $0.x and $1 refer to the same value if
* $1 = PropertyLoad $0.x.
*
* In the second pass, we traverse the AST in reverse order and track how each place
* is used. If a place is read from in any Terminal, we mark the place as "Update", meaning
* it is used whenever the component is updated/re-rendered. If a place is read from in
* a useState or useRef hook call, we mark it as "Create", since it is only used when the
* component is created. In other instructions, we propagate the inferred place for the
* instructions lvalues onto any other instructions that are read.
*
* Whenever we finish this reverse pass over a reactive block, we can look at the blocks
* dependencies and see whether the dependencies are used in an "Update" context or only
* in a "Create" context. If a dependency is create-only, then we can remove that dependency
* from the block.
*/
type CreateUpdate = "Create" | "Update" | "Unknown";
type KindMap = Map<IdentifierId, CreateUpdate>;
class Visitor extends ReactiveFunctionVisitor<CreateUpdate> {
map: KindMap = new Map();
aliases: DisjointSet<IdentifierId>;
paths: Map<IdentifierId, Map<string, IdentifierId>>;
env: Environment;
constructor(
env: Environment,
aliases: DisjointSet<IdentifierId>,
paths: Map<IdentifierId, Map<string, IdentifierId>>
) {
super();
this.aliases = aliases;
this.paths = paths;
this.env = env;
}
join(values: Array<CreateUpdate>): CreateUpdate {
function join2(l: CreateUpdate, r: CreateUpdate): CreateUpdate {
if (l === "Update" || r === "Update") {
return "Update";
} else if (l === "Create" || r === "Create") {
return "Create";
} else if (l === "Unknown" || r === "Unknown") {
return "Unknown";
}
assertExhaustive(r, `Unhandled variable kind ${r}`);
}
return values.reduce(join2, "Unknown");
}
isCreateOnlyHook(id: Identifier): boolean {
return isUseStateType(id) || isUseRefType(id);
}
override visitPlace(
_: InstructionId,
place: Place,
state: CreateUpdate
): void {
this.map.set(
place.identifier.id,
this.join([state, this.map.get(place.identifier.id) ?? "Unknown"])
);
}
override visitBlock(block: ReactiveBlock, state: CreateUpdate): void {
super.visitBlock([...block].reverse(), state);
}
override visitInstruction(instruction: ReactiveInstruction): void {
const state = this.join(
[...eachInstructionLValue(instruction)].map(
(operand) => this.map.get(operand.identifier.id) ?? "Unknown"
)
);
const visitCallOrMethodNonArgs = (): void => {
switch (instruction.value.kind) {
case "CallExpression": {
this.visitPlace(instruction.id, instruction.value.callee, state);
break;
}
case "MethodCall": {
this.visitPlace(instruction.id, instruction.value.property, state);
this.visitPlace(instruction.id, instruction.value.receiver, state);
break;
}
}
};
const isHook = (): boolean => {
let callee = null;
switch (instruction.value.kind) {
case "CallExpression": {
callee = instruction.value.callee.identifier;
break;
}
case "MethodCall": {
callee = instruction.value.property.identifier;
break;
}
}
return callee != null && getHookKind(this.env, callee) != null;
};
switch (instruction.value.kind) {
case "CallExpression":
case "MethodCall": {
if (
instruction.lvalue &&
this.isCreateOnlyHook(instruction.lvalue.identifier)
) {
[...eachCallArgument(instruction.value.args)].forEach((operand) =>
this.visitPlace(instruction.id, operand, "Create")
);
visitCallOrMethodNonArgs();
} else {
this.traverseInstruction(instruction, isHook() ? "Update" : state);
}
break;
}
default: {
this.traverseInstruction(instruction, state);
}
}
}
override visitScope(scope: ReactiveScopeBlock): void {
const state = this.join(
[
...scope.scope.declarations.keys(),
...[...scope.scope.reassignments.values()].map((ident) => ident.id),
].map((id) => this.map.get(id) ?? "Unknown")
);
super.visitScope(scope, state);
[...scope.scope.dependencies].forEach((ident) => {
let target: undefined | IdentifierId =
this.aliases.find(ident.identifier.id) ?? ident.identifier.id;
ident.path.forEach((key) => {
target &&= this.paths.get(target)?.get(key);
});
if (target && this.map.get(target) === "Create") {
scope.scope.dependencies.delete(ident);
}
});
}
override visitTerminal(
stmt: ReactiveTerminalStatement,
state: CreateUpdate
): void {
CompilerError.invariant(state !== "Create", {
reason: "Visiting a terminal statement with state 'Create'",
loc: stmt.terminal.loc,
});
super.visitTerminal(stmt, state);
}
override visitReactiveFunctionValue(
_id: InstructionId,
_dependencies: Array<Place>,
fn: ReactiveFunction,
state: CreateUpdate
): void {
visitReactiveFunction(fn, this, state);
}
}
export default function pruneInitializationDependencies(
fn: ReactiveFunction
): void {
const [aliases, paths] = getAliases(fn);
visitReactiveFunction(fn, new Visitor(fn.env, aliases, paths), "Update");
}
function update(
map: Map<IdentifierId, Map<string, IdentifierId>>,
key: IdentifierId,
path: string,
value: IdentifierId
): void {
const inner = map.get(key) ?? new Map();
inner.set(path, value);
map.set(key, inner);
}
class AliasVisitor extends ReactiveFunctionVisitor {
scopeIdentifiers: DisjointSet<IdentifierId> = new DisjointSet<IdentifierId>();
scopePaths: Map<IdentifierId, Map<string, IdentifierId>> = new Map();
override visitInstruction(instr: ReactiveInstruction): void {
if (
instr.value.kind === "StoreLocal" ||
instr.value.kind === "StoreContext"
) {
this.scopeIdentifiers.union([
instr.value.lvalue.place.identifier.id,
instr.value.value.identifier.id,
]);
} else if (
instr.value.kind === "LoadLocal" ||
instr.value.kind === "LoadContext"
) {
instr.lvalue &&
this.scopeIdentifiers.union([
instr.lvalue.identifier.id,
instr.value.place.identifier.id,
]);
} else if (instr.value.kind === "PropertyLoad") {
instr.lvalue &&
update(
this.scopePaths,
instr.value.object.identifier.id,
instr.value.property,
instr.lvalue.identifier.id
);
} else if (instr.value.kind === "PropertyStore") {
update(
this.scopePaths,
instr.value.object.identifier.id,
instr.value.property,
instr.value.value.identifier.id
);
}
}
}
function getAliases(
fn: ReactiveFunction
): [DisjointSet<IdentifierId>, Map<IdentifierId, Map<string, IdentifierId>>] {
const visitor = new AliasVisitor();
visitReactiveFunction(fn, visitor, null);
let disjoint = visitor.scopeIdentifiers;
let scopePaths = new Map<IdentifierId, Map<string, IdentifierId>>();
for (const [key, value] of visitor.scopePaths) {
for (const [path, id] of value) {
update(
scopePaths,
disjoint.find(key) ?? key,
path,
disjoint.find(id) ?? id
);
}
}
return [disjoint, scopePaths];
}
@@ -10,6 +10,7 @@ import {
ReactiveFunction,
ReactiveInstruction,
ReactiveScopeBlock,
isDispatcherType,
isSetStateType,
} from "../HIR";
import { eachPatternOperand } from "../HIR/visitors";
@@ -56,7 +57,10 @@ class Visitor extends ReactiveFunctionVisitor<ReactiveIdentifiers> {
case "Destructure": {
if (state.has(value.value.identifier.id)) {
for (const lvalue of eachPatternOperand(value.lvalue.pattern)) {
if (isSetStateType(lvalue.identifier)) {
if (
isSetStateType(lvalue.identifier) ||
isDispatcherType(lvalue.identifier)
) {
continue;
}
state.add(lvalue.identifier.id);
@@ -86,6 +86,7 @@ class SSABuilder {
},
scope: null, // reset along w the mutable range
type: makeType(),
loc: oldId.loc,
};
}
@@ -22,10 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
```javascript
function f() {
let x;
x = 3 >>> 1;
return x;
return 1;
}
export const FIXTURE_ENTRYPOINT = {
@@ -0,0 +1,48 @@
## Input
```javascript
// @enableChangeDetectionForDebugging
function Component(props) {
let x = null;
if (props.cond) {
x = [];
x.push(props.value);
}
return x;
}
```
## Code
```javascript
import { $structuralCheck } from "react-compiler-runtime";
import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging
function Component(props) {
const $ = _c(2);
let x = null;
if (props.cond) {
{
x = [];
x.push(props.value);
let condition = $[0] !== props.value;
if (!condition) {
let old$x = $[1];
$structuralCheck(old$x, x, "x", "Component", "cached", "(3:6)");
}
$[0] = props.value;
$[1] = x;
if (condition) {
x = [];
x.push(props.value);
$structuralCheck($[1], x, "x", "Component", "recomputed", "(3:6)");
x = $[1];
}
}
}
return x;
}
```
@@ -0,0 +1,9 @@
// @enableChangeDetectionForDebugging
function Component(props) {
let x = null;
if (props.cond) {
x = [];
x.push(props.value);
}
return x;
}
@@ -0,0 +1,78 @@
## Input
```javascript
import { Stringify } from "shared-runtime";
function foo() {
return (
<Stringify
value={[
123.45 | 0,
123.45 & 0,
123.45 ^ 0,
123 << 0,
123 >> 0,
123 >>> 0,
123.45 | 1,
123.45 & 1,
123.45 ^ 1,
123 << 1,
123 >> 1,
123 >>> 1,
3 ** 2,
3 ** 2.5,
3.5 ** 2,
2 ** (3 ** 0.5),
4 % 2,
4 % 2.5,
4 % 3,
4.5 % 2,
]}
/>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify } from "shared-runtime";
function foo() {
const $ = _c(1);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = (
<Stringify
value={[
123, 0, 123, 123, 123, 123, 123, 1, 122, 246, 61, 61, 9,
15.588457268119896, 12.25, 3.3219970854839125, 0, 1.5, 1, 0.5,
]}
/>
);
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
```
### Eval output
(kind: ok) <div>{"value":[123,0,123,123,123,123,123,1,122,246,61,61,9,15.588457268119896,12.25,3.3219970854839125,0,1.5,1,0.5]}</div>
@@ -0,0 +1,36 @@
import { Stringify } from "shared-runtime";
function foo() {
return (
<Stringify
value={[
123.45 | 0,
123.45 & 0,
123.45 ^ 0,
123 << 0,
123 >> 0,
123 >>> 0,
123.45 | 1,
123.45 & 1,
123.45 ^ 1,
123 << 1,
123 >> 1,
123 >>> 1,
3 ** 2,
3 ** 2.5,
3.5 ** 2,
2 ** (3 ** 0.5),
4 % 2,
4 % 2.5,
4 % 3,
4.5 % 2,
]}
/>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: foo,
params: [],
isComponent: false,
};
@@ -0,0 +1,28 @@
## Input
```javascript
import { useReducer } from "react";
function Foo() {
let [state, setState] = useReducer({ foo: 1 });
state.foo = 1;
return state;
}
```
## Error
```
3 | function Foo() {
4 | let [state, setState] = useReducer({ foo: 1 });
> 5 | state.foo = 1;
| ^^^^^ InvalidReact: Mutating a value returned from 'useReducer()', which should not be mutated. Use the dispatch function to update instead (5:5)
6 | return state;
7 | }
8 |
```
@@ -0,0 +1,7 @@
import { useReducer } from "react";
function Foo() {
let [state, setState] = useReducer({ foo: 1 });
state.foo = 1;
return state;
}
@@ -0,0 +1,17 @@
## Input
```javascript
// @disableMemoizationForDebugging @enableChangeDetectionForDebugging
function Component(props) {}
```
## Error
```
InvalidConfig: Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together
```
@@ -0,0 +1,2 @@
// @disableMemoizationForDebugging @enableChangeDetectionForDebugging
function Component(props) {}
@@ -0,0 +1,66 @@
## Input
```javascript
// @disableMemoizationForDebugging
import { useMemo } from "react";
function Component({ a }) {
let x = useMemo(() => [a], []);
return <div>{x}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @disableMemoizationForDebugging
import { useMemo } from "react";
function Component(t0) {
const $ = _c(5);
const { a } = t0;
let t1;
if ($[0] !== a || true) {
t1 = () => [a];
$[0] = a;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel") || true) {
t2 = [];
$[2] = t2;
} else {
t2 = $[2];
}
const x = useMemo(t1, t2);
let t3;
if ($[3] !== x || true) {
t3 = <div>{x}</div>;
$[3] = x;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
```
### Eval output
(kind: ok) <div>42</div>
@@ -0,0 +1,13 @@
// @disableMemoizationForDebugging
import { useMemo } from "react";
function Component({ a }) {
let x = useMemo(() => [a], []);
return <div>{x}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
@@ -0,0 +1,66 @@
## Input
```javascript
// @enablePreserveExistingManualUseMemo
import { useMemo } from "react";
function Component({ a }) {
let x = useMemo(() => [a], []);
return <div>{x}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @enablePreserveExistingManualUseMemo
import { useMemo } from "react";
function Component(t0) {
const $ = _c(5);
const { a } = t0;
let t1;
if ($[0] !== a) {
t1 = () => [a];
$[0] = a;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = [];
$[2] = t2;
} else {
t2 = $[2];
}
const x = useMemo(t1, t2);
let t3;
if ($[3] !== x) {
t3 = <div>{x}</div>;
$[3] = x;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
```
### Eval output
(kind: ok) <div>42</div>
@@ -0,0 +1,13 @@
// @enablePreserveExistingManualUseMemo
import { useMemo } from "react";
function Component({ a }) {
let x = useMemo(() => [a], []);
return <div>{x}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ a: 42 }],
isComponent: true,
};
@@ -0,0 +1,57 @@
## Input
```javascript
import { useReducer } from "react";
function f() {
const [state, dispatch] = useReducer();
const onClick = () => {
dispatch();
};
return <div onClick={onClick} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: f,
params: [],
isComponent: true,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useReducer } from "react";
function f() {
const $ = _c(1);
const [state, dispatch] = useReducer();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const onClick = () => {
dispatch();
};
t0 = <div onClick={onClick} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: f,
params: [],
isComponent: true,
};
```
### Eval output
(kind: ok) <div></div>
@@ -0,0 +1,17 @@
import { useReducer } from "react";
function f() {
const [state, dispatch] = useReducer();
const onClick = () => {
dispatch();
};
return <div onClick={onClick} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: f,
params: [],
isComponent: true,
};
@@ -0,0 +1,92 @@
## Input
```javascript
import { useState } from "react"; // @enableChangeDetectionForDebugging
function useOther(x) {
return x;
}
function Component(props) {
const w = f(props.x);
const z = useOther(w);
const [x, _] = useState(z);
return <div>{x}</div>;
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
```
## Code
```javascript
import { $structuralCheck } from "react-compiler-runtime";
import { c as _c } from "react/compiler-runtime";
import { useState } from "react"; // @enableChangeDetectionForDebugging
function useOther(x) {
return x;
}
function Component(props) {
const $ = _c(4);
let t0;
{
t0 = f(props.x);
let condition = $[0] !== props.x;
if (!condition) {
let old$t0 = $[1];
$structuralCheck(old$t0, t0, "t0", "Component", "cached", "(8:8)");
}
$[0] = props.x;
$[1] = t0;
if (condition) {
t0 = f(props.x);
$structuralCheck($[1], t0, "t0", "Component", "recomputed", "(8:8)");
t0 = $[1];
}
}
const w = t0;
const z = useOther(w);
const [x] = useState(z);
let t1;
{
t1 = <div>{x}</div>;
let condition = $[2] !== x;
if (!condition) {
let old$t1 = $[3];
$structuralCheck(old$t1, t1, "t1", "Component", "cached", "(11:11)");
}
$[2] = x;
$[3] = t1;
if (condition) {
t1 = <div>{x}</div>;
$structuralCheck($[3], t1, "t1", "Component", "recomputed", "(11:11)");
t1 = $[3];
}
}
return t1;
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
```
@@ -0,0 +1,22 @@
import { useState } from "react"; // @enableChangeDetectionForDebugging
function useOther(x) {
return x;
}
function Component(props) {
const w = f(props.x);
const z = useOther(w);
const [x, _] = useState(z);
return <div>{x}</div>;
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
@@ -0,0 +1,52 @@
## Input
```javascript
// @enableChangeDetectionForDebugging
import { useState } from "react";
function Component(props) {
const [x, _] = useState(f(props.x));
return <div>{x}</div>;
}
```
## Code
```javascript
import { $structuralCheck } from "react-compiler-runtime";
import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging
import { useState } from "react";
function Component(props) {
const $ = _c(3);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = f(props.x);
$[0] = t0;
} else {
t0 = $[0];
}
const [x] = useState(t0);
let t1;
{
t1 = <div>{x}</div>;
let condition = $[1] !== x;
if (!condition) {
let old$t1 = $[2];
$structuralCheck(old$t1, t1, "t1", "Component", "cached", "(6:6)");
}
$[1] = x;
$[2] = t1;
if (condition) {
t1 = <div>{x}</div>;
$structuralCheck($[2], t1, "t1", "Component", "recomputed", "(6:6)");
t1 = $[2];
}
}
return t1;
}
```
@@ -0,0 +1,7 @@
// @enableChangeDetectionForDebugging
import { useState } from "react";
function Component(props) {
const [x, _] = useState(f(props.x));
return <div>{x}</div>;
}
@@ -0,0 +1,98 @@
## Input
```javascript
import { useState } from "react"; // @enableChangeDetectionForDebugging
function Component(props) {
const w = f(props.x);
const [x, _] = useState(w);
return (
<div>
{x}
{w}
</div>
);
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
```
## Code
```javascript
import { $structuralCheck } from "react-compiler-runtime";
import { c as _c } from "react/compiler-runtime";
import { useState } from "react"; // @enableChangeDetectionForDebugging
function Component(props) {
const $ = _c(5);
let t0;
{
t0 = f(props.x);
let condition = $[0] !== props.x;
if (!condition) {
let old$t0 = $[1];
$structuralCheck(old$t0, t0, "t0", "Component", "cached", "(4:4)");
}
$[0] = props.x;
$[1] = t0;
if (condition) {
t0 = f(props.x);
$structuralCheck($[1], t0, "t0", "Component", "recomputed", "(4:4)");
t0 = $[1];
}
}
const w = t0;
const [x] = useState(w);
let t1;
{
t1 = (
<div>
{x}
{w}
</div>
);
let condition = $[2] !== x || $[3] !== w;
if (!condition) {
let old$t1 = $[4];
$structuralCheck(old$t1, t1, "t1", "Component", "cached", "(7:10)");
}
$[2] = x;
$[3] = w;
$[4] = t1;
if (condition) {
t1 = (
<div>
{x}
{w}
</div>
);
$structuralCheck($[4], t1, "t1", "Component", "recomputed", "(7:10)");
t1 = $[4];
}
}
return t1;
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
```
@@ -0,0 +1,22 @@
import { useState } from "react"; // @enableChangeDetectionForDebugging
function Component(props) {
const w = f(props.x);
const [x, _] = useState(w);
return (
<div>
{x}
{w}
</div>
);
}
function f(x) {
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
isComponent: true,
};
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-react-compiler",
"version": "0.0.0-experimental-a97cca1-20240529",
"version": "0.0.0-experimental-51a85ea-20240601",
"description": "ESLint plugin to display errors found by the React compiler.",
"main": "dist/index.js",
"scripts": {
@@ -124,12 +124,14 @@ const rule: Rule.RuleModule = {
});
} catch {}
} else {
babelAST = HermesParser.parse(sourceCode, {
babel: true,
enableExperimentalComponentSyntax: true,
sourceFilename: filename,
sourceType: "module",
});
try {
babelAST = HermesParser.parse(sourceCode, {
babel: true,
enableExperimentalComponentSyntax: true,
sourceFilename: filename,
sourceType: "module",
});
} catch {}
}
if (babelAST != null) {
@@ -1,6 +1,6 @@
{
"name": "react-compiler-healthcheck",
"version": "0.0.0-experimental-31393f7-20240529",
"version": "0.0.0-experimental-7054a14-20240601",
"description": "Health check script to test violations of the rules of react.",
"bin": {
"react-compiler-healthcheck": "dist/index.js"
@@ -1,3 +1,3 @@
export const config = {
knownIncompatibleLibraries: ["mobx"],
knownIncompatibleLibraries: ["mobx", "@risingstack/react-easy-state"],
};
@@ -9,7 +9,7 @@
import * as React from "react";
const { useRef, useEffect } = React;
const { useRef, useEffect, isValidElement } = React;
const ReactSecretInternals =
//@ts-ignore
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ??
@@ -251,3 +251,172 @@ export function useRenderCounter(name: string): void {
};
});
}
const seenErrors = new Set();
export function $structuralCheck(
oldValue: any,
newValue: any,
variableName: string,
fnName: string,
kind: string,
loc: string
): void {
function error(l: string, r: string, path: string, depth: number) {
const str = `${fnName}:${loc} [${kind}] ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`;
if (seenErrors.has(str)) {
return;
}
seenErrors.add(str);
console.error(str);
}
const depthLimit = 2;
function recur(oldValue: any, newValue: any, path: string, depth: number) {
if (depth > depthLimit) {
return;
} else if (oldValue === newValue) {
return;
} else if (typeof oldValue !== typeof newValue) {
error(`type ${typeof oldValue}`, `type ${typeof newValue}`, path, depth);
} else if (typeof oldValue === "object") {
const oldArray = Array.isArray(oldValue);
const newArray = Array.isArray(newValue);
if (oldValue === null && newValue !== null) {
error("null", `type ${typeof newValue}`, path, depth);
} else if (newValue === null) {
error(`type ${typeof oldValue}`, "null", path, depth);
} else if (oldValue instanceof Map) {
if (!(newValue instanceof Map)) {
error(`Map instance`, `other value`, path, depth);
} else if (oldValue.size !== newValue.size) {
error(
`Map instance with size ${oldValue.size}`,
`Map instance with size ${newValue.size}`,
path,
depth
);
} else {
for (const [k, v] of oldValue) {
if (!newValue.has(k)) {
error(
`Map instance with key ${k}`,
`Map instance without key ${k}`,
path,
depth
);
} else {
recur(v, newValue.get(k), `${path}.get(${k})`, depth + 1);
}
}
}
} else if (newValue instanceof Map) {
error("other value", `Map instance`, path, depth);
} else if (oldValue instanceof Set) {
if (!(newValue instanceof Set)) {
error(`Set instance`, `other value`, path, depth);
} else if (oldValue.size !== newValue.size) {
error(
`Set instance with size ${oldValue.size}`,
`Set instance with size ${newValue.size}`,
path,
depth
);
} else {
for (const v of newValue) {
if (!oldValue.has(v)) {
error(
`Set instance without element ${v}`,
`Set instance with element ${v}`,
path,
depth
);
}
}
}
} else if (newValue instanceof Set) {
error("other value", `Set instance`, path, depth);
} else if (oldArray || newArray) {
if (oldArray !== newArray) {
error(
`type ${oldArray ? "array" : "object"}`,
`type ${newArray ? "array" : "object"}`,
path,
depth
);
} else if (oldValue.length !== newValue.length) {
error(
`array with length ${oldValue.length}`,
`array with length ${newValue.length}`,
path,
depth
);
} else {
for (let ii = 0; ii < oldValue.length; ii++) {
recur(oldValue[ii], newValue[ii], `${path}[${ii}]`, depth + 1);
}
}
} else if (isValidElement(oldValue) || isValidElement(newValue)) {
if (isValidElement(oldValue) !== isValidElement(newValue)) {
error(
`type ${isValidElement(oldValue) ? "React element" : "object"}`,
`type ${isValidElement(newValue) ? "React element" : "object"}`,
path,
depth
);
} else if (oldValue.type !== newValue.type) {
error(
`React element of type ${oldValue.type}`,
`React element of type ${newValue.type}`,
path,
depth
);
} else {
recur(
oldValue.props,
newValue.props,
`[props of ${path}]`,
depth + 1
);
}
} else {
for (const key in newValue) {
if (!(key in oldValue)) {
error(
`object without key ${key}`,
`object with key ${key}`,
path,
depth
);
}
}
for (const key in oldValue) {
if (!(key in newValue)) {
error(
`object with key ${key}`,
`object without key ${key}`,
path,
depth
);
} else {
recur(oldValue[key], newValue[key], `${path}.${key}`, depth + 1);
}
}
}
} else if (typeof oldValue === "function") {
// Bail on functions for now
return;
} else if (isNaN(oldValue) || isNaN(newValue)) {
if (isNaN(oldValue) !== isNaN(newValue)) {
error(
`${isNaN(oldValue) ? "NaN" : "non-NaN value"}`,
`${isNaN(newValue) ? "NaN" : "non-NaN value"}`,
path,
depth
);
}
} else if (oldValue !== newValue) {
error(oldValue, newValue, path, depth);
}
}
recur(oldValue, newValue, "", 0);
}
@@ -495,6 +495,10 @@ const skipFilter = new Set([
"flag-enable-emit-hook-guards",
"fast-refresh-refresh-on-const-changes-dev",
"useState-pruned-dependency-change-detect",
"useState-unpruned-dependency",
"useState-and-other-hook-unpruned-dependency",
"change-detect-reassign",
]);
export default skipFilter;
+9
View File
@@ -43,6 +43,7 @@ function makePluginOptions(
let hookPattern: string | null = null;
// TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false
let validatePreserveExistingMemoizationGuarantees = false;
let enableChangeDetectionForDebugging = null;
if (firstLine.indexOf("@compilationMode(annotation)") !== -1) {
assert(
@@ -120,6 +121,12 @@ function makePluginOptions(
validatePreserveExistingMemoizationGuarantees = true;
}
if (firstLine.includes("@enableChangeDetectionForDebugging")) {
enableChangeDetectionForDebugging = {
source: "react-compiler-runtime",
importSpecifierName: "$structuralCheck",
};
}
const hookPatternMatch = /@hookPattern:"([^"]+)"/.exec(firstLine);
if (
hookPatternMatch &&
@@ -173,6 +180,7 @@ function makePluginOptions(
enableSharedRuntime__testonly: true,
hookPattern,
validatePreserveExistingMemoizationGuarantees,
enableChangeDetectionForDebugging,
},
compilationMode,
logger: null,
@@ -183,6 +191,7 @@ function makePluginOptions(
eslintSuppressionRules,
flowSuppressions,
ignoreUseNoForget,
enableReanimatedCheck: false,
};
}
+4 -3
View File
@@ -153,8 +153,8 @@ function subscribeFilterFile(
} else if (
events.findIndex((event) => event.path.includes(FILTER_FILENAME)) !== -1
) {
state.filter = await readTestFilter();
if (state.mode.filter) {
state.filter = await readTestFilter();
state.mode.action = RunnerAction.Test;
onChange(state);
}
@@ -189,7 +189,7 @@ function subscribeKeyEvents(
state: RunnerState,
onChange: (state: RunnerState) => void
) {
process.stdin.on("keypress", (str, key) => {
process.stdin.on("keypress", async (str, key) => {
if (key.name === "u") {
// u => update fixtures
state.mode.action = RunnerAction.Update;
@@ -197,6 +197,7 @@ function subscribeKeyEvents(
process.exit(0);
} else if (key.name === "f") {
state.mode.filter = !state.mode.filter;
state.filter = state.mode.filter ? await readTestFilter() : null;
state.mode.action = RunnerAction.Test;
} else {
// any other key re-runs tests
@@ -218,7 +219,7 @@ export async function makeWatchRunner(
action: RunnerAction.Test,
filter: filterMode,
},
filter: await readTestFilter(),
filter: filterMode ? await readTestFilter() : null,
};
subscribeTsc(state, onChange);
+1 -1
View File
@@ -199,7 +199,7 @@ module.exports = function (webpackEnv) {
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
: isEnvDevelopment && 'source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: isEnvProduction
+1
View File
@@ -16,6 +16,7 @@ const babelOptions = {
'@babel/plugin-syntax-import-meta',
'@babel/plugin-transform-react-jsx',
],
sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
};
async function babelLoad(url, context, defaultLoad) {
+1 -1
View File
@@ -71,7 +71,7 @@
"prebuild": "cp -r ../../build/oss-experimental/* ./node_modules/",
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
"dev:global": "NODE_ENV=development BUILD_PATH=dist node --experimental-loader ./loader/global.js server/global",
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region",
"dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server server/region",
"start": "node scripts/build.js && concurrently \"npm run start:region\" \"npm run start:global\"",
"start:global": "NODE_ENV=production node --experimental-loader ./loader/global.js server/global",
"start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region",
+37
View File
@@ -214,6 +214,43 @@ app.all('/', async function (req, res, next) {
if (process.env.NODE_ENV === 'development') {
app.use(express.static('public'));
app.get('/source-maps', async function (req, res, next) {
// Proxy the request to the regional server.
const proxiedHeaders = {
'X-Forwarded-Host': req.hostname,
'X-Forwarded-For': req.ips,
'X-Forwarded-Port': 3000,
'X-Forwarded-Proto': req.protocol,
};
const promiseForData = request(
{
host: '127.0.0.1',
port: 3001,
method: req.method,
path: req.originalUrl,
headers: proxiedHeaders,
},
req
);
try {
const rscResponse = await promiseForData;
res.set('Content-type', 'application/json');
rscResponse.on('data', data => {
res.write(data);
res.flush();
});
rscResponse.on('end', data => {
res.end();
});
} catch (e) {
console.error(`Failed to proxy request: ${e.stack}`);
res.statusCode = 500;
res.end();
}
});
} else {
// In production we host the static build output.
app.use(express.static('build'));
+68
View File
@@ -24,6 +24,7 @@ babelRegister({
],
presets: ['@babel/preset-react'],
plugins: ['@babel/transform-modules-commonjs'],
sourceMaps: process.env.NODE_ENV === 'development' ? 'inline' : false,
});
if (typeof fetch === 'undefined') {
@@ -38,6 +39,8 @@ const app = express();
const compress = require('compression');
const {Readable} = require('node:stream');
const nodeModule = require('node:module');
app.use(compress());
// Application
@@ -176,6 +179,71 @@ app.get('/todos', function (req, res) {
]);
});
if (process.env.NODE_ENV === 'development') {
const rootDir = path.resolve(__dirname, '../');
app.get('/source-maps', async function (req, res, next) {
try {
res.set('Content-type', 'application/json');
let requestedFilePath = req.query.name;
let isCompiledOutput = false;
if (requestedFilePath.startsWith('file://')) {
// We assume that if it was prefixed with file:// it's referring to the compiled output
// and if it's a direct file path we assume it's source mapped back to original format.
isCompiledOutput = true;
requestedFilePath = requestedFilePath.slice(7);
}
const relativePath = path.relative(rootDir, requestedFilePath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
// This is outside the root directory of the app. Forbid it to be served.
res.status = 403;
res.write('{}');
res.end();
return;
}
const sourceMap = nodeModule.findSourceMap(requestedFilePath);
let map;
// There are two ways to return a source map depending on what we observe in error.stack.
// A real app will have a similar choice to make for which strategy to pick.
if (!sourceMap || !isCompiledOutput) {
// If a file doesn't have a source map, such as this file, then we generate a blank
// source map that just contains the original content and segments pointing to the
// original lines.
// Similarly
const sourceContent = await readFile(requestedFilePath, 'utf8');
const lines = sourceContent.split('\n').length;
map = {
version: 3,
sources: [requestedFilePath],
sourcesContent: [sourceContent],
// Note: This approach to mapping each line only lets you jump to each line
// not jump to a column within a line. To do that, you need a proper source map
// generated for each parsed segment or add a segment for each column.
mappings: 'AAAA' + ';AACA'.repeat(lines - 1),
sourceRoot: '',
};
} else {
// We always set prepareStackTrace before reading the stack so that we get the stack
// without source maps applied. Therefore we have to use the original source map.
// If something read .stack before we did, we might observe the line/column after
// source mapping back to the original file. We use the isCompiledOutput check above
// in that case.
map = sourceMap.payload;
}
res.write(JSON.stringify(map));
res.end();
} catch (x) {
res.status = 500;
res.write('{}');
res.end();
console.error(x);
}
});
}
app.listen(3001, () => {
console.log('Regional Flight Server listening on port 3001...');
});
+3
View File
@@ -39,6 +39,9 @@ async function hydrateApp() {
}),
{
callServer,
findSourceMapURL(fileName) {
return '/source-maps?name=' + encodeURIComponent(fileName);
},
}
);
+257 -5
View File
@@ -67,8 +67,11 @@ import {
REACT_ELEMENT_TYPE,
REACT_POSTPONE_TYPE,
ASYNC_ITERATOR,
REACT_FRAGMENT_TYPE,
} from 'shared/ReactSymbols';
import getComponentNameFromType from 'shared/getComponentNameFromType';
export type {CallServerCallback, EncodeFormActionCallback};
interface FlightStreamController {
@@ -236,6 +239,8 @@ Chunk.prototype.then = function <T>(
}
};
export type FindSourceMapURLCallback = (fileName: string) => null | string;
export type Response = {
_bundlerConfig: SSRModuleMap,
_moduleLoading: ModuleLoading,
@@ -251,6 +256,8 @@ export type Response = {
_rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline.
_buffer: Array<Uint8Array>, // chunks received so far as part of this row
_tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from
_debugRootTask?: null | ConsoleTask, // DEV-only
_debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only
};
function readChunk<T>(chunk: SomeChunk<T>): T {
@@ -573,7 +580,45 @@ function nullRefGetter() {
}
}
function getServerComponentTaskName(componentInfo: ReactComponentInfo): string {
return '<' + (componentInfo.name || '...') + '>';
}
function getTaskName(type: mixed): string {
if (type === REACT_FRAGMENT_TYPE) {
return '<>';
}
if (typeof type === 'function') {
// This is a function so it must have been a Client Reference that resolved to
// a function. We use "use client" to indicate that this is the boundary into
// the client. There should only be one for any given owner chain.
return '"use client"';
}
if (
typeof type === 'object' &&
type !== null &&
type.$$typeof === REACT_LAZY_TYPE
) {
if (type._init === readChunk) {
// This is a lazy node created by Flight. It is probably a client reference.
// We use the "use client" string to indicate that this is the boundary into
// the client. There will only be one for any given owner chain.
return '"use client"';
}
// We don't want to eagerly initialize the initializer in DEV mode so we can't
// call it to extract the type so we don't know the type of this component.
return '<...>';
}
try {
const name = getComponentNameFromType(type);
return name ? '<' + name + '>' : '<...>';
} catch (x) {
return '<...>';
}
}
function createElement(
response: Response,
type: mixed,
key: mixed,
props: mixed,
@@ -647,11 +692,34 @@ function createElement(
writable: true,
value: stack,
});
let task: null | ConsoleTask = null;
if (supportsCreateTask && stack !== null) {
const createTaskFn = (console: any).createTask.bind(
console,
getTaskName(type),
);
const callStack = buildFakeCallStack(response, stack, createTaskFn);
// This owner should ideally have already been initialized to avoid getting
// user stack frames on the stack.
const ownerTask =
owner === null ? null : initializeFakeTask(response, owner);
if (ownerTask === null) {
const rootTask = response._debugRootTask;
if (rootTask != null) {
task = rootTask.run(callStack);
} else {
task = callStack();
}
} else {
task = ownerTask.run(callStack);
}
}
Object.defineProperty(element, '_debugTask', {
configurable: false,
enumerable: false,
writable: true,
value: null,
value: task,
});
}
// TODO: We should be freezing the element but currently, we might write into
@@ -1049,6 +1117,7 @@ function parseModelTuple(
// TODO: Consider having React just directly accept these arrays as elements.
// Or even change the ReactElement type to be an array.
return createElement(
response,
tuple[1],
tuple[2],
tuple[3],
@@ -1074,6 +1143,7 @@ export function createResponse(
encodeFormAction: void | EncodeFormActionCallback,
nonce: void | string,
temporaryReferences: void | TemporaryReferenceSet,
findSourceMapURL: void | FindSourceMapURLCallback,
): Response {
const chunks: Map<number, SomeChunk<any>> = new Map();
const response: Response = {
@@ -1092,6 +1162,17 @@ export function createResponse(
_buffer: [],
_tempRefs: temporaryReferences,
};
if (supportsCreateTask) {
// Any stacks that appear on the server need to be rooted somehow on the client
// so we create a root Task for this response which will be the root owner for any
// elements created by the server. We use the "use server" string to indicate that
// this is where we enter the server from the client.
// TODO: Make this string configurable.
response._debugRootTask = (console: any).createTask('"use server"');
}
if (__DEV__) {
response._debugFindSourceMapURL = findSourceMapURL;
}
// Don't inline this call because it causes closure to outline the call above.
response._fromJSON = createFromJSONCallback(response);
return response;
@@ -1582,6 +1663,151 @@ function resolveHint<Code: HintCode>(
dispatchHint(code, hintModel);
}
// eslint-disable-next-line react-internal/no-production-logging
const supportsCreateTask =
__DEV__ && enableOwnerStacks && !!(console: any).createTask;
const taskCache: null | WeakMap<
ReactComponentInfo | ReactAsyncInfo,
ConsoleTask,
> = supportsCreateTask ? new WeakMap() : null;
type FakeFunction<T> = (() => T) => T;
const fakeFunctionCache: Map<string, FakeFunction<any>> = __DEV__
? new Map()
: (null: any);
function createFakeFunction<T>(
name: string,
filename: string,
sourceMap: null | string,
line: number,
col: number,
): FakeFunction<T> {
// This creates a fake copy of a Server Module. It represents a module that has already
// executed on the server but we re-execute a blank copy for its stack frames on the client.
const comment =
'/* This module was rendered by a Server Component. Turn on Source Maps to see the server source. */';
// We generate code where the call is at the line and column of the server executed code.
// This allows us to use the original source map as the source map of this fake file to
// point to the original source.
let code;
if (line <= 1) {
code = '_=>' + ' '.repeat(col < 4 ? 0 : col - 4) + '_()\n' + comment + '\n';
} else {
code =
comment +
'\n'.repeat(line - 2) +
'_=>\n' +
' '.repeat(col < 1 ? 0 : col - 1) +
'_()\n';
}
if (sourceMap) {
code += '//# sourceMappingURL=' + sourceMap;
} else if (filename) {
code += '//# sourceURL=' + filename;
}
let fn: FakeFunction<T>;
try {
// eslint-disable-next-line no-eval
fn = (0, eval)(code);
} catch (x) {
// If eval fails, such as if in an environment that doesn't support it,
// we fallback to creating a function here. It'll still have the right
// name but it'll lose line/column number and file name.
fn = function (_) {
return _();
};
}
// $FlowFixMe[cannot-write]
Object.defineProperty(fn, 'name', {value: name || '(anonymous)'});
// $FlowFixMe[prop-missing]
fn.displayName = name;
return fn;
}
// This matches either of these V8 formats.
// at name (filename:0:0)
// at filename:0:0
// at async filename:0:0
const frameRegExp =
/^ {3} at (?:(.+) \(([^\)]+):(\d+):(\d+)\)|(?:async )?([^\)]+):(\d+):(\d+))$/;
function buildFakeCallStack<T>(
response: Response,
stack: string,
innerCall: () => T,
): () => T {
const frames = stack.split('\n');
let callStack = innerCall;
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
let fn = fakeFunctionCache.get(frame);
if (fn === undefined) {
const parsed = frameRegExp.exec(frame);
if (!parsed) {
// We assume the server returns a V8 compatible stack trace.
continue;
}
const name = parsed[1] || '';
const filename = parsed[2] || parsed[5] || '';
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
const sourceMap = response._debugFindSourceMapURL
? response._debugFindSourceMapURL(filename)
: null;
fn = createFakeFunction(name, filename, sourceMap, line, col);
// TODO: This cache should technically live on the response since the _debugFindSourceMapURL
// function is an input and can vary by response.
fakeFunctionCache.set(frame, fn);
}
callStack = fn.bind(null, callStack);
}
return callStack;
}
function initializeFakeTask(
response: Response,
debugInfo: ReactComponentInfo | ReactAsyncInfo,
): null | ConsoleTask {
if (taskCache === null || typeof debugInfo.stack !== 'string') {
return null;
}
const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
const stack: string = debugInfo.stack;
const cachedEntry = taskCache.get((componentInfo: any));
if (cachedEntry !== undefined) {
return cachedEntry;
}
const ownerTask =
componentInfo.owner == null
? null
: initializeFakeTask(response, componentInfo.owner);
// eslint-disable-next-line react-internal/no-production-logging
const createTaskFn = (console: any).createTask.bind(
console,
getServerComponentTaskName(componentInfo),
);
const callStack = buildFakeCallStack(response, stack, createTaskFn);
if (ownerTask === null) {
const rootTask = response._debugRootTask;
if (rootTask != null) {
return rootTask.run(callStack);
} else {
return callStack();
}
} else {
return ownerTask.run(callStack);
}
}
function resolveDebugInfo(
response: Response,
id: number,
@@ -1594,6 +1820,10 @@ function resolveDebugInfo(
'resolveDebugInfo should never be called in production mode. This is a bug in React.',
);
}
// We eagerly initialize the fake task because this resolving happens outside any
// render phase so we're not inside a user space stack at this point. If we waited
// to initialize it when we need it, we might be inside user code.
initializeFakeTask(response, debugInfo);
const chunk = getChunk(response, id);
const chunkDebugInfo: ReactDebugInfo =
chunk._debugInfo || (chunk._debugInfo = []);
@@ -1615,12 +1845,34 @@ function resolveConsoleEntry(
const payload: [string, string, null | ReactComponentInfo, string, mixed] =
parseModel(response, value);
const methodName = payload[0];
// TODO: Restore the fake stack before logging.
// const stackTrace = payload[1];
// const owner = payload[2];
const stackTrace = payload[1];
const owner = payload[2];
const env = payload[3];
const args = payload.slice(4);
printToConsole(methodName, args, env);
if (!enableOwnerStacks) {
// Printing with stack isn't really limited to owner stacks but
// we gate it behind the same flag for now while iterating.
printToConsole(methodName, args, env);
return;
}
const callStack = buildFakeCallStack(
response,
stackTrace,
printToConsole.bind(null, methodName, args, env),
);
if (owner != null) {
const task = initializeFakeTask(response, owner);
if (task !== null) {
task.run(callStack);
return;
}
}
const rootTask = response._debugRootTask;
if (rootTask != null) {
rootTask.run(callStack);
return;
}
callStack();
}
function mergeBuffer(
+18 -10
View File
@@ -868,7 +868,12 @@ function findCommonAncestorIndex(rootStack: any, hookStack: any) {
}
function isReactWrapper(functionName: any, wrapperName: string) {
return parseHookName(functionName) === wrapperName;
const hookName = parseHookName(functionName);
if (wrapperName === 'HostTransitionStatus') {
return hookName === wrapperName || hookName === 'FormStatus';
}
return hookName === wrapperName;
}
function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
@@ -878,21 +883,24 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
return -1;
}
for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) {
// Note: there is no guarantee that we will find the top-most primitive frame in the stack
// For React Native (uses Hermes), these source fields will be identical and skipped
if (primitiveStack[i].source !== hookStack[i].source) {
// If the next frame is a method from the dispatcher, we
// assume that the next frame after that is the actual public API call.
// This prohibits nesting dispatcher calls in hooks.
// If the next two frames are functions called `useX` then we assume that they're part of the
// wrappers that the React package or other packages adds around the dispatcher.
if (
i < hookStack.length - 1 &&
isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
) {
i++;
// Guard against the dispatcher call being inlined.
// At this point we wouldn't be able to recover the actual React Hook name.
if (i < hookStack.length - 1) {
i++;
}
}
if (
i < hookStack.length - 1 &&
isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
) {
i++;
}
return i;
}
}
@@ -1040,7 +1048,7 @@ function buildTree(
const levelChild: HooksNode = {
id,
isStateEditable,
name: name,
name,
value: hook.value,
subHooks: [],
debugInfo: debugInfo,
-1
View File
@@ -279,7 +279,6 @@ function initialize(socket: WebSocket) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
store = new Store(bridge, {
checkBridgeProtocolCompatibility: true,
supportsNativeInspection: true,
supportsTraceUpdates: true,
});
+1
View File
@@ -97,6 +97,7 @@ function createBridgeAndStore() {
// At this time, the timeline can only parse Chrome performance profiles.
supportsTimeline: __IS_CHROME__,
supportsTraceUpdates: true,
supportsNativeInspection: true,
});
if (!isProfiling) {
-1
View File
@@ -37,7 +37,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store {
return new Store(bridge, {
checkBridgeProtocolCompatibility: true,
supportsTraceUpdates: true,
supportsNativeInspection: true,
...config,
});
}
-1
View File
@@ -23,7 +23,6 @@ export function createStore(bridge: FrontendBridge, config?: Config): Store {
checkBridgeProtocolCompatibility: true,
supportsTraceUpdates: true,
supportsTimeline: true,
supportsNativeInspection: true,
...config,
});
}
+31 -2
View File
@@ -1915,8 +1915,12 @@ describe('Store', () => {
});
});
// @reactVersion >= 18.0
it('from react get counted', () => {
// In React 19, JSX warnings were moved into the renderer - https://github.com/facebook/react/pull/29088
// When the error is emitted, the source fiber of this error is not yet mounted
// So DevTools can't connect the error and the fiber
// TODO(hoxyq): update RDT to keep track of such fibers
// @reactVersion >= 19.0
it('from react get counted [React >= 19]', () => {
function Example() {
return [<Child />];
}
@@ -1938,6 +1942,31 @@ describe('Store', () => {
`);
});
// @reactVersion >= 18.0
// @reactVersion < 19.0
it('from react get counted [React 18.x]', () => {
function Example() {
return [<Child />];
}
function Child() {
return null;
}
withErrorsOrWarningsIgnored(
['Warning: Each child in a list should have a unique "key" prop'],
() => {
act(() => render(<Example />));
},
);
expect(store).toMatchInlineSnapshot(`
1, 0
[root]
<Example>
<Child>
`);
});
// @reactVersion >= 18.0
it('can be cleared for the whole app', () => {
function Example() {
+4 -2
View File
@@ -172,7 +172,7 @@ export default class Store extends EventEmitter<{
_rootIDToRendererID: Map<number, number> = new Map();
// These options may be initially set by a configuration option when constructing the Store.
_supportsNativeInspection: boolean = true;
_supportsNativeInspection: boolean = false;
_supportsReloadAndProfile: boolean = false;
_supportsTimeline: boolean = false;
_supportsTraceUpdates: boolean = false;
@@ -216,7 +216,9 @@ export default class Store extends EventEmitter<{
supportsTimeline,
supportsTraceUpdates,
} = config;
this._supportsNativeInspection = supportsNativeInspection !== false;
if (supportsNativeInspection) {
this._supportsNativeInspection = true;
}
if (supportsReloadAndProfile) {
this._supportsReloadAndProfile = true;
}
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": {
"cross-spawn": "^5.0.1",
"electron": "^23.1.2",
"ip": "^1.1.4",
"internal-ip": "^6.2.0",
"minimist": "^1.2.3",
"react-devtools-core": "5.2.0",
"update-notifier": "^2.1.0"
+2 -2
View File
@@ -1,11 +1,11 @@
const {clipboard, shell, contextBridge} = require('electron');
const fs = require('fs');
const {address} = require('ip');
const internalIP = require('internal-ip');
// Expose protected methods so that render process does not need unsafe node integration
contextBridge.exposeInMainWorld('api', {
electron: {clipboard, shell},
ip: {address},
ip: {address: internalIP.v4.sync},
getDevTools() {
let devtools;
try {
@@ -2358,6 +2358,7 @@ export function getResource(
type: string,
currentProps: any,
pendingProps: any,
currentResource: null | Resource,
): null | Resource {
const resourceRoot = getCurrentResourceRoot();
if (!resourceRoot) {
@@ -2430,9 +2431,44 @@ export function getResource(
);
}
}
if (currentProps && currentResource === null) {
// This node was previously an Instance type and is becoming a Resource type
// For now we error because we don't support flavor changes
let diff = '';
if (__DEV__) {
diff = `
- ${describeLinkForResourceErrorDEV(currentProps)}
+ ${describeLinkForResourceErrorDEV(pendingProps)}`;
}
throw new Error(
'Expected <link> not to update to be updated to a stylesheet with precedence.' +
' Check the `rel`, `href`, and `precedence` props of this component.' +
' Alternatively, check whether two different <link> components render in the same slot or share the same key.' +
diff,
);
}
return resource;
} else {
if (currentProps && currentResource !== null) {
// This node was previously a Resource type and is becoming an Instance type
// For now we error because we don't support flavor changes
let diff = '';
if (__DEV__) {
diff = `
- ${describeLinkForResourceErrorDEV(currentProps)}
+ ${describeLinkForResourceErrorDEV(pendingProps)}`;
}
throw new Error(
'Expected stylesheet with precedence to not be updated to a different kind of <link>.' +
' Check the `rel`, `href`, and `precedence` props of this component.' +
' Alternatively, check whether two different <link> components render in the same slot or share the same key.' +
diff,
);
}
return null;
}
return null;
}
case 'script': {
const async = pendingProps.async;
@@ -2473,6 +2509,49 @@ export function getResource(
}
}
function describeLinkForResourceErrorDEV(props: any) {
if (__DEV__) {
let describedProps = 0;
let description = '<link';
if (typeof props.rel === 'string') {
describedProps++;
description += ` rel="${props.rel}"`;
} else if (hasOwnProperty.call(props, 'rel')) {
describedProps++;
description += ` rel="${
props.rel === null ? 'null' : 'invalid type ' + typeof props.rel
}"`;
}
if (typeof props.href === 'string') {
describedProps++;
description += ` href="${props.href}"`;
} else if (hasOwnProperty.call(props, 'href')) {
describedProps++;
description += ` href="${
props.href === null ? 'null' : 'invalid type ' + typeof props.href
}"`;
}
if (typeof props.precedence === 'string') {
describedProps++;
description += ` precedence="${props.precedence}"`;
} else if (hasOwnProperty.call(props, 'precedence')) {
describedProps++;
description += ` precedence={${
props.precedence === null
? 'null'
: 'invalid type ' + typeof props.precedence
}}`;
}
if (Object.getOwnPropertyNames(props).length > describedProps) {
description += ' ...';
}
description += ' />';
return description;
}
return '';
}
function styleTagPropsFromRawProps(
rawProps: StyleTagQualifyingProps,
): StyleTagProps {
@@ -20,6 +20,14 @@ export function scheduleWork(callback: () => void) {
callback();
}
export function scheduleMicrotask(callback: () => void) {
// While this defies the method name the legacy builds have special
// overrides that make work scheduling sync. At the moment scheduleMicrotask
// isn't used by any legacy APIs so this is somewhat academic but if they
// did in the future we'd probably want to have this be in sync with scheduleWork
callback();
}
export function flushBuffered(destination: Destination) {}
export function beginWriting(destination: Destination) {}
@@ -10,6 +10,7 @@
'use strict';
import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
// Polyfills for test environment
global.ReadableStream =
@@ -21,12 +22,16 @@ let ReactDOMServer;
let Scheduler;
let assertLog;
let container;
let act;
describe('ReactClassComponentPropResolutionFizz', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOMServer = require('react-dom/server.browser');
assertLog = require('internal-test-utils').assertLog;
container = document.createElement('div');
@@ -37,6 +42,17 @@ describe('ReactClassComponentPropResolutionFizz', () => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
@@ -57,7 +73,7 @@ describe('ReactClassComponentPropResolutionFizz', () => {
return text;
}
test('resolves ref and default props before calling lifecycle methods', async () => {
it('resolves ref and default props before calling lifecycle methods', async () => {
function getPropKeys(props) {
return Object.keys(props).join(', ');
}
@@ -80,11 +96,13 @@ describe('ReactClassComponentPropResolutionFizz', () => {
};
// `ref` should never appear as a prop. `default` always should.
const ref = React.createRef();
const stream = await ReactDOMServer.renderToReadableStream(
<Component text="Yay" ref={ref} />,
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<Component text="Yay" ref={ref} />),
);
await readIntoContainer(stream);
assertLog([
'constructor: text, default',
'componentWillMount: text, default',
@@ -13,6 +13,7 @@ import {
insertNodesAndExecuteScripts,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
// Polyfills for test environment
global.ReadableStream =
@@ -33,13 +34,14 @@ let Suspense;
describe('ReactDOMFizzForm', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
useDeferredValue = React.useDeferredValue;
Suspense = React.Suspense;
act = require('internal-test-utils').act;
assertLog = require('internal-test-utils').assertLog;
waitForPaint = require('internal-test-utils').waitForPaint;
container = document.createElement('div');
@@ -50,6 +52,17 @@ describe('ReactDOMFizzForm', () => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
@@ -76,7 +89,9 @@ describe('ReactDOMFizzForm', () => {
return useDeferredValue('Final', 'Initial');
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toEqual('Initial');
@@ -107,7 +122,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toEqual('Loading...');
@@ -153,8 +170,9 @@ describe('ReactDOMFizzForm', () => {
const cRef = React.createRef();
// The server renders using the "initial" value for B.
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
assertLog(['A', 'B [Initial]', 'C']);
expect(getVisibleChildren(container)).toEqual(
+57 -15
View File
@@ -10,6 +10,7 @@
'use strict';
import {insertNodesAndExecuteScripts} from '../test-utils/FizzTestUtils';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
// Polyfills for test environment
global.ReadableStream =
@@ -24,10 +25,13 @@ let ReactDOMClient;
let useFormStatus;
let useOptimistic;
let useActionState;
let Scheduler;
describe('ReactDOMFizzForm', () => {
beforeEach(() => {
jest.resetModules();
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
React = require('react');
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
@@ -48,6 +52,14 @@ describe('ReactDOMFizzForm', () => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
});
return maybePromise;
}
function submit(submitter) {
const form = submitter.form || submitter;
if (!submitter.form) {
@@ -96,7 +108,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
@@ -143,7 +157,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
@@ -175,7 +191,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
await expect(async () => {
await act(async () => {
@@ -197,7 +215,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
// This should ideally warn because only the client provides a function that doesn't line up.
await act(async () => {
@@ -231,7 +251,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
let root;
await act(async () => {
@@ -278,7 +300,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
let root;
await act(async () => {
@@ -334,7 +358,9 @@ describe('ReactDOMFizzForm', () => {
// Specifying the extra form fields are a DEV error, but we expect it
// to eventually still be patched up after an update.
await expect(async () => {
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
}).toErrorDev([
'Cannot specify a encType or method for a form that specifies a function as the action.',
@@ -379,7 +405,9 @@ describe('ReactDOMFizzForm', () => {
return 'Pending: ' + pending;
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toBe('Pending: false');
@@ -400,7 +428,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
// Dispatch an event before hydration
@@ -441,7 +471,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
submit(container.getElementsByTagName('input')[1]);
@@ -463,7 +495,9 @@ describe('ReactDOMFizzForm', () => {
return optimisticState;
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toBe('hi');
@@ -484,7 +518,9 @@ describe('ReactDOMFizzForm', () => {
return state;
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
expect(container.textContent).toBe('0');
@@ -521,7 +557,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
const form = container.firstChild;
@@ -581,7 +619,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
const input = container.getElementsByTagName('input')[1];
@@ -651,7 +691,9 @@ describe('ReactDOMFizzForm', () => {
);
}
const stream = await ReactDOMServer.renderToReadableStream(<App />);
const stream = await serverAct(() =>
ReactDOMServer.renderToReadableStream(<App />),
);
await readIntoContainer(stream);
const barField = container.querySelector('[name=bar]');
@@ -9,6 +9,8 @@
'use strict';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
// Polyfills for test environment
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
@@ -17,15 +19,33 @@ global.TextEncoder = require('util').TextEncoder;
let React;
let ReactDOMFizzServer;
let Suspense;
let Scheduler;
let act;
describe('ReactDOMFizzServerBrowser', () => {
beforeEach(() => {
jest.resetModules();
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOMFizzServer = require('react-dom/server.browser');
Suspense = React.Suspense;
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
const theError = new Error('This is an error');
function Throw() {
throw theError;
@@ -48,18 +68,20 @@ describe('ReactDOMFizzServerBrowser', () => {
}
it('should call renderToReadableStream', async () => {
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>hello world</div>,
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>),
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
it('should emit DOCTYPE at the root of the document', async () => {
const stream = await ReactDOMFizzServer.renderToReadableStream(
<html>
<body>hello world</body>
</html>,
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<html>
<body>hello world</body>
</html>,
),
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
@@ -68,13 +90,12 @@ describe('ReactDOMFizzServerBrowser', () => {
});
it('should emit bootstrap script src at the end', async () => {
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>hello world</div>,
{
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
}),
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
@@ -93,23 +114,22 @@ describe('ReactDOMFizzServerBrowser', () => {
return 'Done';
}
let isComplete = false;
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
),
);
stream.allReady.then(() => (isComplete = true));
await jest.runAllTimers();
expect(isComplete).toBe(false);
// Resolve the loading.
hasLoaded = true;
await resolve();
await jest.runAllTimers();
await serverAct(() => resolve());
expect(isComplete).toBe(true);
@@ -123,15 +143,17 @@ describe('ReactDOMFizzServerBrowser', () => {
const reportedErrors = [];
let caughtError = null;
try {
await ReactDOMFizzServer.renderToReadableStream(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
} catch (error) {
caughtError = error;
@@ -144,17 +166,19 @@ describe('ReactDOMFizzServerBrowser', () => {
const reportedErrors = [];
let caughtError = null;
try {
await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
} catch (error) {
caughtError = error;
@@ -165,17 +189,19 @@ describe('ReactDOMFizzServerBrowser', () => {
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
const reportedErrors = [];
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
const result = await readResult(stream);
@@ -186,18 +212,20 @@ describe('ReactDOMFizzServerBrowser', () => {
it('should be able to complete by aborting even if the promise never resolves', async () => {
const errors = [];
const controller = new AbortController();
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
controller.abort();
@@ -211,20 +239,20 @@ describe('ReactDOMFizzServerBrowser', () => {
it('should reject if aborting before the shell is complete', async () => {
const errors = [];
const controller = new AbortController();
const promise = ReactDOMFizzServer.renderToReadableStream(
<div>
<InfiniteSuspend />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const promise = serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<InfiniteSuspend />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
await jest.runAllTimers();
const theReason = new Error('aborted for reasons');
controller.abort(theReason);
@@ -249,16 +277,18 @@ describe('ReactDOMFizzServerBrowser', () => {
</Suspense>
);
}
const streamPromise = ReactDOMFizzServer.renderToReadableStream(
<div>
<App />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const streamPromise = serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<App />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
let caughtError = null;
@@ -277,18 +307,20 @@ describe('ReactDOMFizzServerBrowser', () => {
const theReason = new Error('aborted for reasons');
controller.abort(theReason);
const promise = ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const promise = serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
// Technically we could still continue rendering the shell but currently the
@@ -317,17 +349,19 @@ describe('ReactDOMFizzServerBrowser', () => {
return 'Done';
}
const errors = [];
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<div>
<Suspense fallback={<div>Loading</div>}>
<Wait />
</Suspense>
</div>,
{
onError(x) {
errors.push(x.message);
},
},
},
),
);
stream.allReady.then(() => (isComplete = true));
@@ -344,9 +378,7 @@ describe('ReactDOMFizzServerBrowser', () => {
]);
hasLoaded = true;
resolve();
await jest.runAllTimers();
await serverAct(() => resolve());
expect(rendered).toBe(false);
expect(isComplete).toBe(true);
@@ -366,14 +398,16 @@ describe('ReactDOMFizzServerBrowser', () => {
// as such for now. I don't think it needs to be maintained if in the future
// the view sizes change or become dynamic becasue of the use of byobRequest
let stream;
stream = await ReactDOMFizzServer.renderToReadableStream(
<>
<div>
<span>{''}</span>
</div>
<div>{str492}</div>
<div>{str492}</div>
</>,
stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<>
<div>
<span>{''}</span>
</div>
<div>{str492}</div>
<div>{str492}</div>
</>,
),
);
let result;
@@ -385,10 +419,12 @@ describe('ReactDOMFizzServerBrowser', () => {
// this size 2049 was chosen to be a couple base 2 orders larger than the current view
// size. if the size changes in the future hopefully this will still exercise
// a chunk that is too large for the view size.
stream = await ReactDOMFizzServer.renderToReadableStream(
<>
<div>{str2049}</div>
</>,
stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<>
<div>{str2049}</div>
</>,
),
);
result = await readResult(stream);
@@ -419,13 +455,15 @@ describe('ReactDOMFizzServerBrowser', () => {
const errors = [];
const controller = new AbortController();
await ReactDOMFizzServer.renderToReadableStream(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'a digest';
},
});
await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'a digest';
},
}),
);
controller.abort('foobar');
@@ -456,13 +494,15 @@ describe('ReactDOMFizzServerBrowser', () => {
const errors = [];
const controller = new AbortController();
await ReactDOMFizzServer.renderToReadableStream(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
return 'a digest';
},
});
await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
return 'a digest';
},
}),
);
controller.abort(new Error('uh oh'));
@@ -471,13 +511,15 @@ describe('ReactDOMFizzServerBrowser', () => {
// https://github.com/facebook/react/pull/25534/files - fix transposed escape functions
it('should encode title properly', async () => {
const stream = await ReactDOMFizzServer.renderToReadableStream(
<html>
<head>
<title>foo</title>
</head>
<body>bar</body>
</html>,
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(
<html>
<head>
<title>foo</title>
</head>
<body>bar</body>
</html>,
),
);
const result = await readResult(stream);
@@ -488,14 +530,13 @@ describe('ReactDOMFizzServerBrowser', () => {
it('should support nonce attribute for bootstrap scripts', async () => {
const nonce = 'R4nd0m';
const stream = await ReactDOMFizzServer.renderToReadableStream(
<div>hello world</div>,
{
const stream = await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<div>hello world</div>, {
nonce,
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
},
}),
);
const result = await readResult(stream);
expect(result).toMatchInlineSnapshot(
@@ -523,14 +564,16 @@ describe('ReactDOMFizzServerBrowser', () => {
let caughtError = null;
try {
await ReactDOMFizzServer.renderToReadableStream(<App />, {
onError(error) {
errors.push(error.message);
},
onPostpone(reason) {
postponed.push(reason);
},
});
await serverAct(() =>
ReactDOMFizzServer.renderToReadableStream(<App />, {
onError(error) {
errors.push(error.message);
},
onPostpone(reason) {
postponed.push(reason);
},
}),
);
} catch (error) {
caughtError = error;
}
@@ -9,6 +9,8 @@
'use strict';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
import {
getVisibleChildren,
insertNodesAndExecuteScripts,
@@ -26,10 +28,17 @@ let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let container;
let Scheduler;
let act;
describe('ReactDOMFizzStaticBrowser', () => {
beforeEach(() => {
jest.resetModules();
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMFizzServer = require('react-dom/server.browser');
@@ -45,6 +54,17 @@ describe('ReactDOMFizzStaticBrowser', () => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
const theError = new Error('This is an error');
function Throw() {
throw theError;
@@ -113,17 +133,21 @@ describe('ReactDOMFizzStaticBrowser', () => {
// @gate experimental
it('should call prerender', async () => {
const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>);
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>),
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
});
// @gate experimental
it('should emit DOCTYPE at the root of the document', async () => {
const result = await ReactDOMFizzStatic.prerender(
<html>
<body>hello world</body>
</html>,
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(
<html>
<body>hello world</body>
</html>,
),
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
@@ -133,11 +157,13 @@ describe('ReactDOMFizzStaticBrowser', () => {
// @gate experimental
it('should emit bootstrap script src at the end', async () => {
const result = await ReactDOMFizzStatic.prerender(<div>hello world</div>, {
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
});
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello world</div>, {
bootstrapScriptContent: 'INIT();',
bootstrapScripts: ['init.js'],
bootstrapModules: ['init.mjs'],
}),
);
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
@@ -155,12 +181,14 @@ describe('ReactDOMFizzStaticBrowser', () => {
}
return 'Done';
}
const resultPromise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
const resultPromise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback="Loading">
<Wait />
</Suspense>
</div>,
),
);
await jest.runAllTimers();
@@ -171,9 +199,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
const result = await resultPromise;
const prelude = await readContent(result.prelude);
expect(prelude).toMatchInlineSnapshot(
`"<div><!--$-->Done<!-- --><!--/$--></div>"`,
);
expect(prelude).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
});
// @gate experimental
@@ -181,15 +207,17 @@ describe('ReactDOMFizzStaticBrowser', () => {
const reportedErrors = [];
let caughtError = null;
try {
await ReactDOMFizzStatic.prerender(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Throw />
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
} catch (error) {
caughtError = error;
@@ -203,17 +231,19 @@ describe('ReactDOMFizzStaticBrowser', () => {
const reportedErrors = [];
let caughtError = null;
try {
await ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<Throw />}>
<InfiniteSuspend />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
} catch (error) {
caughtError = error;
@@ -225,17 +255,19 @@ describe('ReactDOMFizzStaticBrowser', () => {
// @gate experimental
it('should not error the stream when an error is thrown inside suspense boundary', async () => {
const reportedErrors = [];
const result = await ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
const result = await serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<Throw />
</Suspense>
</div>,
{
onError(x) {
reportedErrors.push(x);
},
},
},
),
);
const prelude = await readContent(result.prelude);
@@ -247,21 +279,22 @@ describe('ReactDOMFizzStaticBrowser', () => {
it('should be able to complete by aborting even if the promise never resolves', async () => {
const errors = [];
const controller = new AbortController();
const resultPromise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
);
await jest.runAllTimers();
);
});
controller.abort();
@@ -277,16 +310,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
it('should reject if aborting before the shell is complete', async () => {
const errors = [];
const controller = new AbortController();
const promise = ReactDOMFizzStatic.prerender(
<div>
<InfiniteSuspend />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const promise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<InfiniteSuspend />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
await jest.runAllTimers();
@@ -316,16 +351,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
</Suspense>
);
}
const streamPromise = ReactDOMFizzStatic.prerender(
<div>
<App />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const streamPromise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<App />
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
let caughtError = null;
@@ -345,18 +382,20 @@ describe('ReactDOMFizzStaticBrowser', () => {
const theReason = new Error('aborted for reasons');
controller.abort(theReason);
const promise = ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
const promise = serverAct(() =>
ReactDOMFizzStatic.prerender(
<div>
<Suspense fallback={<div>Loading</div>}>
<InfiniteSuspend />
</Suspense>
</div>,
{
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
},
},
),
);
// Technically we could still continue rendering the shell but currently the
@@ -396,12 +435,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
const errors = [];
const controller = new AbortController();
const resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'a digest';
},
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x);
return 'a digest';
},
});
});
controller.abort('foobar');
@@ -436,12 +478,15 @@ describe('ReactDOMFizzStaticBrowser', () => {
const errors = [];
const controller = new AbortController();
const resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
return 'a digest';
},
let resultPromise;
await serverAct(() => {
resultPromise = ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
return 'a digest';
},
});
});
controller.abort(new Error('uh oh'));
@@ -471,14 +516,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -513,14 +562,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -552,14 +605,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -600,14 +657,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -641,14 +702,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -682,14 +747,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
const html = await readContent(concat(prerendered.prelude, content));
@@ -748,9 +817,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
{virtual: true},
);
const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
bootstrapScripts: ['init.js'],
});
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
bootstrapScripts: ['init.js'],
}),
);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
@@ -779,9 +850,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
]);
prerendering = false;
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(content);
@@ -860,14 +933,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -911,14 +988,18 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(prerendered.prelude);
@@ -957,7 +1038,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
// TODO: This should actually be null because we should've been able to fully
// resolve the render on the server eventually, even though the fallback postponed.
// So we should not need to resume.
@@ -967,9 +1050,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
expect(getVisibleChildren(container)).toEqual(<div>Outer</div>);
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(resumed);
@@ -1020,7 +1105,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
@@ -1033,14 +1120,16 @@ describe('ReactDOMFizzStaticBrowser', () => {
prerendering = false;
const errors = [];
const resumed = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
onError(x) {
errors.push(x.message);
const resumed = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
onError(x) {
errors.push(x.message);
},
},
},
),
);
expect(errors).toEqual([
@@ -1085,7 +1174,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
@@ -1098,15 +1189,17 @@ describe('ReactDOMFizzStaticBrowser', () => {
const errors = [];
const resumedPromise = ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: controller.signal,
onError(x) {
errors.push(x);
const resumedPromise = serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
{
signal: controller.signal,
onError(x) {
errors.push(x);
},
},
},
),
);
controller.abort('abort');
@@ -1160,16 +1253,20 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
prerendering = false;
const resumedPromise = ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const resumedPromise = serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await jest.runAllTimers();
@@ -1204,16 +1301,20 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
expect(await readContent(prerendered.prelude)).toBe('');
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(content)).toBe(
@@ -1246,16 +1347,20 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
expect(await readContent(prerendered.prelude)).toBe('');
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(content)).toBe(
@@ -1293,16 +1398,20 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
expect(await readContent(prerendered.prelude)).toBe('');
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
expect(await readContent(content)).toBe(
@@ -1356,9 +1465,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
onHeaders,
});
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
onHeaders,
}),
);
expect(prerendered.postponed).not.toBe(null);
prerendering = false;
@@ -1375,9 +1486,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
}),
);
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
const decoder = new TextDecoder();
@@ -1391,7 +1504,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
await 1;
hasLoaded = true;
resolve();
await serverAct(resolve);
while (true) {
({value, done} = await reader.read());
@@ -1425,10 +1538,12 @@ describe('ReactDOMFizzStaticBrowser', () => {
throw new Error('bad onHeaders');
}
const prerendered = await ReactDOMFizzStatic.prerender(<div>hello</div>, {
onHeaders,
onError,
});
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<div>hello</div>, {
onHeaders,
onError,
}),
);
expect(prerendered.postponed).toBe(null);
expect(errors).toEqual(['bad onHeaders']);
@@ -1469,9 +1584,11 @@ describe('ReactDOMFizzStaticBrowser', () => {
{virtual: true},
);
const prerendered = await ReactDOMFizzStatic.prerender(<App />, {
bootstrapScripts: ['init.js'],
});
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
bootstrapScripts: ['init.js'],
}),
);
const postponedSerializedState = JSON.stringify(prerendered.postponed);
@@ -1497,9 +1614,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
prerendering = false;
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(postponedSerializedState),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedSerializedState)),
);
await readIntoContainer(content);
@@ -1542,7 +1658,9 @@ describe('ReactDOMFizzStaticBrowser', () => {
);
}
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoContainer(prerendered.prelude);
@@ -1550,9 +1668,8 @@ describe('ReactDOMFizzStaticBrowser', () => {
isPrerendering = false;
const dynamic = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(postponedState),
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
);
await readIntoContainer(dynamic);
@@ -9,6 +9,8 @@
'use strict';
import {patchMessageChannel} from '../../../../scripts/jest/patchMessageChannel';
import {
getVisibleChildren,
insertNodesAndExecuteScripts,
@@ -25,10 +27,16 @@ let ReactDOMFizzServer;
let ReactDOMFizzStatic;
let Suspense;
let container;
let Scheduler;
let act;
describe('ReactDOMFizzStaticFloat', () => {
beforeEach(() => {
jest.resetModules();
Scheduler = require('scheduler');
patchMessageChannel(Scheduler);
act = require('internal-test-utils').act;
React = require('react');
ReactDOM = require('react-dom');
ReactDOMFizzServer = require('react-dom/server.browser');
@@ -44,6 +52,17 @@ describe('ReactDOMFizzStaticFloat', () => {
document.body.removeChild(container);
});
async function serverAct(callback) {
let maybePromise;
await act(() => {
maybePromise = callback();
if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => {});
}
});
return maybePromise;
}
async function readIntoContainer(stream) {
const reader = stream.getReader();
let result = '';
@@ -135,7 +154,9 @@ describe('ReactDOMFizzStaticFloat', () => {
virtual: true,
});
const prerendered = await ReactDOMFizzStatic.prerender(<App />);
const prerendered = await serverAct(() =>
ReactDOMFizzStatic.prerender(<App />),
);
expect(prerendered.postponed).not.toBe(null);
await readIntoContainer(prerendered.prelude);
@@ -171,28 +192,28 @@ describe('ReactDOMFizzStaticFloat', () => {
]);
prerendering = false;
const content = await ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
const content = await serverAct(() =>
ReactDOMFizzServer.resume(
<App />,
JSON.parse(JSON.stringify(prerendered.postponed)),
),
);
await readIntoContainer(content);
// Dispatch load event to injected stylesheet
const linkCreds = document.querySelector(
'link[rel="stylesheet"][href="style creds"]',
);
const linkAnon = document.querySelector(
'link[rel="stylesheet"][href="style anon"]',
);
const event = document.createEvent('Events');
event.initEvent('load', true, true);
linkCreds.dispatchEvent(event);
linkAnon.dispatchEvent(event);
// Wait for the instruction microtasks to flush.
await 0;
await 0;
await act(() => {
// Dispatch load event to injected stylesheet
const linkCreds = document.querySelector(
'link[rel="stylesheet"][href="style creds"]',
);
const linkAnon = document.querySelector(
'link[rel="stylesheet"][href="style anon"]',
);
const event = document.createEvent('Events');
event.initEvent('load', true, true);
linkCreds.dispatchEvent(event);
linkAnon.dispatchEvent(event);
});
expect(getVisibleChildren(document)).toEqual(
<html>
+150 -50
View File
@@ -1020,15 +1020,15 @@ describe('ReactDOMForm', () => {
assertLog(['0']);
expect(container.textContent).toBe('0');
await act(() => dispatch('increment'));
await act(() => startTransition(() => dispatch('increment')));
assertLog(['Async action started [1]', 'Pending 0']);
expect(container.textContent).toBe('Pending 0');
// Dispatch a few more actions. None of these will start until the previous
// one finishes.
await act(() => dispatch('increment'));
await act(() => dispatch('decrement'));
await act(() => dispatch('increment'));
await act(() => startTransition(() => dispatch('increment')));
await act(() => startTransition(() => dispatch('decrement')));
await act(() => startTransition(() => dispatch('increment')));
assertLog([]);
// Each action starts as soon as the previous one finishes.
@@ -1067,7 +1067,7 @@ describe('ReactDOMForm', () => {
// Perform an action. This will increase the state by 1, as defined by the
// stepSize prop.
await act(() => increment());
await act(() => startTransition(() => increment()));
assertLog(['Pending 0', '1']);
// Now increase the stepSize prop to 10. Subsequent steps will increase
@@ -1076,7 +1076,7 @@ describe('ReactDOMForm', () => {
assertLog(['1']);
// Increment again. The state should increase by 10.
await act(() => increment());
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
@@ -1113,11 +1113,11 @@ describe('ReactDOMForm', () => {
await act(() => root.render(<App />));
assertLog(['A']);
await act(() => action('B'));
await act(() => startTransition(() => action('B')));
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => action('C'));
await act(() => action('D'));
await act(() => startTransition(() => action('C')));
await act(() => startTransition(() => action('D')));
assertLog([]);
await act(() => resolveText('B'));
@@ -1151,10 +1151,10 @@ describe('ReactDOMForm', () => {
// Dispatch two actions. The first one is async, so it forces the second
// one into an async queue.
await act(() => action('First action'));
await act(() => startTransition(() => action('First action')));
assertLog(['Initial (pending)']);
// This action won't run until the first one finishes.
await act(() => action('Second action'));
await act(() => startTransition(() => action('Second action')));
// While the first action is still pending, update a prop. This causes the
// inline action implementation to change, but it should not affect the
@@ -1169,7 +1169,9 @@ describe('ReactDOMForm', () => {
// Confirm that if we dispatch yet another action, it uses the updated
// action implementation.
await expect(act(() => action('Third action'))).rejects.toThrow('Oops!');
await expect(
act(() => startTransition(() => action('Third action'))),
).rejects.toThrow('Oops!');
},
);
@@ -1192,7 +1194,7 @@ describe('ReactDOMForm', () => {
// Perform an action. This will increase the state by 1, as defined by the
// stepSize prop.
await act(() => increment());
await act(() => startTransition(() => increment()));
assertLog(['Pending 0', '1']);
// Now increase the stepSize prop to 10. Subsequent steps will increase
@@ -1201,7 +1203,7 @@ describe('ReactDOMForm', () => {
assertLog(['1']);
// Increment again. The state should increase by 10.
await act(() => increment());
await act(() => startTransition(() => increment()));
assertLog(['Pending 1', '11']);
});
@@ -1219,12 +1221,12 @@ describe('ReactDOMForm', () => {
await act(() => root.render(<App />));
assertLog(['A']);
await act(() => action(getText('B')));
await act(() => startTransition(() => action(getText('B'))));
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => action('C'));
await act(() => action(getText('D')));
await act(() => action('E'));
await act(() => startTransition(() => action('C')));
await act(() => startTransition(() => action(getText('D'))));
await act(() => startTransition(() => action('E')));
assertLog([]);
await act(() => resolveText('B'));
@@ -1235,14 +1237,12 @@ describe('ReactDOMForm', () => {
// @gate enableAsyncActions
test('useActionState: error handling (sync action)', async () => {
let resetErrorBoundary;
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
resetErrorBoundary = () => this.setState({error: null});
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
@@ -1273,7 +1273,7 @@ describe('ReactDOMForm', () => {
);
assertLog(['A']);
await act(() => action('Oops!'));
await act(() => startTransition(() => action('Oops!')));
assertLog([
// Action begins, error has not thrown yet.
'Pending A',
@@ -1282,31 +1282,16 @@ describe('ReactDOMForm', () => {
'Caught an error: Oops!',
]);
expect(container.textContent).toBe('Caught an error: Oops!');
// Reset the error boundary
await act(() => resetErrorBoundary());
assertLog(['A']);
// Trigger an error again, but this time, perform another action that
// overrides the first one and fixes the error
await act(() => {
action('Oops!');
action('B');
});
assertLog(['Pending A', 'B']);
expect(container.textContent).toBe('B');
});
// @gate enableAsyncActions
test('useActionState: error handling (async action)', async () => {
let resetErrorBoundary;
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
resetErrorBoundary = () => this.setState({error: null});
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
@@ -1338,27 +1323,71 @@ describe('ReactDOMForm', () => {
);
assertLog(['A']);
await act(() => action('Oops!'));
await act(() => startTransition(() => action('Oops!')));
// The first dispatch will update the pending state.
assertLog(['Pending A']);
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
});
// Reset the error boundary
await act(() => resetErrorBoundary());
test('useActionState: when an action errors, subsequent actions are canceled', async () => {
class ErrorBoundary extends React.Component {
state = {error: null};
static getDerivedStateFromError(error) {
return {error};
}
render() {
if (this.state.error !== null) {
return <Text text={'Caught an error: ' + this.state.error.message} />;
}
return this.props.children;
}
}
let action;
function App() {
const [state, dispatch, isPending] = useActionState(async (s, a) => {
Scheduler.log('Start action: ' + a);
const text = await getText(a);
if (text.endsWith('!')) {
throw new Error(text);
}
return text;
}, 'A');
action = dispatch;
const pending = isPending ? 'Pending ' : '';
return <Text text={pending + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
),
);
assertLog(['A']);
// Trigger an error again, but this time, perform another action that
// overrides the first one and fixes the error
await act(() => {
action('Oops!');
action('B');
});
assertLog(['Pending A']);
await act(() => resolveText('B'));
assertLog(['B']);
expect(container.textContent).toBe('B');
await act(() => startTransition(() => action('Oops!')));
assertLog(['Start action: Oops!', 'Pending A']);
// Queue up another action after the one will error.
await act(() => startTransition(() => action('Should never run')));
assertLog([]);
// The first dispatch will update the pending state.
await act(() => resolveText('Oops!'));
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
expect(container.textContent).toBe('Caught an error: Oops!');
// Attempt to dispatch another action. This should not run either.
await act(() =>
startTransition(() => action('This also should never run')),
);
assertLog([]);
expect(container.textContent).toBe('Caught an error: Oops!');
});
// @gate enableAsyncActions
@@ -1399,7 +1428,7 @@ describe('ReactDOMForm', () => {
assertLog(['0']);
expect(container.textContent).toBe('0');
await act(() => dispatch('increment'));
await act(() => startTransition(() => dispatch('increment')));
assertLog(['Async action started [1]', 'Pending 0']);
expect(container.textContent).toBe('Pending 0');
@@ -1408,6 +1437,77 @@ describe('ReactDOMForm', () => {
expect(container.textContent).toBe('1');
});
test('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useActionState(() => {
return state + 1;
}, 0);
dispatch = _dispatch;
return <AsyncText text={'Count: ' + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<Suspense fallback={<Text text="Loading..." />}>
<App />
</Suspense>,
),
);
assertLog(['Suspend! [Count: 0]', 'Loading...']);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
// Dispatch outside of a transition. This will trigger a loading state.
await act(() => dispatch());
assertLog(['Suspend! [Count: 1]', 'Loading...']);
expect(container.textContent).toBe('Loading...');
await act(() => resolveText('Count: 1'));
assertLog(['Count: 1']);
expect(container.textContent).toBe('Count: 1');
// Now dispatch inside of a transition. This one does not trigger a
// loading state.
await act(() => startTransition(() => dispatch()));
assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
expect(container.textContent).toBe('Count: 1');
await act(() => resolveText('Count: 2'));
assertLog(['Count: 2']);
expect(container.textContent).toBe('Count: 2');
});
test('useActionState warns if async action is dispatched outside of a transition', async () => {
let dispatch;
function App() {
const [state, _dispatch] = useActionState(async () => {
return state + 1;
}, 0);
dispatch = _dispatch;
return <AsyncText text={'Count: ' + state} />;
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['Suspend! [Count: 0]']);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);
// Dispatch outside of a transition.
await act(() => dispatch());
assertConsoleErrorDev([
[
'An async function was passed to useActionState, but it was ' +
'dispatched outside of an action context',
{withoutStack: true},
],
]);
assertLog(['Suspend! [Count: 1]']);
expect(container.textContent).toBe('Count: 0');
});
test('uncontrolled form inputs are reset after the action completes', async () => {
const formRef = React.createRef();
const inputRef = React.createRef();
@@ -0,0 +1,123 @@
/**
* 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.
*
* @emails react-core
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
*/
'use strict';
let JSDOM;
let React;
let ReactDOMClient;
let container;
let waitForAll;
describe('ReactDOM HostSingleton', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
// Test Environment
const jsdom = new JSDOM(
'<!DOCTYPE html><html><head></head><body><div id="container">',
{
runScripts: 'dangerously',
},
);
global.window = jsdom.window;
global.document = jsdom.window.document;
container = global.document.getElementById('container');
React = require('react');
ReactDOMClient = require('react-dom/client');
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
});
it('errors when a hoistable component becomes a Resource', async () => {
const errors = [];
function onError(e) {
errors.push(e.message);
}
const root = ReactDOMClient.createRoot(container, {
onUncaughtError: onError,
});
root.render(
<div>
<link rel="preload" href="bar" as="style" />
</div>,
);
await waitForAll([]);
root.render(
<div>
<link rel="stylesheet" href="bar" precedence="default" />
</div>,
);
await waitForAll([]);
if (__DEV__) {
expect(errors).toEqual([
`Expected <link> not to update to be updated to a stylesheet with precedence. Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different <link> components render in the same slot or share the same key.
- <link rel=\"preload\" href=\"bar\" ... />
+ <link rel=\"stylesheet\" href=\"bar\" precedence=\"default\" />`,
]);
} else {
expect(errors).toEqual([
'Expected <link> not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different <link> components render in the same slot or share the same key.',
]);
}
});
it('errors when a hoistable Resource becomes an instance', async () => {
const errors = [];
function onError(e) {
errors.push(e.message);
}
const root = ReactDOMClient.createRoot(container, {
onUncaughtError: onError,
});
root.render(
<div>
<link rel="stylesheet" href="bar" precedence="default" />
</div>,
);
await waitForAll([]);
const event = new window.Event('load');
const preloads = document.querySelectorAll('link[rel="preload"]');
for (let i = 0; i < preloads.length; i++) {
const node = preloads[i];
node.dispatchEvent(event);
}
const stylesheets = document.querySelectorAll('link[rel="preload"]');
for (let i = 0; i < stylesheets.length; i++) {
const node = stylesheets[i];
node.dispatchEvent(event);
}
root.render(
<div>
<link rel="foo" href="bar" />
</div>,
);
await waitForAll([]);
if (__DEV__) {
expect(errors).toEqual([
`Expected stylesheet with precedence to not be updated to a different kind of <link>. Check the \`rel\`, \`href\`, and \`precedence\` props of this component. Alternatively, check whether two different <link> components render in the same slot or share the same key.
- <link rel=\"stylesheet\" href=\"bar\" precedence=\"default\" />
+ <link rel=\"foo\" href=\"bar\" />`,
]);
} else {
expect(errors).toEqual([
'Expected stylesheet with precedence to not be updated to a different kind of <link>. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different <link> components render in the same slot or share the same key.',
]);
}
});
});
@@ -14,7 +14,10 @@ import {
} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
import isArray from 'shared/isArray';
import {enableAddPropertiesFastPath} from 'shared/ReactFeatureFlags';
import {
enableAddPropertiesFastPath,
enableShallowPropDiffing,
} from 'shared/ReactFeatureFlags';
import type {AttributeConfiguration} from './ReactNativeTypes';
@@ -342,7 +345,7 @@ function diffProperties(
// Pattern match on: attributeConfig
if (typeof attributeConfig !== 'object') {
// case: !Object is the default case
if (defaultDiffer(prevProp, nextProp)) {
if (enableShallowPropDiffing || defaultDiffer(prevProp, nextProp)) {
// a normal leaf has changed
(updatePayload || (updatePayload = ({}: {[string]: $FlowFixMe})))[
propKey
@@ -354,6 +357,7 @@ function diffProperties(
) {
// case: CustomAttributeConfiguration
const shouldUpdate =
enableShallowPropDiffing ||
prevProp === undefined ||
(typeof attributeConfig.diff === 'function'
? attributeConfig.diff(prevProp, nextProp)
@@ -449,17 +453,24 @@ function fastAddProperties(
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
let attributeConfig;
let prop;
// Flatten nested style props.
if (isArray(props)) {
for (let i = 0; i < props.length; i++) {
payload = fastAddProperties(payload, props[i], validAttributes);
}
return payload;
}
for (const propKey in props) {
prop = props[propKey];
const prop = props[propKey];
if (prop === undefined) {
continue;
}
attributeConfig = ((validAttributes[propKey]: any): AttributeConfiguration);
const attributeConfig = ((validAttributes[
propKey
]: any): AttributeConfiguration);
if (attributeConfig == null) {
continue;
@@ -477,7 +488,7 @@ function fastAddProperties(
// An atomic prop with custom processing.
newValue = attributeConfig.process(prop);
} else if (typeof attributeConfig.diff === 'function') {
// An atomic prop with custom diffing. We don't do diffing here.
// An atomic prop with custom diffing. We don't need to do diffing when adding props.
newValue = prop;
}
@@ -489,17 +500,6 @@ function fastAddProperties(
continue;
}
// Not-atomic prop that needs to be flattened. Likely it's the 'style' prop.
// It can be an array.
if (isArray(prop)) {
for (let i = 0; i < prop.length; i++) {
payload = fastAddProperties(payload, prop[i], attributeConfig);
}
continue;
}
// Or it can be an object.
payload = fastAddProperties(payload, prop, attributeConfig);
}
@@ -514,11 +514,7 @@ function addProperties(
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
if (enableAddPropertiesFastPath) {
return fastAddProperties(updatePayload, props, validAttributes);
} else {
return diffProperties(updatePayload, emptyObject, props, validAttributes);
}
return diffProperties(updatePayload, emptyObject, props, validAttributes);
}
/**
@@ -538,11 +534,11 @@ export function create(
props: Object,
validAttributes: AttributeConfiguration,
): null | Object {
return addProperties(
null, // updatePayload
props,
validAttributes,
);
if (enableAddPropertiesFastPath) {
return fastAddProperties(null, props, validAttributes);
} else {
return addProperties(null, props, validAttributes);
}
}
export function diff(
@@ -0,0 +1,449 @@
/**
* 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.
*
* @jest-environment node
*/
'use strict';
const {diff, create} = require('../ReactNativeAttributePayloadFabric');
describe('ReactNativeAttributePayloadFabric.create', () => {
it('should work with simple example', () => {
expect(create({b: 2, c: 3}, {a: true, b: true})).toEqual({
b: 2,
});
});
it('should work with complex example', () => {
const validAttributes = {
style: {
position: true,
zIndex: true,
flexGrow: true,
flexShrink: true,
flexDirection: true,
overflow: true,
backgroundColor: true,
},
};
expect(
create(
{
style: [
{
flexGrow: 1,
flexShrink: 1,
flexDirection: 'row',
overflow: 'scroll',
},
[
{position: 'relative', zIndex: 2},
{flexGrow: 0},
{backgroundColor: 'red'},
],
],
},
validAttributes,
),
).toEqual({
flexGrow: 0,
flexShrink: 1,
flexDirection: 'row',
overflow: 'scroll',
position: 'relative',
zIndex: 2,
backgroundColor: 'red',
});
});
it('should ignore fields that are set to undefined', () => {
expect(create({}, {a: true})).toEqual(null);
expect(create({a: undefined}, {a: true})).toEqual(null);
expect(create({a: undefined, b: undefined}, {a: true, b: true})).toEqual(
null,
);
expect(
create({a: undefined, b: undefined, c: 1}, {a: true, b: true}),
).toEqual(null);
expect(
create({a: undefined, b: undefined, c: 1}, {a: true, b: true, c: true}),
).toEqual({c: 1});
expect(
create({a: 1, b: undefined, c: 2}, {a: true, b: true, c: true}),
).toEqual({a: 1, c: 2});
});
it('should ignore invalid fields', () => {
expect(create({b: 2}, {})).toEqual(null);
});
it('should not use the diff attribute', () => {
const diffA = jest.fn();
expect(create({a: [2]}, {a: {diff: diffA}})).toEqual({a: [2]});
expect(diffA).not.toBeCalled();
});
it('should use the process attribute', () => {
const processA = jest.fn(a => a + 1);
expect(create({a: 2}, {a: {process: processA}})).toEqual({a: 3});
expect(processA).toBeCalledWith(2);
});
it('should work with undefined styles', () => {
expect(create({style: undefined}, {style: {b: true}})).toEqual(null);
expect(create({style: {a: '#ffffff', b: 1}}, {style: {b: true}})).toEqual({
b: 1,
});
});
it('should flatten nested styles and predefined styles', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
create({someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute),
).toEqual({foo: 1, bar: 2});
expect(create({}, validStyleAttribute)).toEqual(null);
const barStyle = {
bar: 3,
};
expect(
create(
{someStyle: [[{foo: 1}, {foo: 2}], barStyle]},
validStyleAttribute,
),
).toEqual({foo: 2, bar: 3});
});
it('should not flatten nested props if attribute config is a primitive or only has diff/process', () => {
expect(create({a: {foo: 1, bar: 2}}, {a: true})).toEqual({
a: {foo: 1, bar: 2},
});
expect(create({a: [{foo: 1}, {bar: 2}]}, {a: true})).toEqual({
a: [{foo: 1}, {bar: 2}],
});
expect(create({a: {foo: 1, bar: 2}}, {a: {diff: a => a}})).toEqual({
a: {foo: 1, bar: 2},
});
expect(
create({a: [{foo: 1}, {bar: 2}]}, {a: {diff: a => a, process: a => a}}),
).toEqual({a: [{foo: 1}, {bar: 2}]});
});
it('handles attributes defined multiple times', () => {
const validAttributes = {foo: true, style: {foo: true}};
expect(create({foo: 4, style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(create({style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(create({style: {foo: 2}, foo: 4}, validAttributes)).toEqual({
foo: 4,
});
expect(create({foo: 4, style: {foo: null}}, validAttributes)).toEqual({
foo: null, // this should ideally be null.
});
expect(
create({foo: 4, style: [{foo: null}, {foo: 5}]}, validAttributes),
).toEqual({
foo: 5,
});
});
// Function properties are just markers to native that events should be sent.
it('should convert functions to booleans', () => {
expect(
create(
{
a: function () {
return 9;
},
b: function () {
return 3;
},
},
{a: true, b: true},
),
).toEqual({a: true, b: true});
});
});
describe('ReactNativeAttributePayloadFabric.diff', () => {
it('should work with simple example', () => {
expect(diff({a: 1, c: 3}, {b: 2, c: 3}, {a: true, b: true})).toEqual({
a: null,
b: 2,
});
});
it('should skip fields that are equal', () => {
expect(
diff(
{a: 1, b: 'two', c: true, d: false, e: undefined, f: 0},
{a: 1, b: 'two', c: true, d: false, e: undefined, f: 0},
{a: true, b: true, c: true, d: true, e: true, f: true},
),
).toEqual(null);
});
it('should remove fields', () => {
expect(diff({a: 1}, {}, {a: true})).toEqual({a: null});
});
it('should remove fields that are set to undefined', () => {
expect(diff({a: 1}, {a: undefined}, {a: true})).toEqual({a: null});
});
it('should ignore invalid fields', () => {
expect(diff({a: 1}, {b: 2}, {})).toEqual(null);
});
// @gate !enableShallowPropDiffing
it('should use the diff attribute', () => {
const diffA = jest.fn((a, b) => true);
const diffB = jest.fn((a, b) => false);
expect(
diff(
{a: [1], b: [3]},
{a: [2], b: [4]},
{a: {diff: diffA}, b: {diff: diffB}},
),
).toEqual({a: [2]});
expect(diffA).toBeCalledWith([1], [2]);
expect(diffB).toBeCalledWith([3], [4]);
});
it('should not use the diff attribute on addition/removal', () => {
const diffA = jest.fn();
const diffB = jest.fn();
expect(
diff({a: [1]}, {b: [2]}, {a: {diff: diffA}, b: {diff: diffB}}),
).toEqual({a: null, b: [2]});
expect(diffA).not.toBeCalled();
expect(diffB).not.toBeCalled();
});
// @gate !enableShallowPropDiffing
it('should do deep diffs of Objects by default', () => {
expect(
diff(
{a: [1], b: {k: [3, 4]}, c: {k: [4, 4]}},
{a: [2], b: {k: [3, 4]}, c: {k: [4, 5]}},
{a: true, b: true, c: true},
),
).toEqual({a: [2], c: {k: [4, 5]}});
});
it('should work with undefined styles', () => {
expect(
diff(
{style: {a: '#ffffff', b: 1}},
{style: undefined},
{style: {b: true}},
),
).toEqual({b: null});
expect(
diff(
{style: undefined},
{style: {a: '#ffffff', b: 1}},
{style: {b: true}},
),
).toEqual({b: 1});
expect(
diff({style: undefined}, {style: undefined}, {style: {b: true}}),
).toEqual(null);
});
it('should work with empty styles', () => {
expect(diff({a: 1, c: 3}, {}, {a: true, b: true})).toEqual({a: null});
expect(diff({}, {a: 1, c: 3}, {a: true, b: true})).toEqual({a: 1});
expect(diff({}, {}, {a: true, b: true})).toEqual(null);
});
it('should flatten nested styles and predefined styles', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff({}, {someStyle: [{foo: 1}, {bar: 2}]}, validStyleAttribute),
).toEqual({foo: 1, bar: 2});
expect(
diff({someStyle: [{foo: 1}, {bar: 2}]}, {}, validStyleAttribute),
).toEqual({foo: null, bar: null});
const barStyle = {
bar: 3,
};
expect(
diff(
{},
{someStyle: [[{foo: 1}, {foo: 2}], barStyle]},
validStyleAttribute,
),
).toEqual({foo: 2, bar: 3});
});
it('should reset a value to a previous if it is removed', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{foo: 1}, {foo: 3}]},
{someStyle: [{foo: 1}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: 1, bar: 2});
});
it('should not clear removed props if they are still in another slot', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 3}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: 3}); // this should ideally be null. heuristic tradeoff.
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 1, bar: 1}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({bar: 2, foo: 1});
});
it('should clear a prop if a later style is explicit null/undefined', () => {
const validStyleAttribute = {someStyle: {foo: true, bar: true}};
expect(
diff(
{someStyle: [{}, {foo: 3, bar: 2}]},
{someStyle: [{foo: 1}, {bar: 2, foo: null}]},
validStyleAttribute,
),
).toEqual({foo: null});
expect(
diff(
{someStyle: [{foo: 3}, {foo: null, bar: 2}]},
{someStyle: [{foo: null}, {bar: 2}]},
validStyleAttribute,
),
).toEqual({foo: null});
expect(
diff(
{someStyle: [{foo: 1}, {foo: null}]},
{someStyle: [{foo: 2}, {foo: null}]},
validStyleAttribute,
),
).toEqual({foo: null}); // this should ideally be null. heuristic.
// Test the same case with object equality because an early bailout doesn't
// work in this case.
const fooObj = {foo: 3};
expect(
diff(
{someStyle: [{foo: 1}, fooObj]},
{someStyle: [{foo: 2}, fooObj]},
validStyleAttribute,
),
).toEqual({foo: 3}); // this should ideally be null. heuristic.
expect(
diff(
{someStyle: [{foo: 1}, {foo: 3}]},
{someStyle: [{foo: 2}, {foo: undefined}]},
validStyleAttribute,
),
).toEqual({foo: null}); // this should ideally be null. heuristic.
});
it('handles attributes defined multiple times', () => {
const validAttributes = {foo: true, style: {foo: true}};
expect(diff({}, {foo: 4, style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(diff({foo: 4}, {style: {foo: 2}}, validAttributes)).toEqual({
foo: 2,
});
expect(diff({style: {foo: 2}}, {foo: 4}, validAttributes)).toEqual({
foo: 4,
});
});
// Function properties are just markers to native that events should be sent.
it('should convert functions to booleans', () => {
// Note that if the property changes from one function to another, we don't
// need to send an update.
expect(
diff(
{
a: function () {
return 1;
},
b: function () {
return 2;
},
c: 3,
},
{
b: function () {
return 9;
},
c: function () {
return 3;
},
},
{a: true, b: true, c: true},
),
).toEqual({a: null, c: true});
});
it('should skip changed functions', () => {
expect(
diff(
{
a: function () {
return 1;
},
},
{
a: function () {
return 9;
},
},
{a: true},
),
).toEqual(null);
});
// @gate !enableShallowPropDiffing
it('should skip deeply-nested changed functions', () => {
expect(
diff(
{
wrapper: {
a: function () {
return 1;
},
},
},
{
wrapper: {
a: function () {
return 9;
},
},
},
{wrapper: true},
),
).toEqual(null);
});
});
@@ -1377,113 +1377,118 @@ describe('ResponderEventPlugin', () => {
expect(ResponderEventPlugin._getResponder()).toBe(null);
});
it('should determine the first common ancestor correctly', async () => {
// This test was moved here from the ReactTreeTraversal test since only the
// ResponderEventPlugin uses `getLowestCommonAncestor`
const React = require('react');
const ReactDOMClient = require('react-dom/client');
const act = require('internal-test-utils').act;
const getLowestCommonAncestor =
require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor;
// This works by accident and will likely break in the future.
const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree');
it(
'should determine the first common ancestor correctly',
async () => {
// This test was moved here from the ReactTreeTraversal test since only the
// ResponderEventPlugin uses `getLowestCommonAncestor`
const React = require('react');
const ReactDOMClient = require('react-dom/client');
const act = require('internal-test-utils').act;
const getLowestCommonAncestor =
require('react-native-renderer/src/legacy-events/ResponderEventPlugin').getLowestCommonAncestor;
// This works by accident and will likely break in the future.
const ReactDOMComponentTree = require('react-dom-bindings/src/client/ReactDOMComponentTree');
class ChildComponent extends React.Component {
divRef = React.createRef();
div1Ref = React.createRef();
div2Ref = React.createRef();
class ChildComponent extends React.Component {
divRef = React.createRef();
div1Ref = React.createRef();
div2Ref = React.createRef();
render() {
return (
<div ref={this.divRef} id={this.props.id + '__DIV'}>
<div ref={this.div1Ref} id={this.props.id + '__DIV_1'} />
<div ref={this.div2Ref} id={this.props.id + '__DIV_2'} />
</div>
);
}
}
class ParentComponent extends React.Component {
pRef = React.createRef();
p_P1Ref = React.createRef();
p_P1_C1Ref = React.createRef();
p_P1_C2Ref = React.createRef();
p_OneOffRef = React.createRef();
render() {
return (
<div ref={this.pRef} id="P">
<div ref={this.p_P1Ref} id="P_P1">
<ChildComponent ref={this.p_P1_C1Ref} id="P_P1_C1" />
<ChildComponent ref={this.p_P1_C2Ref} id="P_P1_C2" />
render() {
return (
<div ref={this.divRef} id={this.props.id + '__DIV'}>
<div ref={this.div1Ref} id={this.props.id + '__DIV_1'} />
<div ref={this.div2Ref} id={this.props.id + '__DIV_2'} />
</div>
<div ref={this.p_OneOffRef} id="P_OneOff" />
</div>
);
}
}
class ParentComponent extends React.Component {
pRef = React.createRef();
p_P1Ref = React.createRef();
p_P1_C1Ref = React.createRef();
p_P1_C2Ref = React.createRef();
p_OneOffRef = React.createRef();
render() {
return (
<div ref={this.pRef} id="P">
<div ref={this.p_P1Ref} id="P_P1">
<ChildComponent ref={this.p_P1_C1Ref} id="P_P1_C1" />
<ChildComponent ref={this.p_P1_C2Ref} id="P_P1_C2" />
</div>
<div ref={this.p_OneOffRef} id="P_OneOff" />
</div>
);
}
}
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
let parent;
await act(() => {
root.render(<ParentComponent ref={current => (parent = current)} />);
});
const ancestors = [
// Common ancestor with self is self.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.div1Ref.current,
com: parent.p_P1_C1Ref.current.div1Ref.current,
},
// Common ancestor with self is self - even if topmost DOM.
{
one: parent.pRef.current,
two: parent.pRef.current,
com: parent.pRef.current,
},
// Siblings
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.div2Ref.current,
com: parent.p_P1_C1Ref.current.divRef.current,
},
// Common ancestor with parent is the parent.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.divRef.current,
com: parent.p_P1_C1Ref.current.divRef.current,
},
// Common ancestor with grandparent is the grandparent.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1Ref.current,
com: parent.p_P1Ref.current,
},
// Grandparent across subcomponent boundaries.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C2Ref.current.div1Ref.current,
com: parent.p_P1Ref.current,
},
// Something deep with something one-off.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_OneOffRef.current,
com: parent.pRef.current,
},
];
let i;
for (i = 0; i < ancestors.length; i++) {
const plan = ancestors[i];
const firstCommon = getLowestCommonAncestor(
ReactDOMComponentTree.getInstanceFromNode(plan.one),
ReactDOMComponentTree.getInstanceFromNode(plan.two),
);
expect(firstCommon).toBe(
ReactDOMComponentTree.getInstanceFromNode(plan.com),
);
}
}
const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
let parent;
await act(() => {
root.render(<ParentComponent ref={current => (parent = current)} />);
});
const ancestors = [
// Common ancestor with self is self.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.div1Ref.current,
com: parent.p_P1_C1Ref.current.div1Ref.current,
},
// Common ancestor with self is self - even if topmost DOM.
{
one: parent.pRef.current,
two: parent.pRef.current,
com: parent.pRef.current,
},
// Siblings
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.div2Ref.current,
com: parent.p_P1_C1Ref.current.divRef.current,
},
// Common ancestor with parent is the parent.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C1Ref.current.divRef.current,
com: parent.p_P1_C1Ref.current.divRef.current,
},
// Common ancestor with grandparent is the grandparent.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1Ref.current,
com: parent.p_P1Ref.current,
},
// Grandparent across subcomponent boundaries.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_P1_C2Ref.current.div1Ref.current,
com: parent.p_P1Ref.current,
},
// Something deep with something one-off.
{
one: parent.p_P1_C1Ref.current.div1Ref.current,
two: parent.p_OneOffRef.current,
com: parent.pRef.current,
},
];
let i;
for (i = 0; i < ancestors.length; i++) {
const plan = ancestors[i];
const firstCommon = getLowestCommonAncestor(
ReactDOMComponentTree.getInstanceFromNode(plan.one),
ReactDOMComponentTree.getInstanceFromNode(plan.two),
);
expect(firstCommon).toBe(
ReactDOMComponentTree.getInstanceFromNode(plan.com),
);
}
});
},
// TODO: this is a long running test, we should speed it up.
60 * 1000,
);
});
@@ -25,6 +25,9 @@ type Destination = Array<Uint8Array>;
const textEncoder = new TextEncoder();
const ReactNoopFlightServer = ReactFlightServer({
scheduleMicrotask(callback: () => void) {
callback();
},
scheduleWork(callback: () => void) {
callback();
},
+3
View File
@@ -74,6 +74,9 @@ function write(destination: Destination, buffer: Uint8Array): void {
}
const ReactNoopServer = ReactFizzServer({
scheduleMicrotask(callback: () => void) {
callback();
},
scheduleWork(callback: () => void) {
callback();
},
+2 -2
View File
@@ -44,7 +44,7 @@ export function getCurrentParentStackInDev(): string {
return '';
}
function getCurrentFiberStackInDev(): string {
function getCurrentFiberStackInDev(stack: Error): string {
if (__DEV__) {
if (current === null) {
return '';
@@ -54,7 +54,7 @@ function getCurrentFiberStackInDev(): string {
// TODO: The above comment is not actually true. We might be
// in a commit phase or preemptive set state callback.
if (enableOwnerStacks) {
return getOwnerStackByFiberInDev(current);
return getOwnerStackByFiberInDev(current, stack);
}
return getStackByFiberInDevAndProd(current);
}
+28 -14
View File
@@ -1689,22 +1689,36 @@ function updateHostHoistable(
renderLanes: Lanes,
) {
markRef(current, workInProgress);
const currentProps = current === null ? null : current.memoizedProps;
const resource = (workInProgress.memoizedState = getResource(
workInProgress.type,
currentProps,
workInProgress.pendingProps,
));
if (current === null) {
if (!getIsHydrating() && resource === null) {
// This is not a Resource Hoistable and we aren't hydrating so we construct the instance.
workInProgress.stateNode = createHoistableInstance(
workInProgress.type,
workInProgress.pendingProps,
getRootHostContainer(),
workInProgress,
);
const resource = getResource(
workInProgress.type,
null,
workInProgress.pendingProps,
null,
);
if (resource) {
workInProgress.memoizedState = resource;
} else {
if (!getIsHydrating()) {
// This is not a Resource Hoistable and we aren't hydrating so we construct the instance.
workInProgress.stateNode = createHoistableInstance(
workInProgress.type,
workInProgress.pendingProps,
getRootHostContainer(),
workInProgress,
);
}
}
} else {
// Get Resource may or may not return a resource. either way we stash the result
// on memoized state.
workInProgress.memoizedState = getResource(
workInProgress.type,
current.memoizedProps,
workInProgress.pendingProps,
current.memoizedState,
);
}
// Resources never have reconciler managed children. It is possible for
+15 -7
View File
@@ -9,7 +9,7 @@
import type {LazyComponent} from 'react/src/ReactLazy';
import {setIsRendering} from './ReactCurrentFiber';
import {isRendering, setIsRendering} from './ReactCurrentFiber';
// These indirections exists so we can exclude its stack frame in DEV (and anything below it).
// TODO: Consider marking the whole bundle instead of these boundaries.
@@ -20,10 +20,14 @@ export function callComponentInDEV<Props, Arg, R>(
props: Props,
secondArg: Arg,
): R {
const wasRendering = isRendering;
setIsRendering(true);
const result = Component(props, secondArg);
setIsRendering(false);
return result;
try {
const result = Component(props, secondArg);
return result;
} finally {
setIsRendering(wasRendering);
}
}
interface ClassInstance<R> {
@@ -32,10 +36,14 @@ interface ClassInstance<R> {
/** @noinline */
export function callRenderInDEV<R>(instance: ClassInstance<R>): R {
const wasRendering = isRendering;
setIsRendering(true);
const result = instance.render();
setIsRendering(false);
return result;
try {
const result = instance.render();
return result;
} finally {
setIsRendering(wasRendering);
}
}
/** @noinline */
+19 -19
View File
@@ -1052,7 +1052,6 @@ function completeWork(
return null;
} else {
// This is a Hoistable Instance
// This must come at the very end of the complete phase.
bubbleProperties(workInProgress);
preloadInstanceAndSuspendIfNeeded(
@@ -1064,21 +1063,18 @@ function completeWork(
return null;
}
} else {
// We are updating.
const currentResource = current.memoizedState;
if (nextResource !== currentResource) {
// We are transitioning to, from, or between Hoistable Resources
// and require an update
markUpdate(workInProgress);
}
if (nextResource !== null) {
// This is a Hoistable Resource
// This must come at the very end of the complete phase.
bubbleProperties(workInProgress);
if (nextResource === currentResource) {
workInProgress.flags &= ~MaySuspendCommit;
} else {
// This is an update.
if (nextResource) {
// This is a Resource
if (nextResource !== current.memoizedState) {
// we have a new Resource. we need to update
markUpdate(workInProgress);
// This must come at the very end of the complete phase.
bubbleProperties(workInProgress);
// This must come at the very end of the complete phase, because it might
// throw to suspend, and if the resource immediately loads, the work loop
// will resume rendering as if the work-in-progress completed. So it must
// fully complete.
preloadResourceAndSuspendIfNeeded(
workInProgress,
nextResource,
@@ -1086,10 +1082,15 @@ function completeWork(
newProps,
renderLanes,
);
return null;
} else {
// This must come at the very end of the complete phase.
bubbleProperties(workInProgress);
workInProgress.flags &= ~MaySuspendCommit;
return null;
}
return null;
} else {
// This is a Hoistable Instance
// This is an Instance
// We may have props to update on the Hoistable instance.
if (supportsMutation) {
const oldProps = current.memoizedProps;
@@ -1107,7 +1108,6 @@ function completeWork(
renderLanes,
);
}
// This must come at the very end of the complete phase.
bubbleProperties(workInProgress);
preloadInstanceAndSuspendIfNeeded(
+19 -3
View File
@@ -90,13 +90,27 @@ function describeFunctionComponentFrameWithoutLineNumber(fn: Function): string {
return name ? describeBuiltInComponentFrame(name) : '';
}
export function getOwnerStackByFiberInDev(workInProgress: Fiber): string {
export function getOwnerStackByFiberInDev(
workInProgress: Fiber,
topStack: null | Error,
): string {
if (!enableOwnerStacks || !__DEV__) {
return '';
}
try {
let info = '';
if (topStack) {
// Prefix with a filtered version of the currently executing
// stack. This information will be available in the native
// stack regardless but it's hidden since we're reprinting
// the stack on top of it.
const formattedTopStack = formatOwnerStack(topStack);
if (formattedTopStack !== '') {
info += '\n' + formattedTopStack;
}
}
if (workInProgress.tag === HostText) {
// Text nodes never have an owner/stack because they're not created through JSX.
// We use the parent since text nodes are always created through a host parent.
@@ -125,14 +139,16 @@ export function getOwnerStackByFiberInDev(workInProgress: Fiber): string {
case FunctionComponent:
case SimpleMemoComponent:
case ClassComponent:
if (!workInProgress._debugOwner) {
if (!workInProgress._debugOwner && info === '') {
// Only if we have no other data about the callsite do we add
// the component name as the single stack frame.
info += describeFunctionComponentFrameWithoutLineNumber(
workInProgress.type,
);
}
break;
case ForwardRef:
if (!workInProgress._debugOwner) {
if (!workInProgress._debugOwner && info === '') {
info += describeFunctionComponentFrameWithoutLineNumber(
workInProgress.type.render,
);
+229 -129
View File
@@ -1083,20 +1083,49 @@ function useThenable<T>(thenable: Thenable<T>): T {
thenableState = createThenableState();
}
const result = trackUsedThenable(thenableState, thenable, index);
if (
currentlyRenderingFiber.alternate === null &&
(workInProgressHook === null
? currentlyRenderingFiber.memoizedState === null
: workInProgressHook.next === null)
) {
// Initial render, and either this is the first time the component is
// called, or there were no Hooks called after this use() the previous
// time (perhaps because it threw). Subsequent Hook calls should use the
// mount dispatcher.
// When something suspends with `use`, we replay the component with the
// "re-render" dispatcher instead of the "mount" or "update" dispatcher.
//
// But if there are additional hooks that occur after the `use` invocation
// that suspended, they wouldn't have been processed during the previous
// attempt. So after we invoke `use` again, we may need to switch from the
// "re-render" dispatcher back to the "mount" or "update" dispatcher. That's
// what the following logic accounts for.
//
// TODO: Theoretically this logic only needs to go into the rerender
// dispatcher. Could optimize, but probably not be worth it.
// This is the same logic as in updateWorkInProgressHook.
const workInProgressFiber = currentlyRenderingFiber;
const nextWorkInProgressHook =
workInProgressHook === null
? // We're at the beginning of the list, so read from the first hook from
// the fiber.
workInProgressFiber.memoizedState
: workInProgressHook.next;
if (nextWorkInProgressHook !== null) {
// There are still hooks remaining from the previous attempt.
} else {
// There are no remaining hooks from the previous attempt. We're no longer
// in "re-render" mode. Switch to the normal mount or update dispatcher.
//
// This is the same as the logic in renderWithHooks, except we don't bother
// to track the hook types debug information in this case (sufficient to
// only do that when nothing suspends).
const currentFiber = workInProgressFiber.alternate;
if (__DEV__) {
ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
if (currentFiber !== null && currentFiber.memoizedState !== null) {
ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
} else {
ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
}
} else {
ReactSharedInternals.H = HooksDispatcherOnMount;
ReactSharedInternals.H =
currentFiber === null || currentFiber.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}
}
return result;
@@ -1965,7 +1994,9 @@ type ActionStateQueue<S, P> = {
dispatch: Dispatch<P>,
// This is the most recent action function that was rendered. It's updated
// during the commit phase.
action: (Awaited<S>, P) => S,
// If it's null, it means the action queue errored and subsequent actions
// should not run.
action: ((Awaited<S>, P) => S) | null,
// This is a circular linked list of pending action payloads. It incudes the
// action that is currently running.
pending: ActionStateQueueNode<S, P> | null,
@@ -1977,65 +2008,93 @@ type ActionStateQueueNode<S, P> = {
action: (Awaited<S>, P) => S,
// This is never null because it's part of a circular linked list.
next: ActionStateQueueNode<S, P>,
// Whether or not the action was dispatched as part of a transition. We use
// this to restore the transition context when the queued action is run. Once
// we're able to track parallel async actions, this should be updated to
// represent the specific transition instance the action is associated with.
isTransition: boolean,
// Implements the Thenable interface. We use it to suspend until the action
// finishes.
then: (listener: () => void) => void,
status: 'pending' | 'rejected' | 'fulfilled',
value: any,
reason: any,
listeners: Array<() => void>,
};
function dispatchActionState<S, P>(
fiber: Fiber,
actionQueue: ActionStateQueue<S, P>,
setPendingState: boolean => void,
setState: Dispatch<S | Awaited<S>>,
setState: Dispatch<ActionStateQueueNode<S, P>>,
payload: P,
): void {
if (isRenderPhaseUpdate(fiber)) {
throw new Error('Cannot update form state while rendering.');
}
const currentAction = actionQueue.action;
if (currentAction === null) {
// An earlier action errored. Subsequent actions should not run.
return;
}
const actionNode: ActionStateQueueNode<S, P> = {
payload,
action: currentAction,
next: (null: any), // circular
isTransition: true,
status: 'pending',
value: null,
reason: null,
listeners: [],
then(listener) {
// We know the only thing that subscribes to these promises is `use` so
// this implementation is simpler than a generic thenable. E.g. we don't
// bother to check if the thenable is still pending because `use` already
// does that.
actionNode.listeners.push(listener);
},
};
// Check if we're inside a transition. If so, we'll need to restore the
// transition context when the action is run.
const prevTransition = ReactSharedInternals.T;
if (prevTransition !== null) {
// Optimistically update the pending state, similar to useTransition.
// This will be reverted automatically when all actions are finished.
setPendingState(true);
// `actionNode` is a thenable that resolves to the return value of
// the action.
setState(actionNode);
} else {
// This is not a transition.
actionNode.isTransition = false;
setState(actionNode);
}
const last = actionQueue.pending;
if (last === null) {
// There are no pending actions; this is the first one. We can run
// it immediately.
const newLast: ActionStateQueueNode<S, P> = {
payload,
action: actionQueue.action,
next: (null: any), // circular
};
newLast.next = actionQueue.pending = newLast;
runActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
newLast,
);
actionNode.next = actionQueue.pending = actionNode;
runActionStateAction(actionQueue, actionNode);
} else {
// There's already an action running. Add to the queue.
const first = last.next;
const newLast: ActionStateQueueNode<S, P> = {
payload,
action: actionQueue.action,
next: first,
};
actionQueue.pending = last.next = newLast;
actionNode.next = first;
actionQueue.pending = last.next = actionNode;
}
}
function runActionStateAction<S, P>(
actionQueue: ActionStateQueue<S, P>,
setPendingState: boolean => void,
setState: Dispatch<S | Awaited<S>>,
node: ActionStateQueueNode<S, P>,
) {
// This is a fork of startTransition
const prevTransition = ReactSharedInternals.T;
const currentTransition: BatchConfigTransition = {};
ReactSharedInternals.T = currentTransition;
if (__DEV__) {
ReactSharedInternals.T._updatedFibers = new Set();
}
// Optimistically update the pending state, similar to useTransition.
// This will be reverted automatically when all actions are finished.
setPendingState(true);
// `node.action` represents the action function at the time it was dispatched.
// If this action was queued, it might be stale, i.e. it's not necessarily the
// most current implementation of the action, stored on `actionQueue`. This is
@@ -2045,93 +2104,106 @@ function runActionStateAction<S, P>(
const action = node.action;
const payload = node.payload;
const prevState = actionQueue.state;
try {
const returnValue = action(prevState, payload);
const onStartTransitionFinish = ReactSharedInternals.S;
if (onStartTransitionFinish !== null) {
onStartTransitionFinish(currentTransition, returnValue);
}
if (
returnValue !== null &&
typeof returnValue === 'object' &&
// $FlowFixMe[method-unbinding]
typeof returnValue.then === 'function'
) {
const thenable = ((returnValue: any): Thenable<Awaited<S>>);
// Attach a listener to read the return state of the action. As soon as
// this resolves, we can run the next action in the sequence.
thenable.then(
(nextState: Awaited<S>) => {
actionQueue.state = nextState;
finishRunningActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
);
},
() =>
finishRunningActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
),
);
setState((thenable: any));
} else {
setState((returnValue: any));
const nextState = ((returnValue: any): Awaited<S>);
actionQueue.state = nextState;
finishRunningActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
);
}
} catch (error) {
// This is a trick to get the `useActionState` hook to rethrow the error.
// When it unwraps the thenable with the `use` algorithm, the error
// will be thrown.
const rejectedThenable: S = ({
then() {},
status: 'rejected',
reason: error,
// $FlowFixMe: Not sure why this doesn't work
}: RejectedThenable<Awaited<S>>);
setState(rejectedThenable);
finishRunningActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
);
} finally {
ReactSharedInternals.T = prevTransition;
if (node.isTransition) {
// The original dispatch was part of a transition. We restore its
// transition context here.
// This is a fork of startTransition
const prevTransition = ReactSharedInternals.T;
const currentTransition: BatchConfigTransition = {};
ReactSharedInternals.T = currentTransition;
if (__DEV__) {
if (prevTransition === null && currentTransition._updatedFibers) {
const updatedFibersCount = currentTransition._updatedFibers.size;
currentTransition._updatedFibers.clear();
if (updatedFibersCount > 10) {
console.warn(
'Detected a large number of updates inside startTransition. ' +
'If this is due to a subscription please re-write it to use React provided hooks. ' +
'Otherwise concurrent mode guarantees are off the table.',
);
ReactSharedInternals.T._updatedFibers = new Set();
}
try {
const returnValue = action(prevState, payload);
const onStartTransitionFinish = ReactSharedInternals.S;
if (onStartTransitionFinish !== null) {
onStartTransitionFinish(currentTransition, returnValue);
}
handleActionReturnValue(actionQueue, node, returnValue);
} catch (error) {
onActionError(actionQueue, node, error);
} finally {
ReactSharedInternals.T = prevTransition;
if (__DEV__) {
if (prevTransition === null && currentTransition._updatedFibers) {
const updatedFibersCount = currentTransition._updatedFibers.size;
currentTransition._updatedFibers.clear();
if (updatedFibersCount > 10) {
console.warn(
'Detected a large number of updates inside startTransition. ' +
'If this is due to a subscription please re-write it to use React provided hooks. ' +
'Otherwise concurrent mode guarantees are off the table.',
);
}
}
}
}
} else {
// The original dispatch was not part of a transition.
try {
const returnValue = action(prevState, payload);
handleActionReturnValue(actionQueue, node, returnValue);
} catch (error) {
onActionError(actionQueue, node, error);
}
}
}
function finishRunningActionStateAction<S, P>(
function handleActionReturnValue<S, P>(
actionQueue: ActionStateQueue<S, P>,
setPendingState: Dispatch<S | Awaited<S>>,
setState: Dispatch<S | Awaited<S>>,
node: ActionStateQueueNode<S, P>,
returnValue: mixed,
) {
// The action finished running. Pop it from the queue and run the next pending
// action, if there are any.
if (
returnValue !== null &&
typeof returnValue === 'object' &&
// $FlowFixMe[method-unbinding]
typeof returnValue.then === 'function'
) {
const thenable = ((returnValue: any): Thenable<Awaited<S>>);
// Attach a listener to read the return state of the action. As soon as
// this resolves, we can run the next action in the sequence.
thenable.then(
(nextState: Awaited<S>) => {
onActionSuccess(actionQueue, node, nextState);
},
(error: mixed) => onActionError(actionQueue, node, error),
);
if (__DEV__) {
if (!node.isTransition) {
console.error(
'An async function was passed to useActionState, but it was ' +
'dispatched outside of an action context. This is likely not ' +
'what you intended. Either pass the dispatch function to an ' +
'`action` prop, or dispatch manually inside `startTransition`',
);
}
}
} else {
const nextState = ((returnValue: any): Awaited<S>);
onActionSuccess(actionQueue, node, nextState);
}
}
function onActionSuccess<S, P>(
actionQueue: ActionStateQueue<S, P>,
actionNode: ActionStateQueueNode<S, P>,
nextState: Awaited<S>,
) {
// The action finished running.
actionNode.status = 'fulfilled';
actionNode.value = nextState;
notifyActionListeners(actionNode);
actionQueue.state = nextState;
// Pop the action from the queue and run the next pending action, if there
// are any.
const last = actionQueue.pending;
if (last !== null) {
const first = last.next;
@@ -2144,16 +2216,44 @@ function finishRunningActionStateAction<S, P>(
last.next = next;
// Run the next action.
runActionStateAction(
actionQueue,
(setPendingState: any),
(setState: any),
next,
);
runActionStateAction(actionQueue, next);
}
}
}
function onActionError<S, P>(
actionQueue: ActionStateQueue<S, P>,
actionNode: ActionStateQueueNode<S, P>,
error: mixed,
) {
// Mark all the following actions as rejected.
const last = actionQueue.pending;
actionQueue.pending = null;
if (last !== null) {
const first = last.next;
do {
actionNode.status = 'rejected';
actionNode.reason = error;
notifyActionListeners(actionNode);
actionNode = actionNode.next;
} while (actionNode !== first);
}
// Prevent subsequent actions from being dispatched.
actionQueue.action = null;
}
function notifyActionListeners<S, P>(actionNode: ActionStateQueueNode<S, P>) {
// Notify React that the action has finished.
const listeners = actionNode.listeners;
for (let i = 0; i < listeners.length; i++) {
// This is always a React internal listener, so we don't need to worry
// about it throwing.
const listener = listeners[i];
listener();
}
}
function actionStateReducer<S>(oldState: S, newState: S): S {
return newState;
}
+5
View File
@@ -103,6 +103,11 @@ function filterDebugStack(error: Error): string {
if (lastFrameIdx !== -1) {
// Cut off everything after our "callComponent" slot since it'll be Fiber internals.
frames.length = lastFrameIdx;
} else {
// We didn't find any internal callsite out to user space.
// This means that this was called outside an owner or the owner is fully internal.
// To keep things light we exclude the entire trace in this case.
return '';
}
return frames.filter(isNotExternal).join('\n');
}
+2 -2
View File
@@ -118,7 +118,7 @@ describe('Activity', () => {
);
});
// @gate www && !disableLegacyMode
// @gate enableLegacyHidden && !disableLegacyMode
it('does not defer in legacy mode', async () => {
let setState;
function Foo() {
@@ -163,7 +163,7 @@ describe('Activity', () => {
);
});
// @gate www
// @gate enableLegacyHidden
it('does defer in concurrent mode', async () => {
let setState;
function Foo() {
@@ -140,7 +140,7 @@ describe('Activity Suspense', () => {
);
});
// @gate www
// @gate enableLegacyHidden
test('LegacyHidden does not handle suspense', async () => {
const root = ReactNoop.createRoot();
@@ -174,7 +174,7 @@ describe('Activity Suspense', () => {
);
});
// @gate experimental || www
// @gate enableActivity
test("suspending inside currently hidden tree that's switching to visible", async () => {
const root = ReactNoop.createRoot();
@@ -319,7 +319,7 @@ describe('Activity Suspense', () => {
);
});
// @gate experimental || www
// @gate enableActivity
test('update that suspends inside hidden tree', async () => {
let setText;
function Child() {
@@ -352,7 +352,7 @@ describe('Activity Suspense', () => {
});
});
// @gate experimental || www
// @gate enableActivity
test('updates at multiple priorities that suspend inside hidden tree', async () => {
let setText;
let setStep;
@@ -550,7 +550,7 @@ describe('ReactLazyContextPropagation', () => {
expect(root).toMatchRenderedOutput('BB');
});
// @gate www
// @gate enableLegacyCache && enableLegacyHidden
test('context is propagated through offscreen trees', async () => {
const LegacyHidden = React.unstable_LegacyHidden;
@@ -596,7 +596,7 @@ describe('ReactLazyContextPropagation', () => {
expect(root).toMatchRenderedOutput('BB');
});
// @gate www
// @gate enableLegacyCache && enableLegacyHidden
test('multiple contexts are propagated across through offscreen trees', async () => {
// Same as previous test, but with multiple context providers
const LegacyHidden = React.unstable_LegacyHidden;
@@ -822,7 +822,7 @@ describe('ReactLazyContextPropagation', () => {
expect(root).toMatchRenderedOutput('BB');
});
// @gate www
// @gate enableLegacyCache && enableLegacyHidden
test('nested bailouts through offscreen trees', async () => {
// Lazy context propagation will stop propagating when it hits the first
// match. If we bail out again inside that tree, we must resume propagating.
@@ -1618,8 +1618,7 @@ describe('ReactHooks', () => {
' Previous render Next render\n' +
' ------------------------------------------------------\n' +
`1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameB)}\n` +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' +
' in App (at **)',
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n',
]);
// further warnings for this component are silenced
@@ -1671,8 +1670,7 @@ describe('ReactHooks', () => {
' ------------------------------------------------------\n' +
`1. ${formatHookNamesToMatchErrorMessage(hookNameA, hookNameA)}\n` +
`2. undefined use${hookNameB}\n` +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' +
' in App (at **)',
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n',
]);
});
});
@@ -1758,8 +1756,7 @@ describe('ReactHooks', () => {
'ImperativeHandle',
'Memo',
)}\n` +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n' +
' in App (at **)',
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n',
]);
// further warnings for this component are silenced
@@ -239,7 +239,7 @@ describe('ReactIncremental', () => {
expect(inst.state).toEqual({text: 'bar', text2: 'baz'});
});
// @gate www
// @gate enableLegacyHidden
it('can deprioritize unfinished work and resume it later', async () => {
function Bar(props) {
Scheduler.log('Bar');
@@ -279,7 +279,7 @@ describe('ReactIncremental', () => {
await waitForAll(['Middle', 'Middle']);
});
// @gate www
// @gate enableLegacyHidden
it('can deprioritize a tree from without dropping work', async () => {
function Bar(props) {
Scheduler.log('Bar');
@@ -1864,8 +1864,7 @@ describe('ReactIncremental', () => {
]);
});
// @gate www
// @gate !disableLegacyContext
// @gate enableLegacyHidden && !disableLegacyContext
it('provides context when reusing work', async () => {
class Intl extends React.Component {
static childContextTypes = {
@@ -289,7 +289,7 @@ describe('ReactIncrementalErrorHandling', () => {
);
});
// @gate www
// @gate enableLegacyHidden
it('does not include offscreen work when retrying after an error', async () => {
function App(props) {
if (props.isBroken) {

Some files were not shown because too many files have changed in this diff Show More