Compare commits

..

25 Commits

Author SHA1 Message Date
Rick Hanlon
d2b9b1fa18 [tests] remove withoutStack 2026-01-19 12:55:23 -05:00
Sebastian "Sebbie" Silbermann
41b3e9a670 [Fizz] Push a stalled use() to the ownerStack/debugTask (#35226) 2026-01-19 09:10:16 +01:00
Ricky
195fd2286b [tests] Fix flaky flight tests (#35513)
Flights tests are failing locally and in CI non-deterministically
because we're not disabling async hooks after tests, and GC can clear
WeakRefs non-deterministically.

This PR fixes the issue by adding an afterEach to disable installed
hooks, and normalizing the `value` to `value: {value: undefined}}` when
snapshotting.
2026-01-18 15:36:00 -05:00
Ricky
d87298ae16 [tests] add silent reporter (#35547)
Adds silent reporter so you can run tests and only see the failed tests

This helps reduce context agents use, if you're inclined to use agents:

<img width="630" height="292" alt="Screenshot 2026-01-17 at 12 39 58 PM"
src="https://github.com/user-attachments/assets/373b9803-59a6-4b9a-99f9-d74a7b41462e"
/>
2026-01-18 10:17:17 -05:00
Ricky
be3fb29904 [internal] revert change merged accidentally (#35546)
I accidentally pushed this to new flag to
https://github.com/facebook/react/pull/35541 and then merged it.

Reverting it so I can submit a review.
2026-01-17 13:21:46 -05:00
Ricky
23e5edd05c [flags] clean up enableUseEffectEventHook (#35541)
This is landed everywhere
2026-01-17 12:46:05 -05:00
Jack Pope
3926e2438f Fix ViewTransition null stateNode with SuspenseList (#35520)
I was experimenting with animations in SuspenseList and hit a crash
using ViewTransition as a direct child with `revealOrder="together"`

```
    TypeError: Cannot read properties of null (reading 'autoName')

      33 |     return props.name;
      34 |   }
    > 35 |   if (instance.autoName !== null) {
         |                ^
      36 |     return instance.autoName;
      37 |   }
```

When ViewTransition is direct child of SuspenseList, the second render
pass calls resetChildFibers, setting stateNode to null. Other fibers
create stateNode in completeWork. ViewTransition does not, so stateNode
is lost.

Followed the pattern used for Offscreen to update stateNode in beginWork
if it is null.

Also added a regression test.
2026-01-16 16:39:25 -05:00
Hendrik Liebau
6baff7ac76 [Flight] Allow cyclic references to be serialized when unwrapping lazy elements (#35471)
When `renderModelDestructive` unwraps a lazy element and subsequently
calls `renderModelDestructive` again with the resolved model, we should
preserve the parent connection so that cyclic references can be
serialized properly. This can occur in an advanced scenario where the
result from the Flight Client is serialized again with the Flight
Server, e.g. for slicing a precomputed payload into multiple parts.

Note: The added test only fails when run with `--prod`. In dev mode, the
component info outlining prevents the issue from occurring.
2026-01-16 18:42:09 +01:00
Sebastian "Sebbie" Silbermann
bef88f7c11 [DevTools] Stop setting unused global variables (#35532) 2026-01-16 16:13:29 +01:00
Sebastian "Sebbie" Silbermann
01c4d03d84 [DevTools] Clear element inspection if host element not owned by any renderer is selected (#35504) 2026-01-16 13:20:44 +01:00
Sebastian "Sebbie" Silbermann
cbc4d40663 Typecheck React DevTools extension main script (#35519) 2026-01-16 13:08:28 +01:00
Josh Story
db71391c5c [Fiber] Instrument the lazy initializer thenable in all cases (#35521)
When a lazy element or component is initialized a thenable is returned
which was only be conditionally instrumented in dev when asyncDebugInfo
was enabled. When instrumented these thenables can be used in
conjunction with the SuspendOnImmediate optimization where if a thenable
resolves before the stack unwinds we can continue rendering from the
last suspended fiber. Without this change a recent fix to the useId
implementation cannot be easily tested in production because this
optimization pathway isn't available to regular React.lazy thenables. To
land the prior PR I changed the thenables to a custom type so I could
instrument manually in the test. WIth this change we can just use a
regular Promise since ReactLazy will instrument in all
environments/flags now
2026-01-15 19:05:23 -08:00
Sebastian Markbåge
4cf906380d Optimize gesture by allowing the original work in progress tree to be a suspended commit (#35510)
Stacked on #35487.

This is slightly different because the first suspended commit is on
blockers that prevent us from committing which still needs to be
resolved first.

If a gesture lane has to be rerendered while the gesture is happening
then it reenters this state with a new tree. (Currently this doesn't
happen for a ping I think which is not really how it usually works but
better in this case.)
2026-01-15 20:51:36 -05:00
Sebastian Markbåge
eac3c95537 Defer useDeferredValue updates in Gestures (#35511)
If an initial value is specified, then it's always used regardless as
part of the gesture render.

If a gesture render causes an update, then previously that was not
treated as deferred and could therefore be blocking the render. However,
a gesture is supposed to flush synchronously ideally. Therefore we
should consider these as urgent.

The effect is that useDeferredValue renders the previous state.
2026-01-15 20:46:11 -05:00
Sebastian Markbåge
35a81cecf7 Entangle Gesture revert commit with the corresponding Action commit (#35487)
Stacked on #35486.

When a Gesture commits, it leaves behind work on a Transition lane
(`revertLane`). This entangles that lane with whatever lane we're using
in the event that cancels the Gesture. This ensures that the revert and
the result of any resulting Action commits as one batch. Typically the
Action would apply a new state that is similar or the same as the revert
of the Gesture.

This makes it resilient to unbatching in #35392.
2026-01-15 20:45:14 -05:00
Sebastian Markbåge
4028aaa50c Commit the Gesture lane if a gesture ends closer to the target state (#35486)
Stacked on #35485.

Before this PR, the `startGestureTransition` API would itself never
commit its state. After the gesture releases it stops the animation in
the next commit which just leaves the DOM tree in the original state. If
there's an actual state change from the Action then that's committed as
the new DOM tree. To avoid animating from the original state to the new
state again, this is DOM without an animation. However, this means that
you can't have the actual action committing be in a slightly different
state and animate between the final gesture state and into the new
action.

Instead, we now actually keep the render tree around and commit it in
the end. Basically we assume that if the Timeline was closer to the end
then visually you're already there and we can commit into that state.
Most of the time this will be at the actual end state when you release
but if you have something else cancelling the gesture (e.g.
`touchcancel`) it can still commit this state even though your gesture
recognizer might not consider this an Action. I think this is ok and
keeps it simple.

When the gesture lane commits, it'll leave a Transition behind as work
from the revert lanes on the Optimistic updates. This means that if you
don't do anything in the Action this will cause another commit right
after which reverts. This revert can animate the snap back.

There's a few fixes needed in follow up PRs:

- Fixed in #35487. ~To support unentangled Transitions we need to
explicitly entangle the revert lane with the Action to avoid committing
a revert followed by a forward instead of committing the forward
entangled with the revert. This just works now since everything is
entangled but won't work with #35392.~
- Fixed in #35510. ~This currently rerenders the gesture lane once
before committing if it was already completed but blocked. We should be
able to commit the already completed tree as is.~
2026-01-15 20:43:52 -05:00
Josh Story
f0fbb0d199 [Fiber] fix useId tracking on replay (#35518)
When Fiber replays work after suspending and resolving in a microtask it
stripped the Forked flag from Fibers because this flag type was not
considered a Static flag. The Forked nature of a Fiber is not render
dependent and should persist after unwinding work. By making this change
the replay correctly generates the necessary tree context.
2026-01-15 17:27:58 -08:00
Sebastian "Sebbie" Silbermann
bb8a76c6cc [DevTools] Show fallback in inspected element pane when no element is selected (#35503) 2026-01-15 14:28:02 +01:00
Sebastian "Sebbie" Silbermann
fae15df40e [DevTools] Add React Element pane to browser Elements panel (#35240) 2026-01-15 13:24:06 +01:00
Błażej Kustra
53daaf5aba Improve the detection of changed hooks (#35123)
## Summary

cc @hoxyq 

Fixes https://github.com/facebook/react/issues/28584. Follow up to PR:
https://github.com/facebook/react/pull/34547

This PR updates getChangedHooksIndices to account for the fact that
`useSyncExternalStore`, `useTransition`, `useActionState`,
`useFormState` internally mounts more than one hook while DevTools
should treat it as a single user-facing hook.

Approach idea came from
[this](https://github.com/facebook/react/pull/34547#issuecomment-3504113776)
comment 😄

Before:


https://github.com/user-attachments/assets/6bd5ce80-8b52-4bb8-8bb1-5e91b1e65043


After:


https://github.com/user-attachments/assets/47f56898-ab34-46b6-be7a-a54024dcefee



## How did you test this change?

I used this component to reproduce this issue locally (I followed
instructions in `packages/react-devtools/CONTRIBUTING.md`).

<details><summary>Details</summary>

```ts

import * as React from 'react';

function useDeepNestedHook() {
  React.useState(0); // 1
  return React.useState(1); // 2
}

function useNestedHook() {
  const deepState = useDeepNestedHook();
  React.useState(2); // 3
  React.useState(3); // 4

  return deepState;
}

// Create a simple store for useSyncExternalStore
function createStore(initialValue) {
  let value = initialValue;
  const listeners = new Set();
  return {
    getSnapshot: () => value,
    subscribe: listener => {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    update: newValue => {
      value = newValue;
      listeners.forEach(listener => listener());
    },
  };
}

const syncExternalStore = createStore(0);

export default function InspectableElements(): React.Node {
  const [nestedState, setNestedState] = useNestedHook();

  // 5
  const syncExternalValue = React.useSyncExternalStore(
    syncExternalStore.subscribe,
    syncExternalStore.getSnapshot,
  );

  // 6
  const [isPending, startTransition] = React.useTransition();

  // 7
  const [formState, formAction, formPending] = React.useActionState(
    async (prevState, formData) => {
      return {count: (prevState?.count || 0) + 1};
    },
    {count: 0},
  );

  const handleTransition = () => {
    startTransition(() => {
      setState(Math.random());
    });
  };

  // 8
  const [state, setState] = React.useState('test');

  return (
    <>
      <div
        style={{
          padding: '20px',
          display: 'flex',
          flexDirection: 'column',
          gap: '10px',
        }}>
        <div
          onClick={() => setNestedState(Math.random())}
          style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
          State: {nestedState}
        </div>

        <button onClick={handleTransition} style={{padding: '10px'}}>
          Trigger Transition {isPending ? '(pending...)' : ''}
        </button>

        <div style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
          <button
            onClick={() => syncExternalStore.update(syncExternalValue + 1)}
            style={{padding: '10px'}}>
            Trigger useSyncExternalStore
          </button>
          <span>Value: {syncExternalValue}</span>
        </div>

        <form
          action={formAction}
          style={{display: 'flex', gap: '10px', alignItems: 'center'}}>
          <button
            type="submit"
            style={{padding: '10px'}}
            disabled={formPending}>
            Trigger useFormState {formPending ? '(pending...)' : ''}
          </button>
          <span>Count: {formState.count}</span>
        </form>

        <div
          onClick={() => setState(Math.random())}
          style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}>
          State: {state}
        </div>
      </div>
    </>
  );
}
```


</details>

---------

Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com>
2026-01-15 11:06:14 +00:00
Sebastian Markbåge
4a3d993e52 Add the suffix to cancelled view transition names (#35485)
When a View Transition might not need to update we add it to a queue. If
the parent are able to be reverted, we then cancel the already started
view transitions. We do this by adding an animation that hides the "old"
state and remove the view transition name from the old state.

There was a bug where if you have more than one child in a
`<ViewTransition>` we didn't add the right suffix to the name we added
in the queue so it wasn't adding an animation that hides the old state.
The effect was that it playing an exit animation instead of being
cancelled.
2026-01-14 10:00:06 -05:00
Ricky
3e1abcc8d7 [tests] Require exact error messages in assertConsole helpers (#35497)
Requires full error message in assert helpers. 

Some of the error messages we asset on add a native javascript stack
trace, which would be a pain to add to the messages and maintain. This
PR allows you to just add `\n in <stack>` placeholder to the error
message to denote a native stack trace is present in the message.

---
Note: i vibe coded this so it was a pain to backtrack this to break this
into a stack, I tried and gave up, sorry.
2026-01-13 15:52:53 -05:00
Josh Story
c18662405c [Fiber] Correctly handle replaying when hydrating (#35494)
When hydrating if something suspends and then resolves in a microtask it
is possible that React will resume the render without fully unwinding
work in progress. This can cause hydration cursors to be offset and lead
to hydration errors. This change adds a restore step when replaying
HostComponent to ensure the hydration cursor is in the appropriate
position when replaying.

fixes: #35210
2026-01-13 12:48:01 -08:00
Yukimasa Funaoka
583e200332 [DevTools] Enable minimal support in pages with sandbox Content-Security-Policy (#35208) 2026-01-13 17:49:44 +01:00
Sebastian "Sebbie" Silbermann
8a83073753 [test] Fix DevTools regression tests (#35501) 2026-01-13 16:00:16 +01:00
178 changed files with 4758 additions and 3339 deletions

View File

@@ -278,6 +278,7 @@ jobs:
if: steps.node_modules.outputs.cache-hit != 'true'
- run: yarn --cwd compiler install --frozen-lockfile
if: steps.node_modules.outputs.cache-hit != 'true'
- run: node --version
- run: yarn test ${{ matrix.params }} --ci --shard=${{ matrix.shard }}
# Hardcoded to improve parallelism
@@ -445,6 +446,7 @@ jobs:
merge-multiple: true
- name: Display structure of build
run: ls -R build
- run: node --version
- run: yarn test --build ${{ matrix.test_params }} --shard=${{ matrix.shard }} --ci
test_build_devtools:
@@ -489,6 +491,7 @@ jobs:
merge-multiple: true
- name: Display structure of build
run: ls -R build
- run: node --version
- run: yarn test --build --project=devtools -r=experimental --shard=${{ matrix.shard }} --ci
process_artifacts_combined:

View File

@@ -132,12 +132,6 @@ export class CompilerDiagnostic {
return new CompilerDiagnostic({...options, details: []});
}
clone(): CompilerDiagnostic {
const cloned = CompilerDiagnostic.create({...this.options});
cloned.options.details = [...this.options.details];
return cloned;
}
get reason(): CompilerDiagnosticOptions['reason'] {
return this.options.reason;
}

View File

@@ -96,6 +96,7 @@ import {propagateScopeDependenciesHIR} from '../HIR/PropagateScopeDependenciesHI
import {outlineJSX} from '../Optimization/OutlineJsx';
import {optimizePropsMethodCalls} from '../Optimization/OptimizePropsMethodCalls';
import {transformFire} from '../Transform';
import {validateNoImpureFunctionsInRender} from '../Validation/ValidateNoImpureFunctionsInRender';
import {validateStaticComponents} from '../Validation/ValidateStaticComponents';
import {validateNoFreezingKnownMutableFunctions} from '../Validation/ValidateNoFreezingKnownMutableFunctions';
import {inferMutationAliasingEffects} from '../Inference/InferMutationAliasingEffects';
@@ -106,7 +107,6 @@ import {nameAnonymousFunctions} from '../Transform/NameAnonymousFunctions';
import {optimizeForSSR} from '../Optimization/OptimizeForSSR';
import {validateExhaustiveDependencies} from '../Validation/ValidateExhaustiveDependencies';
import {validateSourceLocations} from '../Validation/ValidateSourceLocations';
import {validateNoImpureValuesInRender} from '../Validation/ValidateNoImpureValuesInRender';
export type CompilerPipelineValue =
| {kind: 'ast'; name: string; value: CodegenFunction}
@@ -271,6 +271,10 @@ function runWithEnvironment(
assertValidMutableRanges(hir);
}
if (env.config.validateRefAccessDuringRender) {
validateNoRefAccessInRender(hir).unwrap();
}
if (env.config.validateNoSetStateInRender) {
validateNoSetStateInRender(hir).unwrap();
}
@@ -292,15 +296,8 @@ function runWithEnvironment(
env.logErrors(validateNoJSXInTryStatement(hir));
}
if (
env.config.validateNoImpureFunctionsInRender ||
env.config.validateRefAccessDuringRender
) {
validateNoImpureValuesInRender(hir).unwrap();
}
if (env.config.validateRefAccessDuringRender) {
validateNoRefAccessInRender(hir).unwrap();
if (env.config.validateNoImpureFunctionsInRender) {
validateNoImpureFunctionsInRender(hir).unwrap();
}
validateNoFreezingKnownMutableFunctions(hir).unwrap();

View File

@@ -38,7 +38,7 @@ import {
addObject,
} from './ObjectShape';
import {BuiltInType, ObjectType, PolyType} from './Types';
import {AliasingSignatureConfig, TypeConfig} from './TypeSchema';
import {TypeConfig} from './TypeSchema';
import {assertExhaustive} from '../Utils/utils';
import {isHookName} from './Environment';
import {CompilerError, SourceLocation} from '..';
@@ -626,136 +626,11 @@ const TYPED_GLOBALS: Array<[string, BuiltInType]> = [
// TODO: rest of Global objects
];
const RenderHookAliasing: (
reason: ValueReason,
) => AliasingSignatureConfig = reason => ({
receiver: '@receiver',
params: [],
rest: '@rest',
returns: '@returns',
temporaries: [],
effects: [
// Freeze the arguments
{
kind: 'Freeze',
value: '@rest',
reason: ValueReason.HookCaptured,
},
// Render the arguments
{
kind: 'Render',
place: '@rest',
},
// Returns a frozen value
{
kind: 'Create',
into: '@returns',
value: ValueKind.Frozen,
reason,
},
// May alias any arguments into the return
{
kind: 'Alias',
from: '@rest',
into: '@returns',
},
],
});
const EffectHookAliasing: AliasingSignatureConfig = {
receiver: '@receiver',
params: ['@fn', '@deps'],
rest: '@rest',
returns: '@returns',
temporaries: ['@effect'],
effects: [
// Freezes the function and deps
{
kind: 'Freeze',
value: '@rest',
reason: ValueReason.Effect,
},
{
kind: 'Freeze',
value: '@fn',
reason: ValueReason.Effect,
},
{
kind: 'Freeze',
value: '@deps',
reason: ValueReason.Effect,
},
// Deps are accessed during render
{
kind: 'Render',
place: '@deps',
},
// Internally creates an effect object that captures the function and deps
{
kind: 'Create',
into: '@effect',
value: ValueKind.Frozen,
reason: ValueReason.KnownReturnSignature,
},
// The effect stores the function and dependencies
{
kind: 'Capture',
from: '@rest',
into: '@effect',
},
{
kind: 'Capture',
from: '@fn',
into: '@effect',
},
// Returns undefined
{
kind: 'Create',
into: '@returns',
value: ValueKind.Primitive,
reason: ValueReason.KnownReturnSignature,
},
],
};
/*
* TODO(mofeiZ): We currently only store rest param effects for hooks.
* now that FeatureFlag `enableTreatHooksAsFunctions` is removed we can
* use positional params too (?)
*/
const useEffectEvent = addHook(
DEFAULT_SHAPES,
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: {
kind: 'Function',
return: {kind: 'Poly'},
shapeId: BuiltInEffectEventId,
isConstructor: false,
},
calleeEffect: Effect.Read,
hookKind: 'useEffectEvent',
// Frozen because it should not mutate any locally-bound values
returnValueKind: ValueKind.Frozen,
aliasing: {
receiver: '@receiver',
params: ['@value'],
rest: null,
returns: '@return',
temporaries: [],
effects: [
{kind: 'Assign', from: '@value', into: '@return'},
{
kind: 'Freeze',
value: '@value',
reason: ValueReason.HookCaptured,
},
],
},
},
BuiltInUseEffectEventId,
);
const REACT_APIS: Array<[string, BuiltInType]> = [
[
'useContext',
@@ -769,7 +644,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useContext',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.Context,
aliasing: RenderHookAliasing(ValueReason.Context),
},
BuiltInUseContextHookId,
),
@@ -784,7 +658,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.State),
}),
],
[
@@ -797,7 +670,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useActionState',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -810,7 +682,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useReducer',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.ReducerState,
aliasing: RenderHookAliasing(ValueReason.ReducerState),
}),
],
[
@@ -844,7 +715,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useMemo',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -856,7 +726,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useCallback',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -870,7 +739,41 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useEffect',
returnValueKind: ValueKind.Frozen,
aliasing: EffectHookAliasing,
aliasing: {
receiver: '@receiver',
params: [],
rest: '@rest',
returns: '@returns',
temporaries: ['@effect'],
effects: [
// Freezes the function and deps
{
kind: 'Freeze',
value: '@rest',
reason: ValueReason.Effect,
},
// Internally creates an effect object that captures the function and deps
{
kind: 'Create',
into: '@effect',
value: ValueKind.Frozen,
reason: ValueReason.KnownReturnSignature,
},
// The effect stores the function and dependencies
{
kind: 'Capture',
from: '@rest',
into: '@effect',
},
// Returns undefined
{
kind: 'Create',
into: '@returns',
value: ValueKind.Primitive,
reason: ValueReason.KnownReturnSignature,
},
],
},
},
BuiltInUseEffectHookId,
),
@@ -886,7 +789,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useLayoutEffect',
returnValueKind: ValueKind.Frozen,
aliasing: EffectHookAliasing,
},
BuiltInUseLayoutEffectHookId,
),
@@ -902,7 +804,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useInsertionEffect',
returnValueKind: ValueKind.Frozen,
aliasing: EffectHookAliasing,
},
BuiltInUseInsertionEffectHookId,
),
@@ -916,7 +817,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
calleeEffect: Effect.Read,
hookKind: 'useTransition',
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -929,7 +829,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
hookKind: 'useOptimistic',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
}),
],
[
@@ -943,7 +842,6 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnType: {kind: 'Poly'},
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Frozen,
aliasing: RenderHookAliasing(ValueReason.HookCaptured),
},
BuiltInUseOperatorId,
),
@@ -968,8 +866,27 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
BuiltInFireId,
),
],
['useEffectEvent', useEffectEvent],
['experimental_useEffectEvent', useEffectEvent],
[
'useEffectEvent',
addHook(
DEFAULT_SHAPES,
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: {
kind: 'Function',
return: {kind: 'Poly'},
shapeId: BuiltInEffectEventId,
isConstructor: false,
},
calleeEffect: Effect.Read,
hookKind: 'useEffectEvent',
// Frozen because it should not mutate any locally-bound values
returnValueKind: ValueKind.Frozen,
},
BuiltInUseEffectEventId,
),
],
['AUTODEPS', addObject(DEFAULT_SHAPES, BuiltInAutodepsId, [])],
];

View File

@@ -1879,15 +1879,7 @@ export function isRefValueType(id: Identifier): boolean {
}
export function isUseRefType(id: Identifier): boolean {
return isUseRefType_(id.type);
}
export function isUseRefType_(type: Type): boolean {
return (
(type.kind === 'Object' && type.shapeId === 'BuiltInUseRefId') ||
(type.kind === 'Phi' &&
type.operands.some(operand => isUseRefType_(operand)))
);
return id.type.kind === 'Object' && id.type.shapeId === 'BuiltInUseRefId';
}
export function isUseStateType(id: Identifier): boolean {
@@ -1898,13 +1890,6 @@ export function isJsxType(type: Type): boolean {
return type.kind === 'Object' && type.shapeId === 'BuiltInJsx';
}
export function isJsxOrJsxUnionType(type: Type): boolean {
return (
(type.kind === 'Object' && type.shapeId === 'BuiltInJsx') ||
(type.kind === 'Phi' && type.operands.some(op => isJsxOrJsxUnionType(op)))
);
}
export function isRefOrRefValue(id: Identifier): boolean {
return isUseRefType(id) || isRefValueType(id);
}
@@ -2073,23 +2058,4 @@ export function getHookKindForType(
return null;
}
export function areEqualSourceLocations(
loc1: SourceLocation,
loc2: SourceLocation,
): boolean {
if (typeof loc1 === 'symbol' || typeof loc2 === 'symbol') {
return false;
}
return (
loc1.filename === loc2.filename &&
loc1.identifierName === loc2.identifierName &&
loc1.start.line === loc2.start.line &&
loc1.start.column === loc2.start.column &&
loc1.start.index === loc2.start.index &&
loc1.end.line === loc2.end.line &&
loc1.end.column === loc2.end.column &&
loc1.end.index === loc2.end.index
);
}
export * from './Types';

View File

@@ -988,7 +988,7 @@ export function createTemporaryPlace(
identifier: makeTemporaryIdentifier(env.nextIdentifierId, loc),
reactive: false,
effect: Effect.Unknown,
loc,
loc: GeneratedSource,
};
}

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerError, ErrorCategory} from '../CompilerError';
import {CompilerError} from '../CompilerError';
import {AliasingEffect, AliasingSignature} from '../Inference/AliasingEffects';
import {assertExhaustive} from '../Utils/utils';
import {
@@ -190,22 +190,14 @@ function parseAliasingSignatureConfig(
};
}
case 'Impure': {
const into = lookup(effect.into);
return {
kind: 'Impure',
into,
category: ErrorCategory.Purity,
description: effect.description,
reason: effect.reason,
sourceMessage: effect.sourceMessage,
usageMessage: effect.usageMessage,
};
}
case 'Render': {
const place = lookup(effect.place);
return {
kind: 'Render',
kind: 'Impure',
place,
error: CompilerError.throwTodo({
reason: 'Support impure effect declarations',
loc: GeneratedSource,
}),
};
}
case 'Apply': {
@@ -1521,11 +1513,6 @@ export const DefaultNonmutatingHook = addHook(
value: '@rest',
reason: ValueReason.HookCaptured,
},
// Render the arguments
{
kind: 'Render',
place: '@rest',
},
// Returns a frozen value
{
kind: 'Create',

View File

@@ -1009,7 +1009,7 @@ export function printAliasingEffect(effect: AliasingEffect): string {
return `MutateGlobal ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
}
case 'Impure': {
return `Impure ${printPlaceForAliasEffect(effect.into)} reason=${effect.reason} description=${effect.description}`;
return `Impure ${printPlaceForAliasEffect(effect.place)} reason=${JSON.stringify(effect.error.reason)}`;
}
case 'Render': {
return `Render ${printPlaceForAliasEffect(effect.place)}`;

View File

@@ -185,29 +185,11 @@ export const ApplyEffectSchema: z.ZodType<ApplyEffectConfig> = z.object({
export type ImpureEffectConfig = {
kind: 'Impure';
into: string;
reason: string;
description: string;
sourceMessage: string;
usageMessage: string;
place: string;
};
export const ImpureEffectSchema: z.ZodType<ImpureEffectConfig> = z.object({
kind: z.literal('Impure'),
into: LifetimeIdSchema,
reason: z.string(),
description: z.string(),
sourceMessage: z.string(),
usageMessage: z.string(),
});
export type RenderEffectConfig = {
kind: 'Render';
place: string;
};
export const RenderEffectSchema: z.ZodType<RenderEffectConfig> = z.object({
kind: z.literal('Render'),
place: LifetimeIdSchema,
});
@@ -222,8 +204,7 @@ export type AliasingEffectConfig =
| ImpureEffectConfig
| MutateEffectConfig
| MutateTransitiveConditionallyConfig
| ApplyEffectConfig
| RenderEffectConfig;
| ApplyEffectConfig;
export const AliasingEffectSchema: z.ZodType<AliasingEffectConfig> = z.union([
FreezeEffectSchema,

View File

@@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {CompilerDiagnostic, ErrorCategory} from '../CompilerError';
import {CompilerDiagnostic} from '../CompilerError';
import {
FunctionExpression,
GeneratedSource,
@@ -162,15 +162,7 @@ export type AliasingEffect =
/**
* Indicates a side-effect that is not safe during render
*/
| {
kind: 'Impure';
into: Place;
category: ErrorCategory;
reason: string;
description: string;
usageMessage: string;
sourceMessage: string;
}
| {kind: 'Impure'; place: Place; error: CompilerDiagnostic}
/**
* Indicates that a given place is accessed during render. Used to distingush
* hook arguments that are known to be called immediately vs those used for
@@ -230,14 +222,6 @@ export function hashEffect(effect: AliasingEffect): string {
return [effect.kind, effect.value.identifier.id, effect.reason].join(':');
}
case 'Impure':
return [
effect.kind,
effect.into.identifier.id,
effect.reason,
effect.description,
effect.usageMessage,
effect.sourceMessage,
].join(':');
case 'Render': {
return [effect.kind, effect.place.identifier.id].join(':');
}

View File

@@ -8,7 +8,7 @@
import {BlockId, computePostDominatorTree, HIRFunction, Place} from '../HIR';
import {PostDominator} from '../HIR/Dominator';
export type ControlDominators = (id: BlockId) => Place | null;
export type ControlDominators = (id: BlockId) => boolean;
/**
* Returns an object that lazily calculates whether particular blocks are controlled
@@ -23,7 +23,7 @@ export function createControlDominators(
});
const postDominatorFrontierCache = new Map<BlockId, Set<BlockId>>();
function isControlledBlock(id: BlockId): Place | null {
function isControlledBlock(id: BlockId): boolean {
let controlBlocks = postDominatorFrontierCache.get(id);
if (controlBlocks === undefined) {
controlBlocks = postDominatorFrontier(fn, postDominators, id);
@@ -35,24 +35,24 @@ export function createControlDominators(
case 'if':
case 'branch': {
if (isControlVariable(controlBlock.terminal.test)) {
return controlBlock.terminal.test;
return true;
}
break;
}
case 'switch': {
if (isControlVariable(controlBlock.terminal.test)) {
return controlBlock.terminal.test;
return true;
}
for (const case_ of controlBlock.terminal.cases) {
if (case_.test !== null && isControlVariable(case_.test)) {
return case_.test;
return true;
}
}
break;
}
}
}
return null;
return false;
}
return isControlledBlock;

View File

@@ -27,13 +27,11 @@ import {
InstructionKind,
InstructionValue,
isArrayType,
isJsxOrJsxUnionType,
isJsxType,
isMapType,
isMutableEffect,
isPrimitiveType,
isRefOrRefValue,
isSetType,
isUseRefType,
makeIdentifierId,
Phi,
Place,
@@ -72,7 +70,6 @@ import {
MutationReason,
} from './AliasingEffects';
import {ErrorCategory} from '../CompilerError';
import {REF_ERROR_DESCRIPTION} from '../Validation/ValidateNoRefAccessInRender';
const DEBUG = false;
@@ -572,32 +569,14 @@ function inferBlock(
terminal.effects = effects.length !== 0 ? effects : null;
}
} else if (terminal.kind === 'return') {
terminal.effects = [
context.internEffect({
kind: 'Alias',
from: terminal.value,
into: context.fn.returns,
}),
];
if (!context.isFuctionExpression) {
terminal.effects.push(
terminal.effects = [
context.internEffect({
kind: 'Freeze',
value: terminal.value,
reason: ValueReason.JsxCaptured,
}),
);
}
if (
context.fn.fnType === 'Component' ||
isJsxOrJsxUnionType(context.fn.returns.identifier.type)
) {
terminal.effects.push(
context.internEffect({
kind: 'Render',
place: terminal.value,
}),
);
];
}
}
}
@@ -770,7 +749,17 @@ function applyEffect(
break;
}
case 'ImmutableCapture': {
effects.push(effect);
const kind = state.kind(effect.from).kind;
switch (kind) {
case ValueKind.Global:
case ValueKind.Primitive: {
// no-op: we don't need to track data flow for copy types
break;
}
default: {
effects.push(effect);
}
}
break;
}
case 'CreateFrom': {
@@ -1072,17 +1061,6 @@ function applyEffect(
reason: new Set(fromValue.reason),
});
state.define(effect.into, value);
applyEffect(
context,
state,
{
kind: 'ImmutableCapture',
from: effect.from,
into: effect.into,
},
initialized,
effects,
);
break;
}
default: {
@@ -1988,11 +1966,6 @@ function computeSignatureForInstruction(
value: ValueKind.Primitive,
reason: ValueReason.Other,
});
effects.push({
kind: 'ImmutableCapture',
from: value.object,
into: lvalue,
});
} else {
effects.push({
kind: 'CreateFrom',
@@ -2000,20 +1973,6 @@ function computeSignatureForInstruction(
into: lvalue,
});
}
if (
env.config.validateRefAccessDuringRender &&
isUseRefType(value.object.identifier)
) {
effects.push({
kind: 'Impure',
into: lvalue,
category: ErrorCategory.Refs,
reason: `Cannot access ref value during render`,
description: REF_ERROR_DESCRIPTION,
sourceMessage: `Ref is initially accessed`,
usageMessage: `Ref value is used during render`,
});
}
break;
}
case 'PropertyStore':
@@ -2178,15 +2137,6 @@ function computeSignatureForInstruction(
into: lvalue,
});
}
if (value.children != null) {
// Children are typically called during render, not used as an event/effect callback
for (const child of value.children) {
effects.push({
kind: 'Render',
place: child,
});
}
}
if (value.kind === 'JsxExpression') {
if (value.tag.kind === 'Identifier') {
// Tags are render function, by definition they're called during render
@@ -2195,23 +2145,29 @@ function computeSignatureForInstruction(
place: value.tag,
});
}
for (const prop of value.props) {
const place =
prop.kind === 'JsxAttribute' ? prop.place : prop.argument;
if (isUseRefType(place.identifier)) {
continue;
}
if (place.identifier.type.kind === 'Function') {
if (isJsxOrJsxUnionType(place.identifier.type.return)) {
effects.push({
kind: 'Render',
place,
});
}
} else {
if (value.children != null) {
// Children are typically called during render, not used as an event/effect callback
for (const child of value.children) {
effects.push({
kind: 'Render',
place,
place: child,
});
}
}
for (const prop of value.props) {
if (
prop.kind === 'JsxAttribute' &&
prop.place.identifier.type.kind === 'Function' &&
(isJsxType(prop.place.identifier.type.return) ||
(prop.place.identifier.type.return.kind === 'Phi' &&
prop.place.identifier.type.return.operands.some(operand =>
isJsxType(operand),
)))
) {
// Any props which return jsx are assumed to be called during render
effects.push({
kind: 'Render',
place: prop.place,
});
}
}
@@ -2247,11 +2203,6 @@ function computeSignatureForInstruction(
value: ValueKind.Primitive,
reason: ValueReason.Other,
});
effects.push({
kind: 'ImmutableCapture',
from: value.value,
into: place,
});
} else if (patternItem.kind === 'Identifier') {
effects.push({
kind: 'CreateFrom',
@@ -2433,46 +2384,15 @@ function computeSignatureForInstruction(
});
break;
}
case 'BinaryExpression': {
effects.push({
kind: 'Create',
into: lvalue,
value: ValueKind.Primitive,
reason: ValueReason.Other,
});
effects.push({
kind: 'ImmutableCapture',
into: lvalue,
from: value.left,
});
effects.push({
kind: 'ImmutableCapture',
into: lvalue,
from: value.right,
});
break;
}
case 'UnaryExpression': {
effects.push({
kind: 'Create',
into: lvalue,
value: ValueKind.Primitive,
reason: ValueReason.Other,
});
effects.push({
kind: 'ImmutableCapture',
into: lvalue,
from: value.value,
});
break;
}
case 'TaggedTemplateExpression':
case 'BinaryExpression':
case 'Debugger':
case 'JSXText':
case 'MetaProperty':
case 'Primitive':
case 'RegExpLiteral':
case 'TemplateLiteral':
case 'UnaryExpression':
case 'UnsupportedNode': {
effects.push({
kind: 'Create',
@@ -2503,7 +2423,7 @@ function computeEffectsForLegacySignature(
lvalue: Place,
receiver: Place,
args: Array<Place | SpreadPattern | Hole>,
_loc: SourceLocation,
loc: SourceLocation,
): Array<AliasingEffect> {
const returnValueReason = signature.returnValueReason ?? ValueReason.Other;
const effects: Array<AliasingEffect> = [];
@@ -2516,18 +2436,20 @@ function computeEffectsForLegacySignature(
if (signature.impure && state.env.config.validateNoImpureFunctionsInRender) {
effects.push({
kind: 'Impure',
into: lvalue,
category: ErrorCategory.Purity,
reason: 'Cannot access impure value during render',
description:
'Calling an impure function can produce unstable results that update ' +
'unpredictably when the component happens to re-render. ' +
'(https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent)',
sourceMessage:
signature.canonicalName != null
? `\`${signature.canonicalName}\` is an impure function.`
: 'This function is impure',
usageMessage: 'Cannot access impure value during render',
place: receiver,
error: CompilerDiagnostic.create({
category: ErrorCategory.Purity,
reason: 'Cannot call impure function during render',
description:
(signature.canonicalName != null
? `\`${signature.canonicalName}\` is an impure function. `
: '') +
'Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent)',
}).withDetails({
kind: 'error',
loc,
message: 'Cannot call impure function',
}),
});
}
if (signature.knownIncompatible != null && state.env.enableValidations) {
@@ -2826,23 +2748,7 @@ function computeEffectsForSignature(
}
break;
}
case 'Impure': {
if (env.config.validateNoImpureFunctionsInRender) {
const values = substitutions.get(effect.into.identifier.id) ?? [];
for (const value of values) {
effects.push({
kind: effect.kind,
into: value,
category: effect.category,
reason: effect.reason,
description: effect.description,
sourceMessage: effect.sourceMessage,
usageMessage: effect.usageMessage,
});
}
}
break;
}
case 'Impure':
case 'MutateFrozen':
case 'MutateGlobal': {
const values = substitutions.get(effect.place.identifier.id) ?? [];

View File

@@ -19,7 +19,6 @@ import {
ValueReason,
Place,
isPrimitiveType,
isUseRefType,
} from '../HIR/HIR';
import {
eachInstructionLValue,
@@ -29,9 +28,6 @@ import {
import {assertExhaustive, getOrInsertWith} from '../Utils/utils';
import {Err, Ok, Result} from '../Utils/Result';
import {AliasingEffect, MutationReason} from './AliasingEffects';
import {printIdentifier, printType} from '../HIR/PrintHIR';
const DEBUG = false;
/**
* This pass builds an abstract model of the heap and interprets the effects of the
@@ -108,6 +104,7 @@ export function inferMutationAliasingRanges(
reason: MutationReason | null;
}> = [];
const renders: Array<{index: number; place: Place}> = [];
let index = 0;
const errors = new CompilerError();
@@ -200,12 +197,14 @@ export function inferMutationAliasingRanges(
});
} else if (
effect.kind === 'MutateFrozen' ||
effect.kind === 'MutateGlobal'
effect.kind === 'MutateGlobal' ||
effect.kind === 'Impure'
) {
errors.pushDiagnostic(effect.error);
functionEffects.push(effect);
} else if (effect.kind === 'Render') {
renders.push({index: index++, place: effect.place});
functionEffects.push(effect);
}
}
}
@@ -215,6 +214,10 @@ export function inferMutationAliasingRanges(
state.assign(index, from, into);
}
}
if (block.terminal.kind === 'return') {
state.assign(index++, block.terminal.value, fn.returns);
}
if (
(block.terminal.kind === 'maybe-throw' ||
block.terminal.kind === 'return') &&
@@ -224,31 +227,23 @@ export function inferMutationAliasingRanges(
if (effect.kind === 'Alias') {
state.assign(index++, effect.from, effect.into);
} else {
CompilerError.invariant(
effect.kind === 'Freeze' || effect.kind === 'Render',
{
reason: `Unexpected '${effect.kind}' effect for MaybeThrow terminal`,
description: null,
details: [
{
kind: 'error',
loc: block.terminal.loc,
message: null,
},
],
},
);
CompilerError.invariant(effect.kind === 'Freeze', {
reason: `Unexpected '${effect.kind}' effect for MaybeThrow terminal`,
description: null,
details: [
{
kind: 'error',
loc: block.terminal.loc,
message: null,
},
],
});
}
}
}
}
for (const mutation of mutations) {
if (DEBUG) {
console.log(
`[${mutation.index}] mutate ${printIdentifier(mutation.place.identifier)}`,
);
}
state.mutate(
mutation.index,
mutation.place.identifier,
@@ -260,16 +255,8 @@ export function inferMutationAliasingRanges(
errors,
);
}
if (DEBUG) {
console.log(state.debug());
}
for (const render of renders) {
if (DEBUG) {
console.log(
`[${render.index}] render ${printIdentifier(render.place.identifier)}`,
);
}
state.render(render.index, render.place, errors);
state.render(render.index, render.place.identifier, errors);
}
for (const param of [...fn.context, ...fn.params]) {
const place = param.kind === 'Identifier' ? param : param.place;
@@ -528,13 +515,6 @@ export function inferMutationAliasingRanges(
const ignoredErrors = new CompilerError();
for (const param of [...fn.params, ...fn.context, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
const node = state.nodes.get(place.identifier);
if (node != null && node.render != null) {
functionEffects.push({
kind: 'Render',
place: place,
});
}
tracked.push(place);
}
for (const into of tracked) {
@@ -597,6 +577,7 @@ export function inferMutationAliasingRanges(
function appendFunctionErrors(errors: CompilerError, fn: HIRFunction): void {
for (const effect of fn.aliasingEffects ?? []) {
switch (effect.kind) {
case 'Impure':
case 'MutateFrozen':
case 'MutateGlobal': {
errors.pushDiagnostic(effect.error);
@@ -631,74 +612,10 @@ type Node = {
| {kind: 'Object'}
| {kind: 'Phi'}
| {kind: 'Function'; function: HIRFunction};
render: Place | null;
};
function _printNode(node: Node): string {
const out: Array<string> = [];
debugNode(out, node);
return out.join('\n');
}
function debugNode(out: Array<string>, node: Node): void {
out.push(
printIdentifier(node.id) +
printType(node.id.type) +
` lastMutated=[${node.lastMutated}]`,
);
if (node.transitive != null) {
out.push(` transitive=${node.transitive.kind}`);
}
if (node.local != null) {
out.push(` local=${node.local.kind}`);
}
if (node.mutationReason != null) {
out.push(` mutationReason=${node.mutationReason?.kind}`);
}
const edges: Array<{
index: number;
direction: '<=' | '=>';
kind: string;
id: Identifier;
}> = [];
for (const [alias, index] of node.createdFrom) {
edges.push({index, direction: '<=', kind: 'createFrom', id: alias});
}
for (const [alias, index] of node.aliases) {
edges.push({index, direction: '<=', kind: 'alias', id: alias});
}
for (const [alias, index] of node.maybeAliases) {
edges.push({index, direction: '<=', kind: 'alias?', id: alias});
}
for (const [alias, index] of node.captures) {
edges.push({index, direction: '<=', kind: 'capture', id: alias});
}
for (const edge of node.edges) {
edges.push({
index: edge.index,
direction: '=>',
kind: edge.kind,
id: edge.node,
});
}
edges.sort((a, b) => a.index - b.index);
for (const edge of edges) {
out.push(
` [${edge.index}] ${edge.direction} ${edge.kind} ${printIdentifier(edge.id)}`,
);
}
}
class AliasingState {
nodes: Map<Identifier, Node> = new Map();
debug(): string {
const items: Array<string> = [];
for (const [_id, node] of this.nodes) {
debugNode(items, node);
}
return items.join('\n');
}
create(place: Place, value: Node['value']): void {
this.nodes.set(place.identifier, {
id: place.identifier,
@@ -712,7 +629,6 @@ class AliasingState {
lastMutated: 0,
mutationReason: null,
value,
render: null,
});
}
@@ -765,9 +681,9 @@ class AliasingState {
}
}
render(index: number, start: Place, errors: CompilerError): void {
render(index: number, start: Identifier, errors: CompilerError): void {
const seen = new Set<Identifier>();
const queue: Array<Identifier> = [start.identifier];
const queue: Array<Identifier> = [start];
while (queue.length !== 0) {
const current = queue.pop()!;
if (seen.has(current)) {
@@ -775,34 +691,11 @@ class AliasingState {
}
seen.add(current);
const node = this.nodes.get(current);
if (node == null || isUseRefType(node.id)) {
if (DEBUG) {
console.log(` render ${printIdentifier(current)}: skip mutated/ref`);
}
if (node == null || node.transitive != null || node.local != null) {
continue;
}
if (
node.local == null &&
node.transitive == null &&
node.value.kind === 'Function'
) {
const returns = node.value.function.returns;
if (
isJsxType(returns.identifier.type) ||
(returns.identifier.type.kind === 'Phi' &&
returns.identifier.type.operands.some(operand =>
isJsxType(operand),
))
) {
appendFunctionErrors(errors, node.value.function);
}
if (DEBUG) {
console.log(` render ${printIdentifier(current)}: skip function`);
}
continue;
}
if (node.render == null) {
node.render = start;
if (node.value.kind === 'Function') {
appendFunctionErrors(errors, node.value.function);
}
for (const [alias, when] of node.createdFrom) {
if (when >= index) {
@@ -816,12 +709,6 @@ class AliasingState {
}
queue.push(alias);
}
for (const [alias, when] of node.maybeAliases) {
if (when >= index) {
continue;
}
queue.push(alias);
}
for (const [capture, when] of node.captures) {
if (when >= index) {
continue;

View File

@@ -167,14 +167,6 @@ export function Set_filter<T>(
return result;
}
export function Set_subtract<T>(
source: ReadonlySet<T>,
other: Iterable<T>,
): Set<T> {
const otherSet = other instanceof Set ? other : new Set(other);
return Set_filter(source, item => !otherSet.has(item));
}
export function hasNode<T>(
input: NodePath<T | null | undefined>,
): input is NodePath<NonNullable<T>> {

View File

@@ -0,0 +1,59 @@
/**
* 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 {CompilerDiagnostic, CompilerError} from '..';
import {ErrorCategory} from '../CompilerError';
import {HIRFunction} from '../HIR';
import {getFunctionCallSignature} from '../Inference/InferMutationAliasingEffects';
import {Result} from '../Utils/Result';
/**
* Checks that known-impure functions are not called during render. Examples of invalid functions to
* call during render are `Math.random()` and `Date.now()`. Users may extend this set of
* impure functions via a module type provider and specifying functions with `impure: true`.
*
* TODO: add best-effort analysis of functions which are called during render. We have variations of
* this in several of our validation passes and should unify those analyses into a reusable helper
* and use it here.
*/
export function validateNoImpureFunctionsInRender(
fn: HIRFunction,
): Result<void, CompilerError> {
const errors = new CompilerError();
for (const [, block] of fn.body.blocks) {
for (const instr of block.instructions) {
const value = instr.value;
if (value.kind === 'MethodCall' || value.kind == 'CallExpression') {
const callee =
value.kind === 'MethodCall' ? value.property : value.callee;
const signature = getFunctionCallSignature(
fn.env,
callee.identifier.type,
);
if (signature != null && signature.impure === true) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Purity,
reason: 'Cannot call impure function during render',
description:
(signature.canonicalName != null
? `\`${signature.canonicalName}\` is an impure function. `
: '') +
'Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent)',
suggestions: null,
}).withDetails({
kind: 'error',
loc: callee.loc,
message: 'Cannot call impure function',
}),
);
}
}
}
}
return errors.asResult();
}

View File

@@ -1,307 +0,0 @@
/**
* 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 prettyFormat from 'pretty-format';
import {CompilerDiagnostic, CompilerError, Effect} from '..';
import {
areEqualSourceLocations,
HIRFunction,
IdentifierId,
InstructionId,
isJsxType,
isRefValueType,
isUseRefType,
} from '../HIR';
import {
eachInstructionLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import {AliasingEffect} from '../Inference/AliasingEffects';
import {createControlDominators} from '../Inference/ControlDominators';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {Err, Ok, Result} from '../Utils/Result';
import {
assertExhaustive,
getOrInsertWith,
Set_filter,
Set_subtract,
} from '../Utils/utils';
import {printInstruction} from '../HIR/PrintHIR';
type ImpureEffect = Extract<AliasingEffect, {kind: 'Impure'}>;
type RenderEffect = Extract<AliasingEffect, {kind: 'Render'}>;
type FunctionCache = Map<HIRFunction, Map<string, ImpuritySignature>>;
type ImpuritySignature = {effects: Array<ImpureEffect>; error: CompilerError};
export function validateNoImpureValuesInRender(
fn: HIRFunction,
): Result<void, CompilerError> {
const impure = new Map<IdentifierId, ImpureEffect>();
const result = inferImpureValues(fn, impure, new Map());
if (result.error.hasAnyErrors()) {
return Err(result.error);
}
return Ok(undefined);
}
function inferFunctionExpressionMemo(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
cache: FunctionCache,
): ImpuritySignature {
const key = fn.context
.map(place => `${place.identifier.id}:${impure.has(place.identifier.id)}`)
.join(',');
return getOrInsertWith(
getOrInsertWith(cache, fn, () => new Map()),
key,
() => inferImpureValues(fn, impure, cache),
);
}
function processEffects(
id: InstructionId,
effects: Array<AliasingEffect>,
impure: Map<IdentifierId, ImpureEffect>,
cache: FunctionCache,
): boolean {
let hasChanges = false;
const rendered: Set<IdentifierId> = new Set();
for (const effect of effects) {
if (effect.kind === 'Render') {
rendered.add(effect.place.identifier.id);
}
}
for (const effect of effects) {
switch (effect.kind) {
case 'Alias':
case 'Assign':
case 'Capture':
case 'CreateFrom':
case 'ImmutableCapture': {
const sourceEffect = impure.get(effect.from.identifier.id);
if (
sourceEffect != null &&
!impure.has(effect.into.identifier.id) &&
!rendered.has(effect.from.identifier.id) &&
!isUseRefType(effect.into.identifier) &&
!isJsxType(effect.into.identifier.type)
) {
impure.set(effect.into.identifier.id, sourceEffect);
hasChanges = true;
}
if (
sourceEffect == null &&
(effect.kind === 'Assign' || effect.kind === 'Capture') &&
!impure.has(effect.from.identifier.id) &&
!rendered.has(effect.from.identifier.id) &&
!isUseRefType(effect.from.identifier) &&
isMutable({id}, effect.into)
) {
const destinationEffect = impure.get(effect.into.identifier.id);
if (destinationEffect != null) {
impure.set(effect.from.identifier.id, destinationEffect);
hasChanges = true;
}
}
break;
}
case 'Impure': {
if (!impure.has(effect.into.identifier.id)) {
impure.set(effect.into.identifier.id, effect);
hasChanges = true;
}
break;
}
case 'Render': {
break;
}
case 'CreateFunction': {
const result = inferFunctionExpressionMemo(
effect.function.loweredFunc.func,
impure,
cache,
);
if (result.error.hasAnyErrors()) {
break;
}
const impureEffect: ImpureEffect | null =
result.effects.find(
(functionEffect: AliasingEffect): functionEffect is ImpureEffect =>
functionEffect.kind === 'Impure' &&
functionEffect.into.identifier.id ===
effect.function.loweredFunc.func.returns.identifier.id,
) ?? null;
if (impureEffect != null) {
impure.set(effect.into.identifier.id, impureEffect);
hasChanges = true;
}
break;
}
case 'MaybeAlias':
case 'Apply':
case 'Create':
case 'Freeze':
case 'Mutate':
case 'MutateConditionally':
case 'MutateFrozen':
case 'MutateGlobal':
case 'MutateTransitive':
case 'MutateTransitiveConditionally': {
break;
}
}
}
return hasChanges;
}
function inferImpureValues(
fn: HIRFunction,
impure: Map<IdentifierId, ImpureEffect>,
cache: FunctionCache,
): ImpuritySignature {
const getBlockControl = createControlDominators(fn, place => {
return impure.has(place.identifier.id);
});
let hasChanges = false;
do {
hasChanges = false;
for (const block of fn.body.blocks.values()) {
const controlPlace = getBlockControl(block.id);
const controlImpureEffect =
controlPlace != null ? impure.get(controlPlace.identifier.id) : null;
for (const phi of block.phis) {
if (impure.has(phi.place.identifier.id)) {
// Already marked impure on a previous pass
continue;
}
let impureEffect = null;
for (const [, operand] of phi.operands) {
const operandEffect = impure.get(operand.identifier.id);
if (operandEffect != null) {
impureEffect = operandEffect;
break;
}
}
if (impureEffect != null) {
impure.set(phi.place.identifier.id, impureEffect);
hasChanges = true;
} else {
for (const [pred] of phi.operands) {
const predControl = getBlockControl(pred);
if (predControl != null) {
const predEffect = impure.get(predControl.identifier.id);
if (predEffect != null) {
impure.set(phi.place.identifier.id, predEffect);
hasChanges = true;
break;
}
}
}
}
}
for (const instr of block.instructions) {
const _impure = new Set(impure.keys());
hasChanges =
processEffects(instr.id, instr.effects ?? [], impure, cache) ||
hasChanges;
}
if (block.terminal.kind === 'return' && block.terminal.effects != null) {
hasChanges =
processEffects(
block.terminal.id,
block.terminal.effects,
impure,
cache,
) || hasChanges;
}
}
} while (hasChanges);
fn.env.logger?.debugLogIRs?.({
kind: 'debug',
name: 'ValidateNoImpureValuesInRender',
value: JSON.stringify(Array.from(impure.keys()).sort(), null, 2),
});
const error = new CompilerError();
function validateRenderEffect(effect: RenderEffect): void {
const impureEffect = impure.get(effect.place.identifier.id);
if (impureEffect == null) {
return;
}
const diagnostic = CompilerDiagnostic.create({
category: impureEffect.category,
reason: impureEffect.reason,
description: impureEffect.description,
}).withDetails({
kind: 'error',
loc: effect.place.loc,
message: impureEffect.usageMessage,
});
if (!areEqualSourceLocations(effect.place.loc, impureEffect.into.loc)) {
diagnostic.withDetails({
kind: 'error',
loc: impureEffect.into.loc,
message: impureEffect.sourceMessage,
});
}
error.pushDiagnostic(diagnostic);
}
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const value = instr.value;
if (
value.kind === 'FunctionExpression' ||
value.kind === 'ObjectMethod'
) {
const result = inferFunctionExpressionMemo(
value.loweredFunc.func,
impure,
cache,
);
if (result.error.hasAnyErrors()) {
error.merge(result.error);
}
}
for (const effect of instr.effects ?? []) {
if (effect.kind === 'Render') {
validateRenderEffect(effect);
}
}
}
if (block.terminal.kind === 'return' && block.terminal.effects != null) {
for (const effect of block.terminal.effects) {
if (effect.kind === 'Render') {
validateRenderEffect(effect);
}
}
}
}
const impureEffects: Array<ImpureEffect> = [];
for (const param of [...fn.context, ...fn.params, fn.returns]) {
const place = param.kind === 'Identifier' ? param : param.place;
const impureEffect = impure.get(place.identifier.id);
if (impureEffect != null) {
impureEffects.push({
kind: 'Impure',
into: impureEffect.into,
category: impureEffect.category,
reason: impureEffect.reason,
description: impureEffect.description,
sourceMessage: impureEffect.sourceMessage,
usageMessage: impureEffect.usageMessage,
});
}
}
return {effects: impureEffects, error};
}

View File

@@ -397,10 +397,16 @@ function validateNoRefAccessInRenderImpl(
switch (instr.value.kind) {
case 'JsxExpression':
case 'JsxFragment': {
for (const operand of eachInstructionValueOperand(instr.value)) {
validateNoDirectRefValueAccess(errors, operand, env);
}
break;
}
case 'ComputedLoad':
case 'PropertyLoad': {
if (instr.value.kind === 'ComputedLoad') {
validateNoDirectRefValueAccess(errors, instr.value.property, env);
}
const objType = env.get(instr.value.object.identifier.id);
let lookupType: null | RefAccessType = null;
if (objType?.kind === 'Structure') {
@@ -493,10 +499,73 @@ function validateNoRefAccessInRenderImpl(
instr.value.kind === 'CallExpression'
? instr.value.callee
: instr.value.property;
const hookKind = getHookKindForType(fn.env, callee.identifier.type);
let returnType: RefAccessType = {kind: 'None'};
const fnType = env.get(callee.identifier.id);
let didError = false;
if (fnType?.kind === 'Structure' && fnType.fn !== null) {
returnType = fnType.fn.returnType;
if (fnType.fn.readRefEffect) {
didError = true;
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: callee.loc,
message: `This function accesses a ref value`,
}),
);
}
}
/*
* If we already reported an error on this instruction, don't report
* duplicate errors
*/
if (!didError) {
const isRefLValue = isUseRefType(instr.lvalue.identifier);
const isEventHandlerLValue = isEventHandlerType(
instr.lvalue.identifier,
);
for (const operand of eachInstructionValueOperand(instr.value)) {
/**
* By default we check that function call operands are not refs,
* ref values, or functions that can access refs.
*/
if (
isRefLValue ||
isEventHandlerLValue ||
(hookKind != null &&
hookKind !== 'useState' &&
hookKind !== 'useReducer')
) {
/**
* Allow passing refs or ref-accessing functions when:
* 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`)
* 2. lvalue is an event handler (DOM events execute outside render)
* 3. calling hooks (independently validated for ref safety)
*/
validateNoDirectRefValueAccess(errors, operand, env);
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
/**
* Special case: the lvalue is passed as a jsx child
*
* For example `<Foo>{renderHelper(ref)}</Foo>`. Here we have more
* context and infer that the ref is being passed to a component-like
* render function which attempts to obey the rules.
*/
validateNoRefValueAccess(errors, env, operand);
} else {
validateNoRefPassedToFunction(
errors,
env,
operand,
operand.loc,
);
}
}
}
env.set(instr.lvalue.identifier.id, returnType);
break;
@@ -505,6 +574,7 @@ function validateNoRefAccessInRenderImpl(
case 'ArrayExpression': {
const types: Array<RefAccessType> = [];
for (const operand of eachInstructionValueOperand(instr.value)) {
validateNoDirectRefValueAccess(errors, operand, env);
types.push(env.get(operand.identifier.id) ?? {kind: 'None'});
}
const value = joinRefAccessTypes(...types);
@@ -541,10 +611,17 @@ function validateNoRefAccessInRenderImpl(
} else {
validateNoRefUpdate(errors, env, instr.value.object, instr.loc);
}
if (
instr.value.kind === 'ComputedDelete' ||
instr.value.kind === 'ComputedStore'
) {
validateNoRefValueAccess(errors, env, instr.value.property);
}
if (
instr.value.kind === 'ComputedStore' ||
instr.value.kind === 'PropertyStore'
) {
validateNoDirectRefValueAccess(errors, instr.value.value, env);
const type = env.get(instr.value.value.identifier.id);
if (type != null && type.kind === 'Structure') {
let objectType: RefAccessType = type;
@@ -585,9 +662,27 @@ function validateNoRefAccessInRenderImpl(
* error for the write to the ref
*/
env.set(instr.lvalue.identifier.id, {kind: 'Guard', refId});
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
})
.withDetails({
kind: 'error',
loc: instr.value.value.loc,
message: `Cannot access ref value during render`,
})
.withDetails({
kind: 'hint',
message:
'To initialize a ref only once, check that the ref is null with the pattern `if (ref.current == null) { ref.current = ... }`',
}),
);
break;
}
}
validateNoRefValueAccess(errors, env, instr.value.value);
break;
}
case 'BinaryExpression': {
@@ -609,14 +704,26 @@ function validateNoRefAccessInRenderImpl(
if (refId !== null && nullish) {
env.set(instr.lvalue.identifier.id, {kind: 'Guard', refId});
} else {
for (const operand of eachInstructionValueOperand(instr.value)) {
validateNoRefValueAccess(errors, env, operand);
}
}
break;
}
default: {
for (const operand of eachInstructionValueOperand(instr.value)) {
validateNoRefValueAccess(errors, env, operand);
}
break;
}
}
// Guard values are derived from ref.current, so they can only be used in if statement targets
for (const operand of eachInstructionOperand(instr)) {
guardCheck(errors, operand, env);
}
if (
isUseRefType(instr.lvalue.identifier) &&
env.get(instr.lvalue.identifier.id)?.kind !== 'Ref'
@@ -654,8 +761,15 @@ function validateNoRefAccessInRenderImpl(
}
for (const operand of eachTerminalOperand(block.terminal)) {
if (block.terminal.kind === 'return') {
if (block.terminal.kind !== 'return') {
validateNoRefValueAccess(errors, env, operand);
if (block.terminal.kind !== 'if') {
guardCheck(errors, operand, env);
}
} else {
// Allow functions containing refs to be returned, but not direct ref values
validateNoDirectRefValueAccess(errors, operand, env);
guardCheck(errors, operand, env);
returnValues.push(env.get(operand.identifier.id));
}
}
@@ -694,6 +808,72 @@ function destructure(
return type;
}
function guardCheck(errors: CompilerError, operand: Place, env: Env): void {
if (env.get(operand.identifier.id)?.kind === 'Guard') {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: operand.loc,
message: `Cannot access ref value during render`,
}),
);
}
}
function validateNoRefValueAccess(
errors: CompilerError,
env: Env,
operand: Place,
): void {
const type = destructure(env.get(operand.identifier.id));
if (
type?.kind === 'RefValue' ||
(type?.kind === 'Structure' && type.fn?.readRefEffect)
) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: (type.kind === 'RefValue' && type.loc) || operand.loc,
message: `Cannot access ref value during render`,
}),
);
}
}
function validateNoRefPassedToFunction(
errors: CompilerError,
env: Env,
operand: Place,
loc: SourceLocation,
): void {
const type = destructure(env.get(operand.identifier.id));
if (
type?.kind === 'Ref' ||
type?.kind === 'RefValue' ||
(type?.kind === 'Structure' && type.fn?.readRefEffect)
) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: (type.kind === 'RefValue' && type.loc) || loc,
message: `Passing a ref to a function may read its value during render`,
}),
);
}
}
function validateNoRefUpdate(
errors: CompilerError,
env: Env,
@@ -706,7 +886,7 @@ function validateNoRefUpdate(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: REF_ERROR_DESCRIPTION,
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: (type.kind === 'RefValue' && type.loc) || loc,
@@ -716,7 +896,28 @@ function validateNoRefUpdate(
}
}
export const REF_ERROR_DESCRIPTION =
function validateNoDirectRefValueAccess(
errors: CompilerError,
operand: Place,
env: Env,
): void {
const type = destructure(env.get(operand.identifier.id));
if (type?.kind === 'RefValue') {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.Refs,
reason: 'Cannot access refs during render',
description: ERROR_DESCRIPTION,
}).withDetails({
kind: 'error',
loc: type.loc ?? operand.loc,
message: `Cannot access ref value during render`,
}),
);
}
}
const ERROR_DESCRIPTION =
'React refs are values that are not needed for rendering. Refs should only be accessed ' +
'outside of render, such as in event handlers or effects. ' +
'Accessing a ref value (the `current` property) during render can cause your component ' +

