@samitouri / QOS-React-2 / commits / 5c420e3824

[compiler] Debug tool to emit change detection code rather than memoization

Summary: The essential assumption of the compiler is that if the inputs to a computation have not changed, then the output should not change either--computation that the compiler optimizes is idempotent. This is, of course, known to be false in practice, because this property rests on requirements (the Rules of React) that are loosely enforced at best. When rolling out the compiler to a codebase that might have rules of react violations, how should developers debug any issues that arise? This diff attempts one approach to that: when the option is set, rather than simply skipping computation when dependencies haven't changed, we will *still perform the computation*, but will then use a runtime function to compare the original value and the resultant value. The runtime function can be customized, but the idea is that it will perform a structural equality check on the values, and if the values aren't structurally equal, we can report an error, including information about what file and what variable was to blame. This assists in debugging by narrowing down what specific computation is responsible for a difference in behavior between the uncompiled code and the program after compilation. ghstack-source-id: 50dad3dacfc7fef74be350431aa2ebf5e9cb0031 Pull Request resolved: https://github.com/facebook/react/pull/29656

Mike Vitousek committed May 31, 2024 at 14:05 UTC 5c420e3824859b33321b4bc9ce3119806fac56c2
11 files changed +353 -9
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+2 -1
@@ -149,7 +149,8 @@ function* runWithEnvironment(
149
150 if (
151 !env.config.enablePreserveExistingManualUseMemo &&
152 - !env.config.disableMemoizationForDebugging
152 + !env.config.disableMemoizationForDebugging &&
153 + !env.config.enableChangeDetectionForDebugging
154 ) {
155 dropManualMemoization(hir);
156 yield log({ kind: "hir", name: "DropManualMemoization", value: hir });
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts
+7
@@ -421,6 +421,13 @@ export function compileProgram(
421 );
422 externalFunctions.push(enableEmitHookGuards);
423 }
424 +
425 + if (options.environment?.enableChangeDetectionForDebugging != null) {
426 + const enableChangeDetectionForDebugging = tryParseExternalFunction(
427 + options.environment.enableChangeDetectionForDebugging
428 + );
429 + externalFunctions.push(enableChangeDetectionForDebugging);
430 + }
431 } catch (err) {
432 handleError(err, pass, null);
433 return;
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+20
@@ -359,6 +359,14 @@ const EnvironmentConfigSchema = z.object({
359 */
360 disableMemoizationForDebugging: z.boolean().default(false),
361
362 + /**
363 + * When true, rather using memoized values, the compiler will always re-compute
364 + * values, and then use a heuristic to compare the memoized value to the newly
365 + * computed one. This detects cases where rules of react violations may cause the
366 + * compiled code to behave differently than the original.
367 + */
368 + enableChangeDetectionForDebugging: ExternalFunctionSchema.nullish(),
369 +
370 /**
371 * The react native re-animated library uses custom Babel transforms that
372 * requires the calls to library API remain unmodified.
@@ -478,6 +486,18 @@ export class Environment {
486 this.#shapes = new Map(DEFAULT_SHAPES);
487 this.#globals = new Map(DEFAULT_GLOBALS);
488
489 + if (
490 + config.disableMemoizationForDebugging &&
491 + config.enableChangeDetectionForDebugging != null
492 + ) {
493 + CompilerError.throwInvalidConfig({
494 + reason: `Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together`,
495 + description: null,
496 + loc: null,
497 + suggestions: null,
498 + });
499 + }
500 +
501 for (const [hookName, hook] of this.config.customHooks) {
502 CompilerError.invariant(!this.#globals.has(hookName), {
503 reason: `[Globals] Found existing definition in global registry for custom hook ${hookName}`,
compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/CodegenReactiveFunction.ts
+68 -7
@@ -617,22 +617,83 @@ function codegenReactiveScope(
617 }
618
619 if (cx.env.config.disableMemoizationForDebugging) {
620 + CompilerError.invariant(
621 + cx.env.config.enableChangeDetectionForDebugging == null,
622 + {
623 + reason: `Expected to not have both change detection enabled and memoization disabled`,
624 + description: `Incompatible config options`,
625 + loc: null,
626 + }
627 + );
628 testCondition = t.logicalExpression(
629 "||",
630 testCondition,
631 t.booleanLiteral(true)
632 );
633 }
626 -
634 let computationBlock = codegenBlock(cx, block);
628 - computationBlock.body.push(...cacheStoreStatements);
635 + let memoStatement;
636 const memoBlock = t.blockStatement(cacheLoadStatements);
637 + if (
638 + cx.env.config.enableChangeDetectionForDebugging != null &&
639 + changeExpressions.length > 0
640 + ) {
641 + const detectionFunction =
642 + cx.env.config.enableChangeDetectionForDebugging.importSpecifierName;
643 + const changeDetectionStatements: Array<t.Statement> = [];
644 + const oldVarDeclarationStatements: Array<t.Statement> = [];
645 + memoBlock.body.forEach((stmt) => {
646 + if (
647 + stmt.type === "ExpressionStatement" &&
648 + stmt.expression.type === "AssignmentExpression" &&
649 + stmt.expression.left.type === "Identifier"
650 + ) {
651 + const name = stmt.expression.left.name;
652 + const loadName = cx.synthesizeName(`old$${name}`);
653 + oldVarDeclarationStatements.push(
654 + t.variableDeclaration("let", [
655 + t.variableDeclarator(t.identifier(loadName)),
656 + ])
657 + );
658 + stmt.expression.left = t.identifier(loadName);
659 + changeDetectionStatements.push(
660 + t.expressionStatement(
661 + t.callExpression(t.identifier(detectionFunction), [
662 + t.identifier(loadName),
663 + t.identifier(name),
664 + t.stringLiteral(name),
665 + t.stringLiteral(cx.fnName),
666 + ])
667 + )
668 + );
669 + changeDetectionStatements.push(
670 + t.expressionStatement(
671 + t.assignmentExpression(
672 + "=",
673 + t.identifier(name),
674 + t.identifier(loadName)
675 + )
676 + )
677 + );
678 + }
679 + });
680 + memoStatement = t.blockStatement([
681 + ...computationBlock.body,
682 + t.ifStatement(
683 + t.unaryExpression("!", testCondition),
684 + t.blockStatement([
685 + ...oldVarDeclarationStatements,
686 + ...memoBlock.body,
687 + ...changeDetectionStatements,
688 + ])
689 + ),
690 + ...cacheStoreStatements,
691 + ]);
692 + } else {
693 + computationBlock.body.push(...cacheStoreStatements);
694
631 - const memoStatement = t.ifStatement(
632 - testCondition,
633 - computationBlock,
634 - memoBlock
635 - );
695 + memoStatement = t.ifStatement(testCondition, computationBlock, memoBlock);
696 + }
697
698 if (cx.env.config.enableMemoizationComments) {
699 if (changeExpressionComments.length) {
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.expect.md new
+17
@@ -0,0 +1,17 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging
6 +function Component(props) {}
7 +
8 +```
9 +
10 +
11 +## Error
12 +
13 +```
14 +InvalidConfig: Invalid environment config: the 'disableMemoizationForDebugging' and 'enableChangeDetectionForDebugging' options cannot be used together
15 +```
16 +
17 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.nomemo-and-change-detect.js new
+2
@@ -0,0 +1,2 @@
1 +// @disableMemoizationForDebugging @enableChangeDetectionForDebugging
2 +function Component(props) {}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.expect.md new
+53
@@ -0,0 +1,53 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableChangeDetectionForDebugging
6 +import { useState } from "react";
7 +
8 +function Component(props) {
9 + const [x, _] = useState(f(props.x));
10 + return <div>{x}</div>;
11 +}
12 +
13 +```
14 +
15 +## Code
16 +
17 +```javascript
18 +import { $structuralCheck } from "react-compiler-runtime";
19 +import { c as _c } from "react/compiler-runtime"; // @enableChangeDetectionForDebugging
20 +import { useState } from "react";
21 +
22 +function Component(props) {
23 + const $ = _c(4);
24 + let t0;
25 + {
26 + t0 = f(props.x);
27 + if (!($[0] !== props.x)) {
28 + let old$t0;
29 + old$t0 = $[1];
30 + $structuralCheck(old$t0, t0, "t0", "Component");
31 + t0 = old$t0;
32 + }
33 + $[0] = props.x;
34 + $[1] = t0;
35 + }
36 + const [x] = useState(t0);
37 + let t1;
38 + {
39 + t1 = <div>{x}</div>;
40 + if (!($[2] !== x)) {
41 + let old$t1;
42 + old$t1 = $[3];
43 + $structuralCheck(old$t1, t1, "t1", "Component");
44 + t1 = old$t1;
45 + }
46 + $[2] = x;
47 + $[3] = t1;
48 + }
49 + return t1;
50 +}
51 +
52 +```
53 +
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/useState-pruned-dependency-change-detect.js new
+7
@@ -0,0 +1,7 @@
1 +// @enableChangeDetectionForDebugging
2 +import { useState } from "react";
3 +
4 +function Component(props) {
5 + const [x, _] = useState(f(props.x));
6 + return <div>{x}</div>;
7 +}
compiler/packages/react-compiler-runtime/src/index.ts
+168 -1
@@ -9,7 +9,7 @@
9
10 import * as React from "react";
11
12 -const { useRef, useEffect } = React;
12 +const { useRef, useEffect, isValidElement } = React;
13 const ReactSecretInternals =
14 //@ts-ignore
15 React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ??
@@ -251,3 +251,170 @@ export function useRenderCounter(name: string): void {
251 };
252 });
253 }
254 +
255 +const seenErrors = new Set();
256 +
257 +export function $structuralCheck(
258 + oldValue: any,
259 + newValue: any,
260 + variableName: string,
261 + fnName: string
262 +): void {
263 + function error(l: string, r: string, path: string, depth: number) {
264 + const str = `${fnName}: ${variableName}${path} changed from ${l} to ${r} at depth ${depth}`;
265 + if (seenErrors.has(str)) {
266 + return;
267 + }
268 + seenErrors.add(str);
269 + console.error(str);
270 + }
271 + const depthLimit = 2;
272 + function recur(oldValue: any, newValue: any, path: string, depth: number) {
273 + if (depth > depthLimit) {
274 + return;
275 + } else if (oldValue === newValue) {
276 + return;
277 + } else if (typeof oldValue !== typeof newValue) {
278 + error(`type ${typeof oldValue}`, `type ${typeof newValue}`, path, depth);
279 + } else if (typeof oldValue === "object") {
280 + const oldArray = Array.isArray(oldValue);
281 + const newArray = Array.isArray(newValue);
282 + if (oldValue === null && newValue !== null) {
283 + error("null", `type ${typeof newValue}`, path, depth);
284 + } else if (newValue === null) {
285 + error(`type ${typeof oldValue}`, null, path, depth);
286 + } else if (oldValue instanceof Map) {
287 + if (!(newValue instanceof Map)) {
288 + error(`Map instance`, `other value`, path, depth);
289 + } else if (oldValue.size !== newValue.size) {
290 + error(
291 + `Map instance with size ${oldValue.size}`,
292 + `Map instance with size ${newValue.size}`,
293 + path,
294 + depth
295 + );
296 + } else {
297 + for (const [k, v] of oldValue) {
298 + if (!newValue.has(k)) {
299 + error(
300 + `Map instance with key ${k}`,
301 + `Map instance without key ${k}`,
302 + path,
303 + depth
304 + );
305 + } else {
306 + recur(v, newValue.get(k), `${path}.get(${k})`, depth + 1);
307 + }
308 + }
309 + }
310 + } else if (newValue instanceof Map) {
311 + error("other value", `Map instance`, path, depth);
312 + } else if (oldValue instanceof Set) {
313 + if (!(newValue instanceof Set)) {
314 + error(`Set instance`, `other value`, path, depth);
315 + } else if (oldValue.size !== newValue.size) {
316 + error(
317 + `Set instance with size ${oldValue.size}`,
318 + `Set instance with size ${newValue.size}`,
319 + path,
320 + depth
321 + );
322 + } else {
323 + for (const v of newValue) {
324 + if (!oldValue.has(v)) {
325 + error(
326 + `Set instance without element ${v}`,
327 + `Set instance with element ${v}`,
328 + path,
329 + depth
330 + );
331 + }
332 + }
333 + }
334 + } else if (newValue instanceof Set) {
335 + error("other value", `Set instance`, path, depth);
336 + } else if (oldArray || newArray) {
337 + if (oldArray !== newArray) {
338 + error(
339 + `type ${oldArray ? "array" : "object"}`,
340 + `type ${newArray ? "array" : "object"}`,
341 + path,
342 + depth
343 + );
344 + } else if (oldValue.length !== newValue.length) {
345 + error(
346 + `array with length ${oldValue.length}`,
347 + `array with length ${newValue.length}`,
348 + path,
349 + depth
350 + );
351 + } else {
352 + for (let ii = 0; ii < oldValue.length; ii++) {
353 + recur(oldValue[ii], newValue[ii], `${path}[${ii}]`, depth + 1);
354 + }
355 + }
356 + } else if (isValidElement(oldValue) || isValidElement(newValue)) {
357 + if (isValidElement(oldValue) !== isValidElement(newValue)) {
358 + error(
359 + `type ${isValidElement(oldValue) ? "React element" : "object"}`,
360 + `type ${isValidElement(newValue) ? "React element" : "object"}`,
361 + path,
362 + depth
363 + );
364 + } else if (oldValue.type !== newValue.type) {
365 + error(
366 + `React element of type ${oldValue.type}`,
367 + `React element of type ${newValue.type}`,
368 + path,
369 + depth
370 + );
371 + } else {
372 + recur(
373 + oldValue.props,
374 + newValue.props,
375 + `[props of ${path}]`,
376 + depth + 1
377 + );
378 + }
379 + } else {
380 + for (const key in newValue) {
381 + if (!(key in oldValue)) {
382 + error(
383 + `object without key ${key}`,
384 + `object with key ${key}`,
385 + path,
386 + depth
387 + );
388 + }
389 + }
390 + for (const key in oldValue) {
391 + if (!(key in newValue)) {
392 + error(
393 + `object with key ${key}`,
394 + `object without key ${key}`,
395 + path,
396 + depth
397 + );
398 + } else {
399 + recur(oldValue[key], newValue[key], `${path}.${key}`, depth + 1);
400 + }
401 + }
402 + }
403 + } else if (typeof oldValue === "function") {
404 + // Bail on functions for now
405 + return;
406 + } else if (isNaN(oldValue) || isNaN(newValue)) {
407 + if (isNaN(oldValue) !== isNaN(newValue)) {
408 + error(
409 + `${isNaN(oldValue) ? "NaN" : "non-NaN value"}`,
410 + `${isNaN(newValue) ? "NaN" : "non-NaN value"}`,
411 + path,
412 + depth
413 + );
414 + }
415 + } else if (oldValue !== newValue) {
416 + error(oldValue, newValue, path, depth);
417 + }
418 + }
419 + recur(oldValue, newValue, "", 0);
420 +}
compiler/packages/snap/src/SproutTodoFilter.ts
+1
@@ -495,6 +495,7 @@ const skipFilter = new Set([
495 "flag-enable-emit-hook-guards",
496
497 "fast-refresh-refresh-on-const-changes-dev",
498 + "useState-pruned-dependency-change-detect",
499 ]);
500
501 export default skipFilter;
compiler/packages/snap/src/compiler.ts
+8
@@ -43,6 +43,7 @@ function makePluginOptions(
43 let hookPattern: string | null = null;
44 // TODO(@mofeiZ) rewrite snap fixtures to @validatePreserveExistingMemo:false
45 let validatePreserveExistingMemoizationGuarantees = false;
46 + let enableChangeDetectionForDebugging = null;
47
48 if (firstLine.indexOf("@compilationMode(annotation)") !== -1) {
49 assert(
@@ -120,6 +121,12 @@ function makePluginOptions(
121 validatePreserveExistingMemoizationGuarantees = true;
122 }
123
124 + if (firstLine.includes("@enableChangeDetectionForDebugging")) {
125 + enableChangeDetectionForDebugging = {
126 + source: "react-compiler-runtime",
127 + importSpecifierName: "$structuralCheck",
128 + };
129 + }
130 const hookPatternMatch = /@hookPattern:"([^"]+)"/.exec(firstLine);
131 if (
132 hookPatternMatch &&
@@ -173,6 +180,7 @@ function makePluginOptions(
180 enableSharedRuntime__testonly: true,
181 hookPattern,
182 validatePreserveExistingMemoizationGuarantees,
183 + enableChangeDetectionForDebugging,
184 },
185 compilationMode,
186 logger: null,