View File

@@ -202,10 +202,10 @@ function getSetStateCall(
);
};
const isRefControlledBlock: (id: BlockId) => Place | null =
const isRefControlledBlock: (id: BlockId) => boolean =
enableAllowSetStateFromRefsInEffects
? createControlDominators(fn, place => isDerivedFromRef(place))
: (): Place | null => null;
: (): boolean => false;
for (const [, block] of fn.body.blocks) {
if (enableAllowSetStateFromRefsInEffects) {
@@ -224,7 +224,7 @@ function getSetStateCall(
refDerivedValues.add(phi.place.identifier.id);
} else {
for (const [pred] of phi.operands) {
if (isRefControlledBlock(pred) != null) {
if (isRefControlledBlock(pred)) {
refDerivedValues.add(phi.place.identifier.id);
break;
}
@@ -337,7 +337,7 @@ function getSetStateCall(
* be needed when initial layout measurements from refs need to be stored in state.
*/
return null;
} else if (isRefControlledBlock(block.id) != null) {
} else if (isRefControlledBlock(block.id)) {
continue;
}
}

View File

@@ -0,0 +1,52 @@
## Input
```javascript
import {useRef} from 'react';
import {Stringify} from 'shared-runtime';
function Component(props) {
const ref = useRef(props.value);
const object = {};
object.foo = () => ref.current;
return <Stringify object={object} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 42}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useRef } from "react";
import { Stringify } from "shared-runtime";
function Component(props) {
const $ = _c(1);
const ref = useRef(props.value);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
const object = {};
object.foo = () => ref.current;
t0 = <Stringify object={object} shouldInvokeFns={true} />;
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: 42 }],
};
```
### Eval output
(kind: ok) <div>{"object":{"foo":{"kind":"Function","result":42}},"shouldInvokeFns":true}</div>

View File

@@ -5,7 +5,6 @@
function Component() {
const Foo = () => {
someGlobal = true;
return <div />;
};
return <Foo />;
}
@@ -27,9 +26,9 @@ error.assign-global-in-component-tag-function.ts:3:4
2 | const Foo = () => {
> 3 | someGlobal = true;
| ^^^^^^^^^^ `someGlobal` cannot be reassigned
4 | return <div />;
5 | };
6 | return <Foo />;
4 | };
5 | return <Foo />;
6 | }
```

View File

@@ -1,7 +1,6 @@
function Component() {
const Foo = () => {
someGlobal = true;
return <div />;
};
return <Foo />;
}

View File

@@ -5,7 +5,6 @@
function Component() {
const foo = () => {
someGlobal = true;
return <div />;
};
// Children are generally access/called during render, so
// modifying a global in a children function is almost
@@ -30,9 +29,9 @@ error.assign-global-in-jsx-children.ts:3:4
2 | const foo = () => {
> 3 | someGlobal = true;
| ^^^^^^^^^^ `someGlobal` cannot be reassigned
4 | return <div />;
5 | };
6 | // Children are generally access/called during render, so
4 | };
5 | // Children are generally access/called during render, so
6 | // modifying a global in a children function is almost
```

View File

@@ -1,7 +1,6 @@
function Component() {
const foo = () => {
someGlobal = true;
return <div />;
};
// Children are generally access/called during render, so
// modifying a global in a children function is almost

View File

@@ -20,26 +20,30 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.hook-ref-value.ts:5:22
3 | function Component(props) {
4 | const ref = useRef();
> 5 | useEffect(() => {}, [ref.current]);
| ^^^^^^^^^^^^^ Ref value is used during render
6 | }
7 |
8 | export const FIXTURE_ENTRYPOINT = {
error.hook-ref-value.ts:5:23
3 | function Component(props) {
4 | const ref = useRef();
> 5 | useEffect(() => {}, [ref.current]);
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^ Cannot access ref value during render
6 | }
7 |
8 | export const FIXTURE_ENTRYPOINT = {
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.hook-ref-value.ts:5:23
3 | function Component(props) {
4 | const ref = useRef();
> 5 | useEffect(() => {}, [ref.current]);
| ^^^^^^^^^^^ Cannot access ref value during render
6 | }
7 |
8 | export const FIXTURE_ENTRYPOINT = {

View File

@@ -17,23 +17,15 @@ function Component(props) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-access-ref-during-render.ts:5:9
3 | const ref = useRef(null);
4 | const value = ref.current;
> 5 | return value;
| ^^^^^ Ref value is used during render
6 | }
7 |
error.invalid-access-ref-during-render.ts:4:16
2 | function Component(props) {
3 | const ref = useRef(null);
> 4 | const value = ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^ Cannot access ref value during render
5 | return value;
6 | }
7 |

View File

@@ -28,7 +28,7 @@ export const FIXTURE_ENTRYPOINT = {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -36,16 +36,7 @@ error.invalid-access-ref-in-reducer-init.ts:8:4
6 | (state, action) => state + action,
7 | 0,
> 8 | init => ref.current
| ^^^^^^^^^^^^^^^^^^^ Ref value is used during render
9 | );
10 |
11 | return <Stringify state={state} />;
error.invalid-access-ref-in-reducer-init.ts:8:12
6 | (state, action) => state + action,
7 | 0,
> 8 | init => ref.current
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^^^^^^^^^ Passing a ref to a function may read its value during render
9 | );
10 |
11 | return <Stringify state={state} />;

View File

@@ -24,7 +24,7 @@ export const FIXTURE_ENTRYPOINT = {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -32,16 +32,7 @@ error.invalid-access-ref-in-reducer.ts:5:29
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useReducer(() => ref.current, null);
| ^^^^^^^^^^^^^^^^^ Ref value is used during render
6 |
7 | return <Stringify state={state} />;
8 | }
error.invalid-access-ref-in-reducer.ts:5:35
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useReducer(() => ref.current, null);
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^^^^^^^ Passing a ref to a function may read its value during render
6 |
7 | return <Stringify state={state} />;
8 | }

View File

@@ -20,26 +20,18 @@ function Component() {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-access-ref-in-render-mutate-object-with-ref-function.ts:8:15
error.invalid-access-ref-in-render-mutate-object-with-ref-function.ts:7:19
5 | const object = {};
6 | object.foo = () => ref.current;
7 | const refValue = object.foo();
> 8 | return <div>{refValue}</div>;
| ^^^^^^^^ Ref value is used during render
> 7 | const refValue = object.foo();
| ^^^^^^^^^^ This function accesses a ref value
8 | return <div>{refValue}</div>;
9 | }
10 |
error.invalid-access-ref-in-render-mutate-object-with-ref-function.ts:6:21
4 | const ref = useRef(null);
5 | const object = {};
> 6 | object.foo = () => ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
7 | const refValue = object.foo();
8 | return <div>{refValue}</div>;
9 | }
```

View File

@@ -24,7 +24,7 @@ export const FIXTURE_ENTRYPOINT = {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -32,16 +32,7 @@ error.invalid-access-ref-in-state-initializer.ts:5:27
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useState(() => ref.current);
| ^^^^^^^^^^^^^^^^^ Ref value is used during render
6 |
7 | return <Stringify state={state} />;
8 | }
error.invalid-access-ref-in-state-initializer.ts:5:33
3 | function Component(props) {
4 | const ref = useRef(props.value);
> 5 | const [state] = useState(() => ref.current);
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^^^^^^^ Passing a ref to a function may read its value during render
6 |
7 | return <Stringify state={state} />;
8 | }

View File

@@ -21,27 +21,17 @@ function Component(props) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-aliased-ref-in-callback-invoked-during-render-.ts:7:37
5 | const aliasedRef = ref;
6 | const current = aliasedRef.current;
> 7 | return <Foo item={item} current={current} />;
| ^^^^^^^ Ref value is used during render
error.invalid-aliased-ref-in-callback-invoked-during-render-.ts:9:33
7 | return <Foo item={item} current={current} />;
8 | };
9 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
> 9 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
| ^^^^^^^^^^^^^^^^^^^^^^^^ Cannot access ref value during render
10 | }
error.invalid-aliased-ref-in-callback-invoked-during-render-.ts:6:20
4 | const renderItem = item => {
5 | const aliasedRef = ref;
> 6 | const current = aliasedRef.current;
| ^^^^^^^^^^^^^^^^^^ Ref is initially accessed
7 | return <Foo item={item} current={current} />;
8 | };
9 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
11 |
```

View File

@@ -1,51 +0,0 @@
## Input
```javascript
import {useRef} from 'react';
import {Stringify} from 'shared-runtime';
function Component(props) {
const ref = useRef(props.value);
const object = {};
object.foo = () => ref.current;
return <Stringify object={object} shouldInvokeFns={true} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: 42}],
};
```
## Error
```
Found 1 error:
Error: Cannot access ref value during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-capturing-ref-returning-function-in-rendered-object.ts:8:28
6 | const object = {};
7 | object.foo = () => ref.current;
> 8 | return <Stringify object={object} shouldInvokeFns={true} />;
| ^^^^^^ Ref value is used during render
9 | }
10 |
11 | export const FIXTURE_ENTRYPOINT = {
error.invalid-capturing-ref-returning-function-in-rendered-object.ts:7:21
5 | const ref = useRef(props.value);
6 | const object = {};
> 7 | object.foo = () => ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
8 | return <Stringify object={object} shouldInvokeFns={true} />;
9 | }
10 |
```

View File

@@ -1,47 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {arrayPush, identity, makeArray} from 'shared-runtime';
function Component() {
const getDate = () => Date.now();
const now = getDate();
const array = [];
arrayPush(array, now);
return <Foo hasDate={array} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-indirect-via-mutation.ts:10:23
8 | const array = [];
9 | arrayPush(array, now);
> 10 | return <Foo hasDate={array} />;
| ^^^^^ Cannot access impure value during render
11 | }
12 |
error.invalid-impure-functions-in-render-indirect-via-mutation.ts:6:24
4 |
5 | function Component() {
> 6 | const getDate = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const now = getDate();
8 | const array = [];
9 | arrayPush(array, now);
```

View File

@@ -1,11 +0,0 @@
// @validateNoImpureFunctionsInRender
import {arrayPush, identity, makeArray} from 'shared-runtime';
function Component() {
const getDate = () => Date.now();
const now = getDate();
const array = [];
arrayPush(array, now);
return <Foo hasDate={array} />;
}

View File

@@ -1,46 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const getDate = () => Date.now();
const array = makeArray(getDate());
const hasDate = identity(array);
return <Foo hasDate={hasDate} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-indirect.ts:9:23
7 | const array = makeArray(getDate());
8 | const hasDate = identity(array);
> 9 | return <Foo hasDate={hasDate} />;
| ^^^^^^^ Cannot access impure value during render
10 | }
11 |
error.invalid-impure-functions-in-render-indirect.ts:6:24
4 |
5 | function Component() {
> 6 | const getDate = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const array = makeArray(getDate());
8 | const hasDate = identity(array);
9 | return <Foo hasDate={hasDate} />;
```

View File

@@ -1,10 +0,0 @@
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const getDate = () => Date.now();
const array = makeArray(getDate());
const hasDate = identity(array);
return <Foo hasDate={hasDate} />;
}

View File

@@ -1,52 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = () => Date.now();
const f = () => {
// this should error but we currently lose track of the impurity bc
// the impure value comes from behind a call
const array = makeArray(now());
const hasDate = identity(array);
return hasDate;
};
const hasDate = f();
return <Foo hasDate={hasDate} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-via-function-call-2.ts:15:23
13 | };
14 | const hasDate = f();
> 15 | return <Foo hasDate={hasDate} />;
| ^^^^^^^ Cannot access impure value during render
16 | }
17 |
error.invalid-impure-functions-in-render-via-function-call-2.ts:6:20
4 |
5 | function Component() {
> 6 | const now = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const f = () => {
8 | // this should error but we currently lose track of the impurity bc
9 | // the impure value comes from behind a call
```

View File

@@ -1,16 +0,0 @@
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = () => Date.now();
const f = () => {
// this should error but we currently lose track of the impurity bc
// the impure value comes from behind a call
const array = makeArray(now());
const hasDate = identity(array);
return hasDate;
};
const hasDate = f();
return <Foo hasDate={hasDate} />;
}

View File

@@ -1,50 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = Date.now();
const f = () => {
const array = makeArray(now);
const hasDate = identity(array);
return hasDate;
};
const hasDate = f();
return <Foo hasDate={hasDate} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-via-function-call.ts:13:23
11 | };
12 | const hasDate = f();
> 13 | return <Foo hasDate={hasDate} />;
| ^^^^^^^ Cannot access impure value during render
14 | }
15 |
error.invalid-impure-functions-in-render-via-function-call.ts:6:14
4 |
5 | function Component() {
> 6 | const now = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const f = () => {
8 | const array = makeArray(now);
9 | const hasDate = identity(array);
```

View File

@@ -1,14 +0,0 @@
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = Date.now();
const f = () => {
const array = makeArray(now);
const hasDate = identity(array);
return hasDate;
};
const hasDate = f();
return <Foo hasDate={hasDate} />;
}

View File

@@ -1,50 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {typedArrayPush, typedIdentity} from 'shared-runtime';
function Component() {
const now = Date.now();
const renderItem = () => {
const array = [];
typedArrayPush(array, now());
const hasDate = typedIdentity(array);
return <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:13:26
11 | return <Bar hasDate={hasDate} />;
12 | };
> 13 | return <Foo renderItem={renderItem} />;
| ^^^^^^^^^^ Cannot access impure value during render
14 | }
15 |
error.invalid-impure-functions-in-render-via-render-helper-typed.ts:6:14
4 |
5 | function Component() {
> 6 | const now = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const renderItem = () => {
8 | const array = [];
9 | typedArrayPush(array, now());
```

View File

@@ -1,14 +0,0 @@
// @validateNoImpureFunctionsInRender
import {typedArrayPush, typedIdentity} from 'shared-runtime';
function Component() {
const now = Date.now();
const renderItem = () => {
const array = [];
typedArrayPush(array, now());
const hasDate = typedIdentity(array);
return <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}

View File

@@ -1,49 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = Date.now();
const renderItem = () => {
const array = makeArray(now);
const hasDate = identity(array);
return <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render-via-render-helper.ts:12:26
10 | return <Bar hasDate={hasDate} />;
11 | };
> 12 | return <Foo renderItem={renderItem} />;
| ^^^^^^^^^^ Cannot access impure value during render
13 | }
14 |
error.invalid-impure-functions-in-render-via-render-helper.ts:6:14
4 |
5 | function Component() {
> 6 | const now = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
7 | const renderItem = () => {
8 | const array = makeArray(now);
9 | const hasDate = identity(array);
```

View File

@@ -1,13 +0,0 @@
// @validateNoImpureFunctionsInRender
import {identity, makeArray} from 'shared-runtime';
function Component() {
const now = Date.now();
const renderItem = () => {
const array = makeArray(now);
const hasDate = identity(array);
return <Bar hasDate={hasDate} />;
};
return <Foo renderItem={renderItem} />;
}

View File

@@ -19,65 +19,41 @@ function Component() {
```
Found 3 errors:
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:20
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^^ Cannot access impure value during render
8 | }
9 |
`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:4:15
2 |
3 | function Component() {
> 4 | const date = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
| ^^^^^^^^^^ Cannot call impure function
5 | const now = performance.now();
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:31
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^ Cannot access impure value during render
8 | }
9 |
`performance.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:5:14
3 | function Component() {
4 | const date = Date.now();
> 5 | const now = performance.now();
| ^^^^^^^^^^^^^^^^^ `performance.now` is an impure function.
| ^^^^^^^^^^^^^^^^^ Cannot call impure function
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:42
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^^ Cannot access impure value during render
8 | }
9 |
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:6:15
4 | const date = Date.now();
5 | const now = performance.now();
> 6 | const rand = Math.random();
| ^^^^^^^^^^^^^ `Math.random` is an impure function.
| ^^^^^^^^^^^^^ Cannot call impure function
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |

View File

@@ -1,45 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
function Component() {
const now = () => Date.now();
const render = () => {
return <div>{now()}</div>;
};
return <div>{render()}</div>;
}
```
## Error
```
Found 1 error:
Error: Cannot access impure value during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-value-in-render-helper.ts:5:17
3 | const now = () => Date.now();
4 | const render = () => {
> 5 | return <div>{now()}</div>;
| ^^^^^ Cannot access impure value during render
6 | };
7 | return <div>{render()}</div>;
8 | }
error.invalid-impure-value-in-render-helper.ts:3:20
1 | // @validateNoImpureFunctionsInRender
2 | function Component() {
> 3 | const now = () => Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
4 | const render = () => {
5 | return <div>{now()}</div>;
6 | };
```

View File

@@ -1,8 +0,0 @@
// @validateNoImpureFunctionsInRender
function Component() {
const now = () => Date.now();
const render = () => {
return <div>{now()}</div>;
};
return <div>{render()}</div>;
}

View File

@@ -16,23 +16,15 @@ function Component({ref}) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-read-ref-prop-in-render-destructure.ts:4:15
2 | function Component({ref}) {
3 | const value = ref.current;
> 4 | return <div>{value}</div>;
| ^^^^^ Ref value is used during render
5 | }
6 |
error.invalid-read-ref-prop-in-render-destructure.ts:3:16
1 | // @validateRefAccessDuringRender @compilationMode:"infer"
2 | function Component({ref}) {
> 3 | const value = ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^ Cannot access ref value during render
4 | return <div>{value}</div>;
5 | }
6 |

View File

@@ -16,23 +16,15 @@ function Component(props) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-read-ref-prop-in-render-property-load.ts:4:15
2 | function Component(props) {
3 | const value = props.ref.current;
> 4 | return <div>{value}</div>;
| ^^^^^ Ref value is used during render
5 | }
6 |
error.invalid-read-ref-prop-in-render-property-load.ts:3:16
1 | // @validateRefAccessDuringRender @compilationMode:"infer"
2 | function Component(props) {
> 3 | const value = props.ref.current;
| ^^^^^^^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^^^^^^^ Cannot access ref value during render
4 | return <div>{value}</div>;
5 | }
6 |

View File

@@ -22,27 +22,57 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 1 error:
Found 4 errors:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
4 | component C() {
5 | const r = useRef(null);
> 6 | const current = !r.current;
| ^^^^^^^^^ Cannot access ref value during render
7 | return <div>{current}</div>;
8 | }
9 |
To initialize a ref only once, check that the ref is null with the pattern `if (ref.current == null) { ref.current = ... }`
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
4 | component C() {
5 | const r = useRef(null);
> 6 | const current = !r.current;
| ^^^^^^^^^^ Cannot access ref value during render
7 | return <div>{current}</div>;
8 | }
9 |
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
5 | const r = useRef(null);
6 | const current = !r.current;
> 7 | return <div>{current}</div>;
| ^^^^^^^ Ref value is used during render
| ^^^^^^^ Cannot access ref value during render
8 | }
9 |
10 | export const FIXTURE_ENTRYPOINT = {
4 | component C() {
5 | const r = useRef(null);
> 6 | const current = !r.current;
| ^^^^^^^^^ Ref is initially accessed
7 | return <div>{current}</div>;
8 | }
9 |
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
5 | const r = useRef(null);
6 | const current = !r.current;
> 7 | return <div>{current}</div>;
| ^^^^^^^ Cannot access ref value during render
8 | }
9 |
10 | export const FIXTURE_ENTRYPOINT = {
```

View File

@@ -20,27 +20,17 @@ function Component(props) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-ref-in-callback-invoked-during-render.ts:6:37
4 | const renderItem = item => {
5 | const current = ref.current;
> 6 | return <Foo item={item} current={current} />;
| ^^^^^^^ Ref value is used during render
7 | };
8 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
9 | }
error.invalid-ref-in-callback-invoked-during-render.ts:5:20
3 | const ref = useRef(null);
4 | const renderItem = item => {
> 5 | const current = ref.current;
| ^^^^^^^^^^^ Ref is initially accessed
6 | return <Foo item={item} current={current} />;
7 | };
8 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
error.invalid-ref-in-callback-invoked-during-render.ts:8:33
6 | return <Foo item={item} current={current} />;
7 | };
> 8 | return <Items>{props.items.map(item => renderItem(item))}</Items>;
| ^^^^^^^^^^^^^^^^^^^^^^^^ Cannot access ref value during render
9 | }
10 |
```

View File

@@ -16,7 +16,7 @@ function Component(props) {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -24,7 +24,7 @@ error.invalid-ref-value-as-props.ts:4:19
2 | function Component(props) {
3 | const ref = useRef(null);
> 4 | return <Foo ref={ref.current} />;
| ^^^^^^^^^^^ Ref value is used during render
| ^^^^^^^^^^^ Cannot access ref value during render
5 | }
6 |
```

View File

@@ -15,9 +15,22 @@ function Component(props) {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-set-and-read-ref-during-render.ts:4:2
2 | function Component(props) {
3 | const ref = useRef(null);
> 4 | ref.current = props.value;
| ^^^^^^^^^^^ Cannot update ref during render
5 | return ref.current;
6 | }
7 |
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -25,7 +38,7 @@ error.invalid-set-and-read-ref-during-render.ts:5:9
3 | const ref = useRef(null);
4 | ref.current = props.value;
> 5 | return ref.current;
| ^^^^^^^^^^^ Ref value is used during render
| ^^^^^^^^^^^ Cannot access ref value during render
6 | }
7 |
```

View File

@@ -15,9 +15,22 @@ function Component(props) {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.invalid-set-and-read-ref-nested-property-during-render.ts:4:2
2 | function Component(props) {
3 | const ref = useRef({inner: null});
> 4 | ref.current.inner = props.value;
| ^^^^^^^^^^^ Cannot update ref during render
5 | return ref.current.inner;
6 | }
7 |
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -25,15 +38,7 @@ error.invalid-set-and-read-ref-nested-property-during-render.ts:5:9
3 | const ref = useRef({inner: null});
4 | ref.current.inner = props.value;
> 5 | return ref.current.inner;
| ^^^^^^^^^^^^^^^^^ Ref value is used during render
6 | }
7 |
error.invalid-set-and-read-ref-nested-property-during-render.ts:5:9
3 | const ref = useRef({inner: null});
4 | ref.current.inner = props.value;
> 5 | return ref.current.inner;
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^^^^^^^ Cannot access ref value during render
6 | }
7 |
```

View File

@@ -25,7 +25,19 @@ export const FIXTURE_ENTRYPOINT = {
## Error
```
Found 1 error:
Found 2 errors:
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
6 | component C() {
7 | const r = useRef(DEFAULT_VALUE);
> 8 | if (r.current == DEFAULT_VALUE) {
| ^^^^^^^^^ Cannot access ref value during render
9 | r.current = 1;
10 | }
11 | }
Error: Cannot access refs during render

View File

@@ -22,7 +22,7 @@ export const FIXTURE_ENTRYPOINT = {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
@@ -30,7 +30,7 @@ error.ref-optional.ts:5:9
3 | function Component(props) {
4 | const ref = useRef();
> 5 | return ref?.current;
| ^^^^^^^^^^^^ Ref value is used during render
| ^^^^^^^^^^^^ Cannot access ref value during render
6 | }
7 |
8 | export const FIXTURE_ENTRYPOINT = {

View File

@@ -38,24 +38,15 @@ export const FIXTURE_ENTRYPOINT = {
```
Found 1 error:
Error: Cannot access ref value during render
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
error.ref-value-in-event-handler-wrapper.ts:19:6
17 | <>
18 | <input ref={ref} />
> 19 | <button onClick={handleClick(ref.current)}>Click</button>
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Ref value is used during render
20 | </>
21 | );
22 | }
error.ref-value-in-event-handler-wrapper.ts:19:35
17 | <>
18 | <input ref={ref} />
> 19 | <button onClick={handleClick(ref.current)}>Click</button>
| ^^^^^^^^^^^ Ref is initially accessed
| ^^^^^^^^^^^ Cannot access ref value during render
20 | </>
21 | );
22 | }

View File

@@ -2,7 +2,7 @@
## Input
```javascript
// @validateExhaustiveMemoizationDependencies @validateRefAccessDuringRender:false
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {Stringify} from 'shared-runtime';

View File

@@ -1,4 +1,4 @@
// @validateExhaustiveMemoizationDependencies @validateRefAccessDuringRender:false
// @validateExhaustiveMemoizationDependencies
import {useMemo} from 'react';
import {Stringify} from 'shared-runtime';

View File

@@ -19,65 +19,41 @@ function Component() {
```
Found 3 errors:
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:20
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^^ Cannot access impure value during render
8 | }
9 |
`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:4:15
2 |
3 | function Component() {
> 4 | const date = Date.now();
| ^^^^^^^^^^ `Date.now` is an impure function.
| ^^^^^^^^^^ Cannot call impure function
5 | const now = performance.now();
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:31
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^ Cannot access impure value during render
8 | }
9 |
`performance.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:5:14
3 | function Component() {
4 | const date = Date.now();
> 5 | const now = performance.now();
| ^^^^^^^^^^^^^^^^^ `performance.now` is an impure function.
| ^^^^^^^^^^^^^^^^^ Cannot call impure function
6 | const rand = Math.random();
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
Error: Cannot access impure value during render
Error: Cannot call impure function during render
Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:7:42
5 | const now = performance.now();
6 | const rand = Math.random();
> 7 | return <Foo date={date} now={now} rand={rand} />;
| ^^^^ Cannot access impure value during render
8 | }
9 |
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
error.invalid-impure-functions-in-render.ts:6:15
4 | const date = Date.now();
5 | const now = performance.now();
> 6 | const rand = Math.random();
| ^^^^^^^^^^^^^ `Math.random` is an impure function.
| ^^^^^^^^^^^^^ Cannot call impure function
7 | return <Foo date={date} now={now} rand={rand} />;
8 | }
9 |

View File

@@ -6,7 +6,7 @@
import {useRef} from 'react';
hook useFoo() {
component Foo() {
const ref = useRef();
const s = () => {
@@ -16,10 +16,6 @@ hook useFoo() {
return s;
}
component Foo() {
useFoo();
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
@@ -34,7 +30,7 @@ import { c as _c } from "react/compiler-runtime";
import { useRef } from "react";
function useFoo() {
function Foo() {
const $ = _c(1);
const ref = useRef();
let t0;
@@ -48,10 +44,6 @@ function useFoo() {
return s;
}
function Foo() {
useFoo();
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],
@@ -60,4 +52,4 @@ export const FIXTURE_ENTRYPOINT = {
```
### Eval output
(kind: ok)
(kind: ok) "[[ function params=0 ]]"

View File

@@ -2,7 +2,7 @@
import {useRef} from 'react';
hook useFoo() {
component Foo() {
const ref = useRef();
const s = () => {
@@ -12,10 +12,6 @@ hook useFoo() {
return s;
}
component Foo() {
useFoo();
}
export const FIXTURE_ENTRYPOINT = {
fn: Foo,
params: [],

View File

@@ -25,40 +25,22 @@ component Component(prop1, ref) {
## Code
```javascript
import { c as _c, useFire } from "react/compiler-runtime";
import { useFire } from "react/compiler-runtime";
import { fire } from "react";
import { print } from "shared-runtime";
const Component = React.forwardRef(Component_withRef);
function Component_withRef(t0, ref) {
const $ = _c(5);
const { prop1 } = t0;
let t1;
if ($[0] !== prop1) {
t1 = () => {
console.log(prop1);
};
$[0] = prop1;
$[1] = t1;
} else {
t1 = $[1];
}
const foo = t1;
const t2 = useFire(foo);
let t3;
if ($[2] !== prop1 || $[3] !== t2) {
t3 = () => {
t2(prop1);
bar();
t2();
};
$[2] = prop1;
$[3] = t2;
$[4] = t3;
} else {
t3 = $[4];
}
useEffect(t3);
const foo = () => {
console.log(prop1);
};
const t1 = useFire(foo);
useEffect(() => {
t1(prop1);
bar();
t1();
});
print(ref.current);
return null;
}

View File

@@ -1,49 +0,0 @@
## Input
```javascript
// @validateNoImpureFunctionsInRender
import {useIdentity} from 'shared-runtime';
function Component() {
const f = () => Math.random();
const ref = useRef(f());
return <div ref={ref} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoImpureFunctionsInRender
import { useIdentity } from "shared-runtime";
function Component() {
const $ = _c(2);
const f = _temp;
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = f();
$[0] = t0;
} else {
t0 = $[0];
}
const ref = useRef(t0);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = <div ref={ref} />;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
function _temp() {
return Math.random();
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,8 +0,0 @@
// @validateNoImpureFunctionsInRender
import {useIdentity} from 'shared-runtime';
function Component() {
const f = () => Math.random();
const ref = useRef(f());
return <div ref={ref} />;
}

View File

@@ -29,9 +29,9 @@ testRule(
}
`,
errors: [
makeTestCaseError('Cannot access impure value during render'),
makeTestCaseError('Cannot access impure value during render'),
makeTestCaseError('Cannot access impure value during render'),
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
],
},
],

View File

@@ -171,17 +171,20 @@ export default function Page({url, navigate}) {
}}>
<h1>{!show ? 'A' + counter : 'B'}</h1>
</ViewTransition>
{show ? (
<div>
{a}
{b}
</div>
) : (
<div>
{b}
{a}
</div>
)}
{
// Using url instead of renderedUrl here lets us only update this on commit.
url === '/?b' ? (
<div>
{a}
{b}
</div>
) : (
<div>
{b}
{a}
</div>
)
}
<ViewTransition>
{show ? (
<div>hello{exclamation}</div>

View File

@@ -114,16 +114,17 @@ export default function SwipeRecognizer({
);
}
function onGestureEnd(changed) {
// Reset scroll
if (changed) {
// Trigger side-effects
startTransition(action);
}
// We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit
// before scheduling new updates.
if (activeGesture.current !== null) {
const cancelGesture = activeGesture.current;
activeGesture.current = null;
cancelGesture();
}
if (changed) {
// Trigger side-effects
startTransition(action);
}
}
function onScrollEnd() {
if (touchTimeline.current) {

View File

@@ -88,6 +88,7 @@
"jest-cli": "^29.4.2",
"jest-diff": "^29.4.2",
"jest-environment-jsdom": "^29.4.2",
"jest-silent-reporter": "^0.6.0",
"jest-snapshot-serializer-raw": "^1.2.0",
"minimatch": "^3.0.4",
"minimist": "^1.2.3",

View File

@@ -879,7 +879,7 @@ describe('ReactInternalTestUtils console assertions', () => {
if (__DEV__) {
console.warn('Hello\n in div');
}
assertConsoleWarnDev(['Hello']);
assertConsoleWarnDev(['Hello\n in div']);
});
it('passes if all warnings contain a stack', () => {
@@ -888,7 +888,11 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Good day\n in div');
console.warn('Bye\n in div');
}
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
it('fails if act is called without assertConsoleWarnDev', async () => {
@@ -1075,7 +1079,11 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi \n in div');
console.warn('Wow \n in div');
assertConsoleWarnDev(['Hi', 'Wow', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
@@ -1085,9 +1093,9 @@ describe('ReactInternalTestUtils console assertions', () => {
- Expected warnings
+ Received warnings
- Hi
- Wow
- Bye
- Hi in div
- Wow in div
- Bye in div
+ Hi in div (at **)
+ Wow in div (at **)"
`);
@@ -1188,16 +1196,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello');
console.warn('Good day\n in div');
console.warn('Bye\n in div');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Hello"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello in div
- Good day in div
- Bye in div
+ Hello
+ Good day in div (at **)
+ Bye in div (at **)"
`);
});
@@ -1207,16 +1225,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello\n in div');
console.warn('Good day');
console.warn('Bye\n in div');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Good day"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello in div
- Good day in div
- Bye in div
+ Hello in div (at **)
+ Good day
+ Bye in div (at **)"
`);
});
@@ -1226,41 +1254,26 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Hello\n in div');
console.warn('Good day\n in div');
console.warn('Bye');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
assertConsoleWarnDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Bye"
Unexpected warning(s) recorded.
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
`);
});
- Expected warnings
+ Received warnings
// @gate __DEV__
it('fails if all warnings do not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.warn('Hello');
console.warn('Good day');
console.warn('Bye');
assertConsoleWarnDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Missing component stack for:
"Hello"
Missing component stack for:
"Good day"
Missing component stack for:
"Bye"
If this warning should omit a component stack, pass [log, {withoutStack: true}].
If all warnings should omit the component stack, add {withoutStack: true} to the assertConsoleWarnDev call."
- Hello in div
- Good day in div
- Bye in div
+ Hello in div (at **)
+ Good day in div (at **)
+ Bye"
`);
});
@@ -1339,12 +1352,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello
+ Hello in div (at **)"
`);
});
@@ -1361,16 +1375,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected warnings
+ Received warnings
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -1382,9 +1396,9 @@ describe('ReactInternalTestUtils console assertions', () => {
console.warn('Bye\n in div');
}
assertConsoleWarnDev([
'Hello',
'Hello\n in div',
['Good day', {withoutStack: true}],
'Bye',
'Bye\n in div',
]);
});
@@ -1490,12 +1504,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Expected warnings
+ Received warnings
- Hello
+ Hello in div (at **)"
`);
});
@@ -1524,16 +1539,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected warning(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected warnings
+ Received warnings
If this warning should include a component stack, remove {withoutStack: true} from this warning.
If all warnings should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleWarnDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -1606,13 +1621,18 @@ describe('ReactInternalTestUtils console assertions', () => {
it('fails if component stack is passed twice', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi %s%s', '\n in div', '\n in div');
assertConsoleWarnDev(['Hi']);
assertConsoleWarnDev(['Hi \n in div (at **)']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Received more than one component stack for a warning:
"Hi %s%s""
Unexpected warning(s) recorded.
- Expected warnings
+ Received warnings
Hi in div (at **)
+ in div (at **)"
`);
});
@@ -1621,16 +1641,23 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.warn('Hi %s%s', '\n in div', '\n in div');
console.warn('Bye %s%s', '\n in div', '\n in div');
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div (at **)',
'Bye \n in div (at **)',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleWarnDev(expected)
Received more than one component stack for a warning:
"Hi %s%s"
Unexpected warning(s) recorded.
Received more than one component stack for a warning:
"Bye %s%s""
- Expected warnings
+ Received warnings
Hi in div (at **)
+ in div (at **)
Bye in div (at **)
+ in div (at **)"
`);
});
@@ -1646,7 +1673,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -1661,7 +1688,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Bye']);
assertConsoleWarnDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -1677,7 +1704,11 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleWarnDev(['Hi', 'Wow', 'Bye']);
assertConsoleWarnDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
it('should fail if waitFor is called before asserting', async () => {
@@ -1884,7 +1915,7 @@ describe('ReactInternalTestUtils console assertions', () => {
if (__DEV__) {
console.error('Hello\n in div');
}
assertConsoleErrorDev(['Hello']);
assertConsoleErrorDev(['Hello\n in div']);
});
it('passes if all errors contain a stack', () => {
@@ -1893,7 +1924,11 @@ describe('ReactInternalTestUtils console assertions', () => {
console.error('Good day\n in div');
console.error('Bye\n in div');
}
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
assertConsoleErrorDev([
'Hello\n in div',
'Good day\n in div',
'Bye\n in div',
]);
});
it('fails if act is called without assertConsoleErrorDev', async () => {
@@ -2080,7 +2115,11 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi \n in div');
console.error('Wow \n in div');
assertConsoleErrorDev(['Hi', 'Wow', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
@@ -2090,9 +2129,9 @@ describe('ReactInternalTestUtils console assertions', () => {
- Expected errors
+ Received errors
- Hi
- Wow
- Bye
- Hi in div
- Wow in div
- Bye in div
+ Hi in div (at **)
+ Wow in div (at **)"
`);
@@ -2192,101 +2231,6 @@ describe('ReactInternalTestUtils console assertions', () => {
+ TypeError: Cannot read properties of undefined (reading 'stack') in Foo (at **)"
`);
});
// @gate __DEV__
it('fails if only error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
assertConsoleErrorDev(['Hello']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if first error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello\n in div');
console.error('Good day\n in div');
console.error('Bye');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Bye"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if last error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
console.error('Good day\n in div');
console.error('Bye\n in div');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if middle error does not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello\n in div');
console.error('Good day');
console.error('Bye\n in div');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Good day"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('fails if all errors do not contain a stack', () => {
const message = expectToThrowFailure(() => {
console.error('Hello');
console.error('Good day');
console.error('Bye');
assertConsoleErrorDev(['Hello', 'Good day', 'Bye']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing component stack for:
"Hello"
Missing component stack for:
"Good day"
Missing component stack for:
"Bye"
If this error should omit a component stack, pass [log, {withoutStack: true}].
If all errors should omit the component stack, add {withoutStack: true} to the assertConsoleErrorDev call."
`);
});
// @gate __DEV__
it('regression: checks entire string, not just the first letter', async () => {
@@ -2385,12 +2329,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Expected errors
+ Received errors
- Hello
+ Hello in div (at **)"
`);
});
@@ -2407,16 +2352,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected errors
+ Received errors
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
});
@@ -2428,9 +2373,9 @@ describe('ReactInternalTestUtils console assertions', () => {
console.error('Bye\n in div');
}
assertConsoleErrorDev([
'Hello',
'Hello\n in div',
['Good day', {withoutStack: true}],
'Bye',
'Bye\n in div',
]);
});
@@ -2536,12 +2481,13 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Expected errors
+ Received errors
- Hello
+ Hello in div (at **)"
`);
});
@@ -2570,16 +2516,16 @@ describe('ReactInternalTestUtils console assertions', () => {
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Unexpected component stack for:
"Hello
in div (at **)"
Unexpected error(s) recorded.
Unexpected component stack for:
"Bye
in div (at **)"
- Expected errors
+ Received errors
If this error should include a component stack, remove {withoutStack: true} from this error.
If all errors should include the component stack, you may need to remove {withoutStack: true} from the assertConsoleErrorDev call."
- Hello
+ Hello in div (at **)
Good day
- Bye
+ Bye in div (at **)"
`);
});
@@ -2678,13 +2624,18 @@ describe('ReactInternalTestUtils console assertions', () => {
it('fails if component stack is passed twice', () => {
const message = expectToThrowFailure(() => {
console.error('Hi %s%s', '\n in div', '\n in div');
assertConsoleErrorDev(['Hi']);
assertConsoleErrorDev(['Hi \n in div (at **)']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Received more than one component stack for a warning:
"Hi %s%s""
Unexpected error(s) recorded.
- Expected errors
+ Received errors
Hi in div (at **)
+ in div (at **)"
`);
});
@@ -2693,16 +2644,23 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi %s%s', '\n in div', '\n in div');
console.error('Bye %s%s', '\n in div', '\n in div');
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div (at **)',
'Bye \n in div (at **)',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Received more than one component stack for a warning:
"Hi %s%s"
Unexpected error(s) recorded.
Received more than one component stack for a warning:
"Bye %s%s""
- Expected errors
+ Received errors
Hi in div (at **)
+ in div (at **)
Bye in div (at **)
+ in div (at **)"
`);
});
@@ -2711,14 +2669,14 @@ describe('ReactInternalTestUtils console assertions', () => {
const message = expectToThrowFailure(() => {
console.error('Hi \n in div');
console.error('Bye \n in div');
assertConsoleErrorDev('Hi', 'Bye');
assertConsoleErrorDev('Hi \n in div', 'Bye \n in div');
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -2733,7 +2691,7 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Bye']);
assertConsoleErrorDev(['Hi \n in div', 'Bye \n in div']);
});
// @gate __DEV__
@@ -2749,7 +2707,133 @@ describe('ReactInternalTestUtils console assertions', () => {
Expected messages should be an array of strings but was given type "string"."
`);
assertConsoleErrorDev(['Hi', 'Wow', 'Bye']);
assertConsoleErrorDev([
'Hi \n in div',
'Wow \n in div',
'Bye \n in div',
]);
});
describe('in <stack> placeholder', () => {
// @gate __DEV__
it('fails if `in <stack>` is used for a component stack instead of an error stack', () => {
const message = expectToThrowFailure(() => {
console.error('Warning message\n in div');
assertConsoleErrorDev(['Warning message\n in <stack>']);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "Warning message
in <stack>"
Received: "Warning message
in div (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)")."
`);
});
// @gate __DEV__
it('fails if `in <stack>` is used for multiple component stacks', () => {
const message = expectToThrowFailure(() => {
console.error('First warning\n in span');
console.error('Second warning\n in div');
assertConsoleErrorDev([
'First warning\n in <stack>',
'Second warning\n in <stack>',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "First warning
in <stack>"
Received: "First warning
in span (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)").
Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error stack traces (messages starting with "Error:"), not for React component stacks.
Expected: "Second warning
in <stack>"
Received: "Second warning
in div (at **)"
If this error has a component stack, include the full component stack in your expected message (e.g., "Warning message\\n in ComponentName (at **)")."
`);
});
it('allows `in <stack>` for actual error stack traces', () => {
// This should pass - \n in <stack> is correctly used for an error stack
console.error(new Error('Something went wrong'));
assertConsoleErrorDev(['Error: Something went wrong\n in <stack>']);
});
// @gate __DEV__
it('fails if error stack trace is present but \\n in <stack> is not expected', () => {
const message = expectToThrowFailure(() => {
console.error(new Error('Something went wrong'));
assertConsoleErrorDev(['Error: Something went wrong']);
});
expect(message).toMatch(`Unexpected error stack trace for:`);
expect(message).toMatch(`Error: Something went wrong`);
expect(message).toMatch(
'If this error should include an error stack trace, add \\n in <stack> to your expected message'
);
});
// @gate __DEV__
it('fails if `in <stack>` is expected but no stack is present', () => {
const message = expectToThrowFailure(() => {
console.error('Error: Something went wrong');
assertConsoleErrorDev([
'Error: Something went wrong\n in <stack>',
]);
});
expect(message).toMatchInlineSnapshot(`
"assertConsoleErrorDev(expected)
Missing error stack trace for:
"Error: Something went wrong"
The expected message uses \\n in <stack> but the actual error doesn't include an error stack trace.
If this error should not have an error stack trace, remove \\n in <stack> from your expected message."
`);
});
});
describe('[Environment] placeholder', () => {
// @gate __DEV__
it('expands [Server] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Server \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Server] Error: something went wrong', {withoutStack: true}],
]);
});
// @gate __DEV__
it('expands [Prerender] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Prerender \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Prerender] Error: something went wrong', {withoutStack: true}],
]);
});
// @gate __DEV__
it('expands [Cache] to ANSI escape sequence for server badge', () => {
const badge = '\u001b[0m\u001b[7m Cache \u001b[0m';
console.error(badge + 'Error: something went wrong');
assertConsoleErrorDev([
['[Cache] Error: something went wrong', {withoutStack: true}],
]);
});
});
it('should fail if waitFor is called before asserting', async () => {

View File

@@ -168,6 +168,53 @@ function normalizeCodeLocInfo(str) {
});
}
// Expands environment placeholders like [Server] into ANSI escape sequences.
// This allows test assertions to use a cleaner syntax like "[Server] Error:"
// instead of the full escape sequence "\u001b[0m\u001b[7m Server \u001b[0mError:"
function expandEnvironmentPlaceholders(str) {
if (typeof str !== 'string') {
return str;
}
// [Environment] -> ANSI escape sequence for environment badge
// The format is: reset + inverse + " Environment " + reset
return str.replace(
/^\[(\w+)] /g,
(match, env) => '\u001b[0m\u001b[7m ' + env + ' \u001b[0m',
);
}
// The error stack placeholder that can be used in expected messages
const ERROR_STACK_PLACEHOLDER = '\n in <stack>';
// A marker used to protect the placeholder during normalization
const ERROR_STACK_PLACEHOLDER_MARKER = '\n in <__STACK_PLACEHOLDER__>';
// Normalizes expected messages, handling special placeholders
function normalizeExpectedMessage(str) {
if (typeof str !== 'string') {
return str;
}
// Protect the error stack placeholder from normalization
// (normalizeCodeLocInfo would add "(at **)" to it)
const hasStackPlaceholder = str.includes(ERROR_STACK_PLACEHOLDER);
let result = str;
if (hasStackPlaceholder) {
result = result.replace(
ERROR_STACK_PLACEHOLDER,
ERROR_STACK_PLACEHOLDER_MARKER,
);
}
result = normalizeCodeLocInfo(result);
result = expandEnvironmentPlaceholders(result);
if (hasStackPlaceholder) {
// Restore the placeholder (remove the "(at **)" that was added)
result = result.replace(
ERROR_STACK_PLACEHOLDER_MARKER + ' (at **)',
ERROR_STACK_PLACEHOLDER,
);
}
return result;
}
function normalizeComponentStack(entry) {
if (
typeof entry[0] === 'string' &&
@@ -187,6 +234,15 @@ const isLikelyAComponentStack = message =>
message.includes('\n in ') ||
message.includes('\n at '));
// Error stack traces start with "*Error:" and contain "at" frames with file paths
// Component stacks contain "in ComponentName" patterns
// This helps validate that \n in <stack> is used correctly
const isLikelyAnErrorStackTrace = message =>
typeof message === 'string' &&
message.includes('Error:') &&
// Has "at" frames typical of error stacks (with file:line:col)
/\n\s+at .+\(.*:\d+:\d+\)/.test(message);
export function createLogAssertion(
consoleMethod,
matcherName,
@@ -236,13 +292,11 @@ export function createLogAssertion(
const withoutStack = options.withoutStack;
// Warn about invalid global withoutStack values.
if (consoleMethod === 'log' && withoutStack !== undefined) {
throwFormattedError(
`Do not pass withoutStack to assertConsoleLogDev, console.log does not have component stacks.`,
);
} else if (withoutStack !== undefined && withoutStack !== true) {
// withoutStack can only have a value true.
throwFormattedError(
`The second argument must be {withoutStack: true}.` +
`\n\nInstead received ${JSON.stringify(options)}.`,
@@ -256,8 +310,11 @@ export function createLogAssertion(
const unexpectedLogs = [];
const unexpectedMissingComponentStack = [];
const unexpectedIncludingComponentStack = [];
const unexpectedMissingErrorStack = [];
const unexpectedIncludingErrorStack = [];
const logsMismatchingFormat = [];
const logsWithExtraComponentStack = [];
const stackTracePlaceholderMisuses = [];
// Loop over all the observed logs to determine:
// - Which expected logs are missing
@@ -319,11 +376,11 @@ export function createLogAssertion(
);
}
expectedMessage = normalizeCodeLocInfo(currentExpectedMessage);
expectedMessage = normalizeExpectedMessage(currentExpectedMessage);
expectedWithoutStack = expectedMessageOrArray[1].withoutStack;
} else if (typeof expectedMessageOrArray === 'string') {
// Should be in the form assert(['log']) or assert(['log'], {withoutStack: true})
expectedMessage = normalizeCodeLocInfo(expectedMessageOrArray);
expectedMessage = normalizeExpectedMessage(expectedMessageOrArray);
// withoutStack: inherit from global option - simplify when withoutStack is removed.
if (consoleMethod === 'log') {
expectedWithoutStack = true;
} else {
@@ -381,19 +438,93 @@ export function createLogAssertion(
}
// Main logic to check if log is expected, with the component stack.
if (
typeof expectedMessage === 'string' &&
(normalizedMessage === expectedMessage ||
normalizedMessage.includes(expectedMessage))
) {
// Check for exact match OR if the message matches with a component stack appended
let matchesExpectedMessage = false;
let expectsErrorStack = false;
const hasErrorStack = isLikelyAnErrorStackTrace(message);
if (typeof expectedMessage === 'string') {
if (normalizedMessage === expectedMessage) {
matchesExpectedMessage = true;
} else if (expectedMessage.includes('\n in <stack>')) {
expectsErrorStack = true;
// \n in <stack> is ONLY for JavaScript Error stack traces (e.g., "Error: message\n at fn (file.js:1:2)")
// NOT for React component stacks (e.g., "\n in ComponentName (at **)").
// Validate that the actual message looks like an error stack trace.
if (!hasErrorStack) {
// The actual message doesn't look like an error stack trace.
// This is likely a misuse - someone used \n in <stack> for a component stack.
stackTracePlaceholderMisuses.push({
expected: expectedMessage,
received: normalizedMessage,
});
}
const expectedMessageWithoutStack = expectedMessage.replace(
'\n in <stack>',
'',
);
if (normalizedMessage.startsWith(expectedMessageWithoutStack)) {
// Remove the stack trace
const remainder = normalizedMessage.slice(
expectedMessageWithoutStack.length,
);
// After normalization, both error stacks and component stacks look like
// component stacks (at frames are converted to "in ... (at **)" format).
// So we check isLikelyAComponentStack for matching purposes.
if (isLikelyAComponentStack(remainder)) {
const messageWithoutStack = normalizedMessage.replace(
remainder,
'',
);
if (messageWithoutStack === expectedMessageWithoutStack) {
matchesExpectedMessage = true;
}
} else if (remainder === '') {
// \n in <stack> was expected but there's no stack at all
matchesExpectedMessage = true;
}
} else if (normalizedMessage === expectedMessageWithoutStack) {
// \n in <stack> was expected but actual has no stack at all (exact match without stack)
matchesExpectedMessage = true;
}
} else if (
hasErrorStack &&
!expectedMessage.includes('\n in <stack>') &&
normalizedMessage.startsWith(expectedMessage)
) {
matchesExpectedMessage = true;
}
}
if (matchesExpectedMessage) {
// withoutStack: Check for unexpected/missing component stacks.
// These checks can be simplified when withoutStack is removed.
if (isLikelyAComponentStack(normalizedMessage)) {
if (expectedWithoutStack === true) {
if (expectedWithoutStack === true && !hasErrorStack) {
// Only report unexpected component stack if it's not an error stack
// (error stacks look like component stacks after normalization)
unexpectedIncludingComponentStack.push(normalizedMessage);
}
} else if (expectedWithoutStack !== true) {
} else if (expectedWithoutStack !== true && !expectsErrorStack) {
unexpectedMissingComponentStack.push(normalizedMessage);
}
// Check for unexpected/missing error stacks
if (hasErrorStack && !expectsErrorStack) {
// Error stack is present but \n in <stack> was not in the expected message
unexpectedIncludingErrorStack.push(normalizedMessage);
} else if (
expectsErrorStack &&
!hasErrorStack &&
!isLikelyAComponentStack(normalizedMessage)
) {
// \n in <stack> was expected but the actual message doesn't have any stack at all
// (if it has a component stack, stackTracePlaceholderMisuses already handles it)
unexpectedMissingErrorStack.push(normalizedMessage);
}
// Found expected log, remove it from missing.
missingExpectedLogs.splice(0, 1);
} else {
@@ -422,6 +553,21 @@ export function createLogAssertion(
)}`;
}
// Wrong %s formatting is a failure.
// This is a common mistake when creating new warnings.
if (logsMismatchingFormat.length > 0) {
throwFormattedError(
logsMismatchingFormat
.map(
item =>
`Received ${item.args.length} arguments for a message with ${
item.expectedArgCount
} placeholders:\n ${printReceived(item.format)}`,
)
.join('\n\n'),
);
}
// Any unexpected warnings should be treated as a failure.
if (unexpectedLogs.length > 0) {
throwFormattedError(
@@ -466,18 +612,33 @@ export function createLogAssertion(
);
}
// Wrong %s formatting is a failure.
// This is a common mistake when creating new warnings.
if (logsMismatchingFormat.length > 0) {
// Any logs that include an error stack trace but \n in <stack> wasn't expected.
if (unexpectedIncludingErrorStack.length > 0) {
throwFormattedError(
logsMismatchingFormat
`${unexpectedIncludingErrorStack
.map(
item =>
`Received ${item.args.length} arguments for a message with ${
item.expectedArgCount
} placeholders:\n ${printReceived(item.format)}`,
stack =>
`Unexpected error stack trace for:\n ${printReceived(stack)}`,
)
.join('\n\n'),
.join(
'\n\n',
)}\n\nIf this ${logName()} should include an error stack trace, add \\n in <stack> to your expected message ` +
`(e.g., "Error: message\\n in <stack>").`,
);
}
// Any logs that are missing an error stack trace when \n in <stack> was expected.
if (unexpectedMissingErrorStack.length > 0) {
throwFormattedError(
`${unexpectedMissingErrorStack
.map(
stack =>
`Missing error stack trace for:\n ${printReceived(stack)}`,
)
.join(
'\n\n',
)}\n\nThe expected message uses \\n in <stack> but the actual ${logName()} doesn't include an error stack trace.` +
`\nIf this ${logName()} should not have an error stack trace, remove \\n in <stack> from your expected message.`,
);
}
@@ -496,6 +657,25 @@ export function createLogAssertion(
.join('\n\n'),
);
}
// Using \n in <stack> for component stacks is a misuse.
// \n in <stack> should only be used for JavaScript Error stack traces,
// not for React component stacks.
if (stackTracePlaceholderMisuses.length > 0) {
throwFormattedError(
`${stackTracePlaceholderMisuses
.map(
item =>
`Incorrect use of \\n in <stack> placeholder. The placeholder is for JavaScript Error ` +
`stack traces (messages starting with "Error:"), not for React component stacks.\n\n` +
`Expected: ${printReceived(item.expected)}\n` +
`Received: ${printReceived(item.received)}\n\n` +
`If this ${logName()} has a component stack, include the full component stack in your expected message ` +
`(e.g., "Warning message\\n in ComponentName (at **)").`,
)
.join('\n\n')}`,
);
}
}
};
}

View File

@@ -79,6 +79,18 @@ function normalizeIOInfo(config: DebugInfoConfig, ioInfo) {
status: promise.status,
};
}
} else if ('value' in ioInfo) {
// If value exists in ioInfo but is undefined (e.g., WeakRef was GC'd),
// ensure we still include it in the normalized output for consistency
copy.value = {
value: undefined,
};
} else if (ioInfo.name && ioInfo.name !== 'rsc stream') {
// For non-rsc-stream IO that doesn't have a value field, add a default.
// This handles the case where the server doesn't send the field when WeakRef is GC'd.
copy.value = {
value: undefined,
};
}
return copy;
}

View File

@@ -1515,16 +1515,13 @@ describe('ReactFlight', () => {
},
};
const transport = ReactNoopFlightServer.render(<input value={obj} />);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <input value={{toJSON: ...}}>\n' +
' ^^^^^^^^^^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <input value={{toJSON: ...}}>\n' +
' ^^^^^^^^^^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1546,14 +1543,11 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(
<div>Womp womp: {new MyError('spaghetti')}</div>,
);
assertConsoleErrorDev(
[
'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
' <div>Womp womp: {Error}</div>\n' +
' ^^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
' <div>Womp womp: {Error}</div>\n' +
' ^^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1566,15 +1560,12 @@ describe('ReactFlight', () => {
it('should warn in DEV if a special object is passed to a host component', () => {
const transport = ReactNoopFlightServer.render(<input value={Math} />);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <input value={Math}>\n' +
' ^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <input value={Math}>\n' +
' ^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1590,15 +1581,12 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(
<input value={{[Symbol.iterator]: {}}} />,
);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <input value={{}}>\n' +
' ^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <input value={{}}>\n' +
' ^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1621,16 +1609,13 @@ describe('ReactFlight', () => {
}
const Client = clientReference(ClientImpl);
const transport = ReactNoopFlightServer.render(<Client value={obj} />);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <... value={{toJSON: ...}}>\n' +
' ^^^^^^^^^^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <... value={{toJSON: ...}}>\n' +
' ^^^^^^^^^^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1656,16 +1641,13 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(
<Client>Current date: {obj}</Client>,
);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <>Current date: {{toJSON: ...}}</>\n' +
' ^^^^^^^^^^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <>Current date: {{toJSON: ...}}</>\n' +
' ^^^^^^^^^^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1684,15 +1666,12 @@ describe('ReactFlight', () => {
}
const Client = clientReference(ClientImpl);
const transport = ReactNoopFlightServer.render(<Client value={Math} />);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <... value={Math}>\n' +
' ^^^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <... value={Math}>\n' +
' ^^^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
assertConsoleErrorDev([
@@ -1713,15 +1692,12 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render(
<Client value={{[Symbol.iterator]: {}}} />,
);
assertConsoleErrorDev(
[
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <... value={{}}>\n' +
' ^^^^',
],
{withoutStack: true},
);
assertConsoleErrorDev([
['Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <... value={{}}>\n' +
' ^^^^', {withoutStack: true}],
]);
ReactNoopFlightClient.read(transport);
@@ -1729,7 +1705,8 @@ describe('ReactFlight', () => {
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.\n' +
' <... value={{}}>\n' +
' ^^^^\n',
' ^^^^\n' +
' in (at **)',
]);
});
@@ -3258,7 +3235,7 @@ describe('ReactFlight', () => {
const transport = ReactNoopFlightServer.render({
root: ReactServer.createElement(App),
});
assertConsoleErrorDev(['Error: err']);
assertConsoleErrorDev(['Error: err' + '\n in <stack>']);
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');

View File

@@ -467,9 +467,11 @@ function useSyncExternalStore<T>(
// useSyncExternalStore() composes multiple hooks internally.
// Advance the current hook index the same number of times
// so that subsequent hooks have the right memoized state.
nextHook(); // SyncExternalStore
const hook = nextHook(); // SyncExternalStore
nextHook(); // Effect
const value = getSnapshot();
// Read from hook.memoizedState to get the value that was used during render,
// not the current value from getSnapshot() which may have changed.
const value = hook !== null ? hook.memoizedState : getSnapshot();
hookLog.push({
displayName: null,
primitive: 'SyncExternalStore',

View File

@@ -734,7 +734,11 @@ describe('ReactHooksInspection', () => {
});
const results = normalizeSourceLoc(tree);
expect(results).toHaveLength(1);
expect(results[0]).toMatchInlineSnapshot(`
expect(results[0]).toMatchInlineSnapshot(
{
subHooks: [{value: expect.any(Promise)}],
},
`
{
"debugInfo": null,
"hookSource": {
@@ -759,12 +763,13 @@ describe('ReactHooksInspection', () => {
"isStateEditable": false,
"name": "Use",
"subHooks": [],
"value": Promise {},
"value": Any<Promise>,
},
],
"value": undefined,
}
`);
`,
);
});
describe('useDebugValue', () => {

View File

@@ -17,6 +17,14 @@ const contentScriptsToInject = [
runAt: 'document_end',
world: chrome.scripting.ExecutionWorld.ISOLATED,
},
{
id: '@react-devtools/fallback-eval-context',
js: ['build/fallbackEvalContext.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/hook',
js: ['build/installHook.js'],

View File

@@ -97,6 +97,58 @@ export function handleDevToolsPageMessage(message) {
break;
}
case 'eval-in-inspected-window': {
const {
payload: {tabId, requestId, scriptId, args},
} = message;
chrome.tabs
.sendMessage(tabId, {
source: 'devtools-page-eval',
payload: {
scriptId,
args,
},
})
.then(response => {
if (!response) {
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result: null,
error: 'No response from content script',
},
});
return;
}
const {result, error} = response;
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result,
error,
},
});
})
.catch(error => {
chrome.runtime.sendMessage({
source: 'react-devtools-background',
payload: {
type: 'eval-in-inspected-window-response',
requestId,
result: null,
error: error?.message || String(error),
},
});
});
break;
}
}
}

View File

@@ -0,0 +1,35 @@
/**
* 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.
*
* @flow
*/
import {evalScripts} from '../evalScripts';
window.addEventListener('message', event => {
if (event.data?.source === 'react-devtools-content-script-eval') {
const {scriptId, args, requestId} = event.data.payload;
const response = {result: null, error: null};
try {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
response.result = evalScripts[scriptId].fn.apply(null, args);
} catch (err) {
response.error = err.message;
}
window.postMessage(
{
source: 'react-devtools-content-script-eval-response',
payload: {
requestId,
response,
},
},
'*',
);
}
});

View File

@@ -117,3 +117,49 @@ function connectPort() {
// $FlowFixMe[incompatible-use]
port.onDisconnect.addListener(handleDisconnect);
}
let evalRequestId = 0;
const evalRequestCallbacks = new Map<number, Function>();
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
switch (msg?.source) {
case 'devtools-page-eval': {
const {scriptId, args} = msg.payload;
const requestId = evalRequestId++;
window.postMessage(
{
source: 'react-devtools-content-script-eval',
payload: {
requestId,
scriptId,
args,
},
},
'*',
);
evalRequestCallbacks.set(requestId, sendResponse);
return true; // Indicate we will respond asynchronously
}
}
});
window.addEventListener('message', event => {
if (event.data?.source === 'react-devtools-content-script-eval-response') {
const {requestId, response} = event.data.payload;
const callback = evalRequestCallbacks.get(requestId);
try {
if (!callback)
throw new Error(
`No eval request callback for id "${requestId}" exists.`,
);
callback(response);
} catch (e) {
console.warn(
'React DevTools Content Script eval response error occurred:',
e,
);
} finally {
evalRequestCallbacks.delete(requestId);
}
}
});

View File

@@ -0,0 +1,112 @@
/**
* 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.
*
* @flow
*/
export type EvalScriptIds =
| 'checkIfReactPresentInInspectedWindow'
| 'reload'
| 'setBrowserSelectionFromReact'
| 'setReactSelectionFromBrowser'
| 'viewAttributeSource'
| 'viewElementSource';
/*
.fn for fallback in Content Script context
.code for chrome.devtools.inspectedWindow.eval()
*/
type EvalScriptEntry = {
fn: (...args: any[]) => any,
code: (...args: any[]) => string,
};
/*
Can not access `Developer Tools Console API` (e.g., inspect(), $0) in this context.
So some fallback functions are no-op or throw error.
*/
export const evalScripts: {[key: EvalScriptIds]: EvalScriptEntry} = {
checkIfReactPresentInInspectedWindow: {
fn: () =>
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ &&
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0,
code: () =>
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ &&' +
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
},
reload: {
fn: () => window.location.reload(),
code: () => 'window.location.reload();',
},
setBrowserSelectionFromReact: {
fn: () => {
throw new Error('Not supported in fallback eval context');
},
code: () =>
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
'false',
},
setReactSelectionFromBrowser: {
fn: () => {
throw new Error('Not supported in fallback eval context');
},
code: () =>
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
'false',
},
viewAttributeSource: {
fn: ({rendererID, elementID, path}) => {
return false; // Not supported in fallback eval context
},
code: ({rendererID, elementID, path}) =>
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementAttributeByPath(' +
JSON.stringify(elementID) +
',' +
JSON.stringify(path) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
},
viewElementSource: {
fn: ({rendererID, elementID}) => {
return false; // Not supported in fallback eval context
},
code: ({rendererID, elementID}) =>
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementSourceFunctionById(' +
JSON.stringify(elementID) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
},
};

View File

@@ -1,13 +1,12 @@
/* global chrome */
import {evalInInspectedWindow} from './evalInInspectedWindow';
export function setBrowserSelectionFromReact() {
// This is currently only called on demand when you press "view DOM".
// In the future, if Chrome adds an inspect() that doesn't switch tabs,
// we could make this happen automatically when you select another component.
chrome.devtools.inspectedWindow.eval(
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
'false',
evalInInspectedWindow(
'setBrowserSelectionFromReact',
[],
(didSelectionChange, evalError) => {
if (evalError) {
console.error(evalError);
@@ -19,10 +18,9 @@ export function setBrowserSelectionFromReact() {
export function setReactSelectionFromBrowser(bridge) {
// When the user chooses a different node in the browser Elements tab,
// copy it over to the hook object so that we can sync the selection.
chrome.devtools.inspectedWindow.eval(
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
'(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
'false',
evalInInspectedWindow(
'setReactSelectionFromBrowser',
[],
(didSelectionChange, evalError) => {
if (evalError) {
console.error(evalError);
@@ -34,7 +32,7 @@ export function setReactSelectionFromBrowser(bridge) {
return;
}
// Remember to sync the selection next time we show Components tab.
// Remember to sync the selection next time we show inspected element
bridge.send('syncSelectionFromBuiltinElementsPanel');
}
},

View File

@@ -0,0 +1,116 @@
/**
* 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.
*
* @flow
*/
import type {EvalScriptIds} from '../evalScripts';
import {evalScripts} from '../evalScripts';
type ExceptionInfo = {
code: ?string,
description: ?string,
isError: boolean,
isException: boolean,
value: any,
};
const EVAL_TIMEOUT = 1000 * 10;
let evalRequestId = 0;
const evalRequestCallbacks = new Map<
number,
(value: {result: any, error: any}) => void,
>();
function fallbackEvalInInspectedWindow(
scriptId: EvalScriptIds,
args: any[],
callback: (value: any, exceptionInfo: ?ExceptionInfo) => void,
) {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
const code = evalScripts[scriptId].code.apply(null, args);
const tabId = chrome.devtools.inspectedWindow.tabId;
const requestId = evalRequestId++;
chrome.runtime.sendMessage({
source: 'devtools-page',
payload: {
type: 'eval-in-inspected-window',
tabId,
requestId,
scriptId,
args,
},
});
const timeout = setTimeout(() => {
evalRequestCallbacks.delete(requestId);
if (callback) {
callback(null, {
code,
description:
'Timed out while waiting for eval response from the inspected window.',
isError: true,
isException: false,
value: undefined,
});
}
}, EVAL_TIMEOUT);
evalRequestCallbacks.set(requestId, ({result, error}) => {
clearTimeout(timeout);
evalRequestCallbacks.delete(requestId);
if (callback) {
if (error) {
callback(null, {
code,
description: undefined,
isError: false,
isException: true,
value: error,
});
return;
}
callback(result, null);
}
});
}
export function evalInInspectedWindow(
scriptId: EvalScriptIds,
args: any[],
callback: (value: any, exceptionInfo: ?ExceptionInfo) => void,
) {
if (!evalScripts[scriptId]) {
throw new Error(`No eval script with id "${scriptId}" exists.`);
}
const code = evalScripts[scriptId].code.apply(null, args);
chrome.devtools.inspectedWindow.eval(code, (result, exceptionInfo) => {
if (!exceptionInfo) {
callback(result, exceptionInfo);
return;
}
// If an exception (e.g. CSP Blocked) occurred,
// fallback to the content script eval context
fallbackEvalInInspectedWindow(scriptId, args, callback);
});
}
chrome.runtime.onMessage.addListener(({payload, source}) => {
if (source === 'react-devtools-background') {
switch (payload?.type) {
case 'eval-in-inspected-window-response': {
const {requestId, result, error} = payload;
const callback = evalRequestCallbacks.get(requestId);
if (callback) {
callback({result, error});
}
break;
}
}
}
});

View File

@@ -1,6 +1,14 @@
/* global chrome */
/** @flow */
import type {RootType} from 'react-dom/src/client/ReactDOMRoot';
import type {FrontendBridge, Message} from 'react-devtools-shared/src/bridge';
import type {
TabID,
ViewElementSource,
} from 'react-devtools-shared/src/devtools/views/DevTools';
import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane';
import type {Element} from 'react-devtools-shared/src/frontend/types';
import {createElement} from 'react';
import {flushSync} from 'react-dom';
@@ -32,6 +40,7 @@ import {
} from './elementSelection';
import {viewAttributeSource} from './sourceSelection';
import {evalInInspectedWindow} from './evalInInspectedWindow';
import {startReactPolling} from './reactPolling';
import {cloneStyleTags} from './cloneStyleTags';
import fetchFileWithCaching from './fetchFileWithCaching';
@@ -50,9 +59,9 @@ const hookNamesModuleLoaderFunction = () => resolvedParseHookNames;
function createBridge() {
bridge = new Bridge({
listen(fn) {
const bridgeListener = message => fn(message);
const bridgeListener = (message: Message) => fn(message);
// Store the reference so that we unsubscribe from the same object.
const portOnMessage = port.onMessage;
const portOnMessage = ((port: any): ExtensionPort).onMessage;
portOnMessage.addListener(bridgeListener);
lastSubscribedBridgeListener = bridgeListener;
@@ -70,7 +79,7 @@ function createBridge() {
bridge.addListener('reloadAppForProfiling', () => {
localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
chrome.devtools.inspectedWindow.eval('window.location.reload();');
evalInInspectedWindow('reload', [], () => {});
});
bridge.addListener(
@@ -175,14 +184,20 @@ function createBridgeAndStore() {
// Otherwise, the Store may miss important initial tree op codes.
injectBackendManager(chrome.devtools.inspectedWindow.tabId);
const viewAttributeSourceFunction = (id, path) => {
const viewAttributeSourceFunction = (
id: Element['id'],
path: Array<string | number>,
) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
viewAttributeSource(rendererID, id, path);
}
};
const viewElementSourceFunction = (source, symbolicatedSource) => {
const viewElementSourceFunction: ViewElementSource = (
source,
symbolicatedSource,
) => {
const [, sourceURL, line, column] = symbolicatedSource
? symbolicatedSource
: source;
@@ -197,7 +212,7 @@ function createBridgeAndStore() {
root = createRoot(document.createElement('div'));
render = (overrideTab = mostRecentOverrideTab) => {
render = (overrideTab: TabID | null = mostRecentOverrideTab) => {
mostRecentOverrideTab = overrideTab;
root.render(
@@ -205,6 +220,7 @@ function createBridgeAndStore() {
bridge,
browserTheme: getBrowserTheme(),
componentsPortalContainer,
inspectedElementPortalContainer,
profilerPortalContainer,
editorPortalContainer,
currentSelectedSource,
@@ -225,7 +241,9 @@ function createBridgeAndStore() {
};
}
function ensureInitialHTMLIsCleared(container) {
function ensureInitialHTMLIsCleared(
container: HTMLElement & {_hasInitialHTMLBeenCleared?: boolean},
) {
if (container._hasInitialHTMLBeenCleared) {
return;
}
@@ -277,6 +295,52 @@ function createComponentsPanel() {
);
}
function createElementsInspectPanel() {
if (inspectedElementPortalContainer) {
// Panel is created and user opened it at least once
ensureInitialHTMLIsCleared(inspectedElementPortalContainer);
render();
return;
}
if (inspectedElementPane) {
// Panel is created, but wasn't opened yet, so no document is present for it
return;
}
const elementsPanel = chrome.devtools.panels.elements;
if (__IS_FIREFOX__ || !elementsPanel || !elementsPanel.createSidebarPane) {
// Firefox will not pass the window to the onShown listener despite setPage
// being called.
// See https://bugzilla.mozilla.org/show_bug.cgi?id=2010549
// May not be supported in some browsers.
// See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/devtools/panels/ElementsPanel/createSidebarPane#browser_compatibility
return;
}
elementsPanel.createSidebarPane('React Element ⚛', createdPane => {
inspectedElementPane = createdPane;
createdPane.setPage('panel.html');
createdPane.setHeight('75px');
createdPane.onShown.addListener(portal => {
inspectedElementPortalContainer = portal.container;
if (inspectedElementPortalContainer != null && render) {
ensureInitialHTMLIsCleared(inspectedElementPortalContainer);
bridge.send('syncSelectionFromBuiltinElementsPanel');
render();
portal.injectStyles(cloneStyleTags);
logEvent({event_name: 'selected-inspected-element-pane'});
}
});
});
}
function createProfilerPanel() {
if (profilerPortalContainer) {
// Panel is created and user opened it at least once
@@ -350,13 +414,6 @@ function createSourcesEditorPanel() {
logEvent({event_name: 'selected-editor-pane'});
}
});
createdPane.onShown.addListener(() => {
bridge.emit('extensionEditorPaneShown');
});
createdPane.onHidden.addListener(() => {
bridge.emit('extensionEditorPaneHidden');
});
});
}
@@ -432,10 +489,10 @@ function performInTabNavigationCleanup() {
// Do not clean mostRecentOverrideTab on purpose, so we remember last opened
// React DevTools tab, when user does in-tab navigation
store = null;
bridge = null;
render = null;
root = null;
store = (null: $FlowFixMe);
bridge = (null: $FlowFixMe);
render = (null: $FlowFixMe);
root = (null: $FlowFixMe);
}
function performFullCleanup() {
@@ -457,18 +514,18 @@ function performFullCleanup() {
componentsPortalContainer = null;
profilerPortalContainer = null;
suspensePortalContainer = null;
root = null;
root = (null: $FlowFixMe);
mostRecentOverrideTab = null;
store = null;
bridge = null;
render = null;
store = (null: $FlowFixMe);
bridge = (null: $FlowFixMe);
render = (null: $FlowFixMe);
port?.disconnect();
port = null;
port = (null: $FlowFixMe);
}
function connectExtensionPort() {
function connectExtensionPort(): void {
if (port) {
throw new Error('DevTools port was already connected');
}
@@ -492,7 +549,7 @@ function connectExtensionPort() {
// so, when we call `port.disconnect()` from this script,
// this should not trigger this callback and port reconnection
port.onDisconnect.addListener(() => {
port = null;
port = (null: $FlowFixMe);
connectExtensionPort();
});
}
@@ -507,6 +564,7 @@ function mountReactDevTools() {
createComponentsPanel();
createProfilerPanel();
createSourcesEditorPanel();
createElementsInspectPanel();
// Suspense Tab is created via the hook
// TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable
}
@@ -545,9 +603,9 @@ function mountReactDevToolsWhenReactHasLoaded() {
);
}
let bridge = null;
let bridge: FrontendBridge = (null: $FlowFixMe);
let lastSubscribedBridgeListener = null;
let store = null;
let store: Store = (null: $FlowFixMe);
let profilingData = null;
@@ -555,18 +613,35 @@ let componentsPanel = null;
let profilerPanel = null;
let suspensePanel = null;
let editorPane = null;
let inspectedElementPane = null;
let componentsPortalContainer = null;
let profilerPortalContainer = null;
let suspensePortalContainer = null;
let editorPortalContainer = null;
let inspectedElementPortalContainer = null;
let mostRecentOverrideTab = null;
let render = null;
let root = null;
let mostRecentOverrideTab: null | TabID = null;
let render: (overrideTab?: TabID) => void = (null: $FlowFixMe);
let root: RootType = (null: $FlowFixMe);
let currentSelectedSource: null | SourceSelection = null;
let port = null;
type ExtensionEvent = {
addListener(callback: (message: Message, port: ExtensionPort) => void): void,
removeListener(
callback: (message: Message, port: ExtensionPort) => void,
): void,
};
/** https://developer.chrome.com/docs/extensions/reference/api/runtime#type-Port */
type ExtensionPort = {
onDisconnect: ExtensionEvent,
onMessage: ExtensionEvent,
postMessage(message: mixed, transferable?: Array<mixed>): void,
disconnect(): void,
};
let port: ExtensionPort = (null: $FlowFixMe);
// In case when multiple navigation events emitted in a short period of time
// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results
@@ -599,7 +674,7 @@ connectExtensionPort();
mountReactDevToolsWhenReactHasLoaded();
function onThemeChanged(themeName) {
function onThemeChanged() {
// Rerender with the new theme
render();
}

View File

@@ -1,4 +1,4 @@
/* global chrome */
import {evalInInspectedWindow} from './evalInInspectedWindow';
class CouldNotFindReactOnThePageError extends Error {
constructor() {
@@ -26,8 +26,9 @@ export function startReactPolling(
// This function will call onSuccess only if React was found and polling is not aborted, onError will be called for every other case
function checkIfReactPresentInInspectedWindow(onSuccess, onError) {
chrome.devtools.inspectedWindow.eval(
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
evalInInspectedWindow(
'checkIfReactPresentInInspectedWindow',
[],
(pageHasReact, exceptionInfo) => {
if (status === 'aborted') {
onError(

View File

@@ -1,27 +1,9 @@
/* global chrome */
import {evalInInspectedWindow} from './evalInInspectedWindow';
export function viewAttributeSource(rendererID, elementID, path) {
chrome.devtools.inspectedWindow.eval(
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementAttributeByPath(' +
JSON.stringify(elementID) +
',' +
JSON.stringify(path) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
evalInInspectedWindow(
'viewAttributeSource',
[{rendererID, elementID, path}],
(didInspect, evalError) => {
if (evalError) {
console.error(evalError);
@@ -31,25 +13,9 @@ export function viewAttributeSource(rendererID, elementID, path) {
}
export function viewElementSource(rendererID, elementID) {
chrome.devtools.inspectedWindow.eval(
'{' + // The outer block is important because it means we can declare local variables.
'const renderer = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces.get(' +
JSON.stringify(rendererID) +
');' +
'if (renderer) {' +
' const value = renderer.getElementSourceFunctionById(' +
JSON.stringify(elementID) +
');' +
' if (value) {' +
' inspect(value);' +
' true;' +
' } else {' +
' false;' +
' }' +
'} else {' +
' false;' +
'}' +
'}',
evalInInspectedWindow(
'viewElementSource',
[{rendererID, elementID}],
(didInspect, evalError) => {
if (evalError) {
console.error(evalError);

View File

@@ -69,6 +69,7 @@ module.exports = {
backend: './src/backend.js',
background: './src/background/index.js',
backendManager: './src/contentScripts/backendManager.js',
fallbackEvalContext: './src/contentScripts/fallbackEvalContext.js',
fileFetcher: './src/contentScripts/fileFetcher.js',
main: './src/main/index.js',
panel: './src/panel.js',

View File

@@ -6,7 +6,10 @@ import {initBackend} from 'react-devtools-shared/src/backend';
import {installHook} from 'react-devtools-shared/src/hook';
import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {
BackendBridge,
SavedPreferencesParams,
} from 'react-devtools-shared/src/bridge';
import type {Wall} from 'react-devtools-shared/src/frontend/types';
import {
getIfReloadedAndProfiling,
@@ -16,31 +19,14 @@ import {
} from 'react-devtools-shared/src/utils';
function startActivation(contentWindow: any, bridge: BackendBridge) {
const onSavedPreferences = (data: $FlowFixMe) => {
const onSavedPreferences = (data: SavedPreferencesParams) => {
// This is the only message we're listening for,
// so it's safe to cleanup after we've received it.
bridge.removeListener('savedPreferences', onSavedPreferences);
const {
appendComponentStack,
breakOnConsoleErrors,
componentFilters,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
disableSecondConsoleLogDimmingInStrictMode,
} = data;
const {componentFilters} = data;
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ =
appendComponentStack;
contentWindow.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ =
breakOnConsoleErrors;
contentWindow.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
contentWindow.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ =
showInlineWarningsAndErrors;
contentWindow.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
hideConsoleLogsInStrictMode;
contentWindow.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
disableSecondConsoleLogDimmingInStrictMode;
// TRICKY
// The backend entry point may be required in the context of an iframe or the parent window.
@@ -49,15 +35,7 @@ function startActivation(contentWindow: any, bridge: BackendBridge) {
// Technically we don't need to store them on the contentWindow in this case,
// but it doesn't really hurt anything to store them there too.
if (contentWindow !== window) {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ =
showInlineWarningsAndErrors;
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
hideConsoleLogsInStrictMode;
window.__REACT_DEVTOOLS_DISABLE_SECOND_CONSOLE_LOG_DIMMING_IN_STRICT_MODE__ =
disableSecondConsoleLogDimmingInStrictMode;
}
finishActivation(contentWindow, bridge);

View File

@@ -63,7 +63,11 @@ export type LoggerEvent =
+value: any,
...
},
};
}
| {
+event_name: 'selected-editor-pane',
}
| {+event_name: 'selected-inspected-element-pane'};
export type LogFunction = LoggerEvent => void | Promise<void>;

View File

@@ -90,7 +90,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
@@ -110,7 +110,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
@@ -125,7 +125,7 @@ describe('Profiler change descriptions', () => {
{
"context": false,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,
@@ -140,7 +140,7 @@ describe('Profiler change descriptions', () => {
{
"context": true,
"didHooksChange": false,
"hooks": null,
"hooks": [],
"isFirstMount": false,
"props": [],
"state": null,

View File

@@ -378,6 +378,12 @@ describe('ProfilingCache', () => {
),
);
// Save references to the real dispatch/setState functions.
// inspectHooks() re-runs the component with a mock dispatcher,
// which would overwrite these variables with mock functions that do nothing.
const realDispatch = dispatch;
const realSetState = setState;
// Second render has no changed hooks, only changed props.
utils.act(() =>
render(
@@ -388,10 +394,10 @@ describe('ProfilingCache', () => {
);
// Third render has a changed reducer hook.
utils.act(() => dispatch({type: 'invert'}));
utils.act(() => realDispatch({type: 'invert'}));
// Fourth render has a changed state hook.
utils.act(() => setState('def'));
utils.act(() => realSetState('def'));
// Fifth render has a changed context value, but no changed hook.
utils.act(() =>
@@ -521,6 +527,238 @@ describe('ProfilingCache', () => {
}
});
// @reactVersion >= 19.0
it('should detect what hooks changed in a render with custom and composite hooks', () => {
let snapshot = 0;
let syncExternalStoreCallback;
function subscribe(callback) {
syncExternalStoreCallback = callback;
return () => {};
}
function getSnapshot() {
return snapshot;
}
// Custom hook wrapping multiple primitive hooks
function useCustomHook() {
const [value, setValue] = React.useState('custom');
React.useEffect(() => {}, [value]);
return [value, setValue];
}
let setState = null;
let startTransition = null;
let actionStateDispatch = null;
let setCustomValue = null;
let setFinalState = null;
const Component = () => {
// Hook 0: useState
const [state, _setState] = React.useState('initial');
setState = _setState;
// Hook 1: useSyncExternalStore (composite hook - internally uses multiple hooks)
const storeValue = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot,
);
// Hook 2: useTransition (composite hook - internally uses multiple hooks)
const [isPending, _startTransition] = React.useTransition();
startTransition = _startTransition;
// Hook 3: useActionState (composite hook - internally uses multiple hooks)
const [actionState, _actionStateDispatch] = React.useActionState(
(_prev, action) => action,
'action-initial',
);
actionStateDispatch = _actionStateDispatch;
// Hook 4: useState inside custom hook (flattened)
// Hook 5: useEffect inside custom hook (not stateful, won't show in changes)
const [customValue, _setCustomValue] = useCustomHook();
setCustomValue = _setCustomValue;
// Hook 6: direct useState at the end
const [finalState, _setFinalState] = React.useState('final');
setFinalState = _setFinalState;
return `${state}-${storeValue}-${isPending}-${actionState}-${customValue}-${finalState}`;
};
utils.act(() => store.profilerStore.startProfiling());
utils.act(() => render(<Component />));
// Save references before inspectHooks() overwrites them
const realSetState = setState;
const realStartTransition = startTransition;
const realActionStateDispatch = actionStateDispatch;
const realSetCustomValue = setCustomValue;
const realSetFinalState = setFinalState;
// 2nd render: change useState (hook 0)
utils.act(() => realSetState('changed'));
// 3rd render: change useSyncExternalStore (hook 1)
utils.act(() => {
snapshot = 1;
syncExternalStoreCallback();
});
// 4th render: trigger useTransition (hook 2)
// Note: useTransition triggers two renders - one when isPending becomes true,
// and another when isPending becomes false after the transition completes
utils.act(() => {
realStartTransition(() => {});
});
// 6th render: change useActionState (hook 3)
utils.act(() => realActionStateDispatch('action-changed'));
// 7th render: change custom hook's useState (hook 4)
utils.act(() => realSetCustomValue('custom-changed'));
// 8th render: change final useState (hook 6)
utils.act(() => realSetFinalState('final-changed'));
utils.act(() => store.profilerStore.stopProfiling());
const rootID = store.roots[0];
const changeDescriptions = store.profilerStore
.getDataForRoot(rootID)
.commitData.map(commitData => commitData.changeDescriptions);
expect(changeDescriptions).toHaveLength(8);
// 1st render: Initial mount
expect(changeDescriptions[0]).toMatchInlineSnapshot(`
Map {
2 => {
"context": null,
"didHooksChange": false,
"isFirstMount": true,
"props": null,
"state": null,
},
}
`);
// 2nd render: Changed hook 0 (useState)
expect(changeDescriptions[1]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
0,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 3rd render: Changed hook 1 (useSyncExternalStore)
expect(changeDescriptions[2]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
1,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 4th render: Changed hook 2 (useTransition - isPending becomes true)
expect(changeDescriptions[3]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
2,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 5th render: Changed hook 2 (useTransition - isPending becomes false)
expect(changeDescriptions[4]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
2,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 6th render: Changed hook 3 (useActionState)
expect(changeDescriptions[5]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
3,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 7th render: Changed hook 4 (useState inside useCustomHook)
expect(changeDescriptions[6]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
4,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
// 8th render: Changed hook 6 (final useState)
expect(changeDescriptions[7]).toMatchInlineSnapshot(`
Map {
2 => {
"context": false,
"didHooksChange": true,
"hooks": [
6,
],
"isFirstMount": false,
"props": [],
"state": null,
},
}
`);
});
// @reactVersion >= 19.0
it('should detect context changes or lack of changes with conditional use()', () => {
const ContextA = React.createContext(0);
@@ -553,6 +791,11 @@ describe('ProfilingCache', () => {
),
);
// Save reference to the real setState function before profiling starts.
// inspectHooks() re-runs the component with a mock dispatcher,
// which would overwrite setState with a mock function that does nothing.
const realSetState = setState;
utils.act(() => store.profilerStore.startProfiling());
// First render changes Context.
@@ -567,7 +810,7 @@ describe('ProfilingCache', () => {
);
// Second render has no changed Context, only changed state.
utils.act(() => setState('def'));
utils.act(() => realSetState('def'));
utils.act(() => store.profilerStore.stopProfiling());

View File

@@ -240,9 +240,6 @@ beforeEach(() => {
setSavedComponentFilters(getDefaultComponentFilters());
global.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = getDefaultComponentFilters();
// Also initialize inline warnings so that we can test them.
global.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = true;
installHook(global, {
appendComponentStack: true,
breakOnConsoleErrors: false,

View File

@@ -7,7 +7,12 @@
* @flow
*/
import semver from 'semver';
import {getVersionedRenderImplementation} from './utils';
import {ReactVersion} from '../../../../ReactVersions';
const ReactVersionTestingAgainst = process.env.REACT_VERSION || ReactVersion;
describe('Store', () => {
let React;
@@ -3143,8 +3148,9 @@ describe('Store', () => {
expect(store).toMatchInlineSnapshot(``);
});
// @reactVersion >= 17.0
it('should track suspended by in filtered fallback', async () => {
// Can't suspend the root in React 17.
// @reactVersion >= 18.0
it('should track suspended-by in filtered fallback suspending the root', async () => {
function IgnoreMe({promise}) {
return readValue(promise);
}
@@ -3196,6 +3202,91 @@ describe('Store', () => {
`);
});
// @reactVersion >= 17.0
it('should track suspended-by in filtered fallback', async () => {
function IgnoreMe({promise}) {
return readValue(promise);
}
function Component({promise}) {
if (promise) {
return readValue(promise);
}
return null;
}
await actAsync(
async () =>
(store.componentFilters = [createDisplayNameFilter('^IgnoreMe', true)]),
);
let resolveFallback;
const fallbackPromise = new Promise(resolve => {
resolveFallback = resolve;
});
let resolveContent;
const contentPromise = new Promise(resolve => {
resolveContent = resolve;
});
await actAsync(() =>
render(
<React.Suspense
fallback={<Component key="root-fallback" />}
name="root">
<React.Suspense
name="main"
fallback={<IgnoreMe promise={fallbackPromise} />}>
<Component promise={contentPromise} />
</React.Suspense>
</React.Suspense>,
),
);
if (semver.lt(ReactVersionTestingAgainst, '18.0.0')) {
// React 17 commits partial trees hidden which causes the "main"
// Suspense boundary to be included.
// React 18 and upwards excluded partial tree entirely.
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="root">
<Component key="root-fallback">
[suspense-root] rects={null}
<Suspense name="root" rects={null}>
<Suspense name="main" rects={null}>
`);
} else {
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="root">
<Component key="root-fallback">
[suspense-root] rects={null}
<Suspense name="root" rects={null}>
`);
}
await actAsync(() => resolveFallback('loading'));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="root">
<Suspense name="main">
[suspense-root] rects={null}
<Suspense name="root" rects={null}>
<Suspense name="main" rects={null}>
`);
await actAsync(() => resolveContent('content'));
expect(store).toMatchInlineSnapshot(`
[root]
▾ <Suspense name="root">
▾ <Suspense name="main">
<Component>
[suspense-root] rects={null}
<Suspense name="root" rects={null}>
<Suspense name="main" rects={null}>
`);
});
// @reactVersion >= 19
it('should keep suspended boundaries in the Suspense tree but not hidden Activity', async () => {
const Activity = React.Activity || React.unstable_Activity;

View File

@@ -938,11 +938,19 @@ export default class Agent extends EventEmitter<{
}
};
selectNode(target: HostInstance): void {
const match = this.getIDForHostInstance(target);
if (match !== null) {
this._bridge.send('selectElement', match.id);
}
selectNode(target: HostInstance | null): void {
const match = target !== null ? this.getIDForHostInstance(target) : null;
this._bridge.send(
'selectElement',
match !== null
? match.id
: // If you click outside a React root in the Elements panel, we want to give
// feedback that no selection is possible so we clear the selection.
// Otherwise clicking outside a React root is indistinguishable from clicking
// a different host node that leads to the same selected React element
// due to Component filters
null,
);
}
registerRendererInterface(
@@ -988,10 +996,7 @@ export default class Agent extends EventEmitter<{
syncSelectionFromBuiltinElementsPanel: () => void = () => {
const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0;
if (target == null) {
return;
}
this.selectNode(target);
this.selectNode(target == null ? null : target);
};
shutdown: () => void = () => {

View File

@@ -18,7 +18,7 @@ import type {
Wakeable,
} from 'shared/ReactTypes';
import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
import {
ComponentFilterDisplayName,
@@ -127,7 +127,6 @@ import {enableStyleXFeatures} from 'react-devtools-feature-flags';
import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs';
import is from 'shared/objectIs';
import hasOwnProperty from 'shared/hasOwnProperty';
import {getIODescription} from 'shared/ReactIODescription';
@@ -1976,10 +1975,9 @@ export function attach(
state: null,
};
} else {
const indices = getChangedHooksIndices(
prevFiber.memoizedState,
nextFiber.memoizedState,
);
const prevHooks = inspectHooks(prevFiber);
const nextHooks = inspectHooks(nextFiber);
const indices = getChangedHooksIndices(prevHooks, nextHooks);
const data: ChangeDescription = {
context: getContextChanged(prevFiber, nextFiber),
didHooksChange: indices !== null && indices.length > 0,
@@ -2028,74 +2026,53 @@ export function attach(
return false;
}
function isUseSyncExternalStoreHook(hookObject: any): boolean {
const queue = hookObject.queue;
if (!queue) {
return false;
}
function didStatefulHookChange(prev: HooksNode, next: HooksNode): boolean {
// Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState()
const isStatefulHook =
prev.isStateEditable === true ||
prev.name === 'SyncExternalStore' ||
prev.name === 'Transition' ||
prev.name === 'ActionState' ||
prev.name === 'FormState';
const boundHasOwnProperty = hasOwnProperty.bind(queue);
return (
boundHasOwnProperty('value') &&
boundHasOwnProperty('getSnapshot') &&
typeof queue.getSnapshot === 'function'
);
}
function isHookThatCanScheduleUpdate(hookObject: any) {
const queue = hookObject.queue;
if (!queue) {
return false;
}
const boundHasOwnProperty = hasOwnProperty.bind(queue);
// Detect the shape of useState() / useReducer() / useTransition()
// using the attributes that are unique to these hooks
// but also stable (e.g. not tied to current Lanes implementation)
// We don't check for dispatch property, because useTransition doesn't have it
if (boundHasOwnProperty('pending')) {
return true;
}
return isUseSyncExternalStoreHook(hookObject);
}
function didStatefulHookChange(prev: any, next: any): boolean {
const prevMemoizedState = prev.memoizedState;
const nextMemoizedState = next.memoizedState;
if (isHookThatCanScheduleUpdate(prev)) {
return prevMemoizedState !== nextMemoizedState;
// Compare the values to see if they changed
if (isStatefulHook) {
return prev.value !== next.value;
}
return false;
}
function getChangedHooksIndices(prev: any, next: any): null | Array<number> {
if (prev == null || next == null) {
function getChangedHooksIndices(
prevHooks: HooksTree | null,
nextHooks: HooksTree | null,
): null | Array<number> {
if (prevHooks == null || nextHooks == null) {
return null;
}
const indices = [];
const indices: Array<number> = [];
let index = 0;
while (next !== null) {
if (didStatefulHookChange(prev, next)) {
indices.push(index);
}
function traverse(prevTree: HooksTree, nextTree: HooksTree): void {
for (let i = 0; i < prevTree.length; i++) {
const prevHook = prevTree[i];
const nextHook = nextTree[i];
// useSyncExternalStore creates 2 internal hooks, but we only count it as 1 user-facing hook
if (isUseSyncExternalStoreHook(next)) {
next = next.next;
prev = prev.next;
}
if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) {
traverse(prevHook.subHooks, nextHook.subHooks);
continue;
}
next = next.next;
prev = prev.next;
index++;
if (didStatefulHookChange(prevHook, nextHook)) {
indices.push(index);
}
index++;
}
}
traverse(prevHooks, nextHooks);
return indices;
}

View File

@@ -74,7 +74,7 @@ export const currentBridgeProtocol: BridgeProtocol =
type ElementAndRendererID = {id: number, rendererID: RendererID};
type Message = {
export type Message = {
event: string,
payload: any,
};
@@ -191,7 +191,7 @@ type NativeStyleEditor_SetValueParams = {
value: string,
};
type SavedPreferencesParams = {
export type SavedPreferencesParams = {
componentFilters: Array<ComponentFilter>,
};
@@ -214,7 +214,7 @@ export type BackendEvents = {
profilingStatus: [boolean],
reloadAppForProfiling: [],
saveToClipboard: [string],
selectElement: [number],
selectElement: [number | null],
shutdown: [],
stopInspectingHost: [boolean],
scrollTo: [{left: number, top: number, right: number, bottom: number}],
@@ -239,7 +239,7 @@ export type BackendEvents = {
type StartProfilingParams = ProfilingSettings;
type ReloadAndProfilingParams = ProfilingSettings;
type FrontendEvents = {
export type FrontendEvents = {
clearErrorsAndWarnings: [{rendererID: RendererID}],
clearErrorsForElementID: [ElementAndRendererID],
clearHostInstanceHighlight: [],

View File

@@ -147,7 +147,7 @@ export default class Store extends EventEmitter<{
enableSuspenseTab: [],
error: [Error],
hookSettings: [$ReadOnly<DevToolsHookSettings>],
hostInstanceSelected: [Element['id']],
hostInstanceSelected: [Element['id'] | null],
settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
mutated: [
[
@@ -2381,8 +2381,15 @@ export default class Store extends EventEmitter<{
this._bridge.send('getHookSettings'); // Warm up cached hook settings
};
onHostInstanceSelected: (elementId: number) => void = elementId => {
if (this._lastSelectedHostInstanceElementId === elementId) {
onHostInstanceSelected: (elementId: number | null) => void = elementId => {
if (
this._lastSelectedHostInstanceElementId === elementId &&
// Force clear selection e.g. when we inspect an element in the Components panel
// and then switch to the browser's Elements panel.
// We wouldn't want to stay on the inspected element if we're inspecting
// an element not owned by React when switching to the browser's Elements panel.
elementId !== null
) {
return;
}

View File

@@ -22,6 +22,7 @@
flex: 1 1 35%;
overflow-x: hidden;
overflow-y: auto;
border-left: 1px solid var(--color-border);
}
.ResizeBarWrapper {
@@ -55,6 +56,7 @@
.InspectedElementWrapper {
flex: 1 1 50%;
border-left: none;
}
.ResizeBar {

View File

@@ -159,7 +159,11 @@ function Components(_: {}) {
<div className={styles.InspectedElementWrapper}>
<NativeStyleContextController>
<InspectedElementErrorBoundary>
<InspectedElement />
<InspectedElement
fallbackEmpty={
'No React element selected. Select an element in the tree to inspect.'
}
/>
</InspectedElementErrorBoundary>
</NativeStyleContextController>
</div>

View File

@@ -3,7 +3,6 @@
flex-direction: column;
height: 100%;
width: 100%;
border-left: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
}
@@ -69,7 +68,11 @@
padding: 0.25rem;
color: var(--color-dimmer);
font-style: italic;
border-left: 1px solid var(--color-border);
}
.NoInspectionFallback {
padding: 0.25rem;
font-style: italic;
}
.StrictModeNonCompliant {
@@ -77,3 +80,11 @@
padding: 0.25rem;
color: var(--color-console-error-icon);
}
.VRule {
height: 20px;
width: 1px;
flex: 0 0 1px;
margin: 0 0.5rem;
background-color: var(--color-border);
}

View File

@@ -34,13 +34,20 @@ import useEditorURL from '../useEditorURL';
import styles from './InspectedElement.css';
import Tooltip from './reach-ui/tooltip';
export type Props = {};
export type Props = {
actionButtons?: React.Node,
/** fallback to show when no element is inspected */
fallbackEmpty: React.Node,
};
// TODO Make edits and deletes also use transition API!
const noSourcePromise = Promise.resolve(null);
export default function InspectedElementWrapper(_: Props): React.Node {
export default function InspectedElementWrapper({
actionButtons,
fallbackEmpty,
}: Props): React.Node {
const {inspectedElementID} = useContext(TreeStateContext);
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
@@ -189,6 +196,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
return (
<div className={styles.InspectedElement}>
<div className={styles.TitleRow} />
<div className={styles.NoInspectionFallback}>{fallbackEmpty}</div>
</div>
);
}
@@ -305,6 +313,13 @@ export default function InspectedElementWrapper(_: Props): React.Node {
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}
{actionButtons && (
<>
<div className={styles.VRule} />
{actionButtons}
</>
)}
</div>
{inspectedElement === null && (

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