diff --git a/compiler/apps/playground/components/AccordionWindow.tsx b/compiler/apps/playground/components/AccordionWindow.tsx index 197f543b4ab4a..db12e76670a91 100644 --- a/compiler/apps/playground/components/AccordionWindow.tsx +++ b/compiler/apps/playground/components/AccordionWindow.tsx @@ -6,7 +6,14 @@ */ import {Resizable} from 're-resizable'; -import React, {useCallback} from 'react'; +import React, { + useCallback, + useId, + unstable_ViewTransition as ViewTransition, + unstable_addTransitionType as addTransitionType, + startTransition, +} from 'react'; +import {EXPAND_ACCORDION_TRANSITION} from '../lib/transitionTypes'; type TabsRecord = Map; @@ -50,17 +57,23 @@ function AccordionWindowItem({ setTabsOpen: (newTab: Set) => void; hasChanged: boolean; }): React.ReactElement { + const id = useId(); const isShow = tabsOpen.has(name); - const toggleTabs = useCallback(() => { - const nextState = new Set(tabsOpen); - if (nextState.has(name)) { - nextState.delete(name); - } else { - nextState.add(name); - } - setTabsOpen(nextState); - }, [tabsOpen, name, setTabsOpen]); + const transitionName = `accordion-window-item-${id}`; + + const toggleTabs = () => { + startTransition(() => { + addTransitionType(EXPAND_ACCORDION_TRANSITION); + const nextState = new Set(tabsOpen); + if (nextState.has(name)) { + nextState.delete(name); + } else { + nextState.add(name); + } + setTabsOpen(nextState); + }); + }; // Replace spaces with non-breaking spaces const displayName = name.replace(/ /g, '\u00A0'); @@ -68,31 +81,45 @@ function AccordionWindowItem({ return (
{isShow ? ( - -

- - {displayName} -

- {tabs.get(name) ??
No output for {name}
} -
+ + +

+ - {displayName} +

+ {tabs.get(name) ??
No output for {name}
} +
+
) : ( -
- -
+ +
+ +
+
)}
); diff --git a/compiler/apps/playground/components/Editor/Output.tsx b/compiler/apps/playground/components/Editor/Output.tsx index 0ccc0747a6931..a54cc3c3d78fa 100644 --- a/compiler/apps/playground/components/Editor/Output.tsx +++ b/compiler/apps/playground/components/Editor/Output.tsx @@ -32,7 +32,10 @@ import AccordionWindow from '../AccordionWindow'; import TabbedWindow from '../TabbedWindow'; import {monacoOptions} from './monacoOptions'; import {BabelFileResult} from '@babel/core'; -import {CONFIG_PANEL_TRANSITION} from '../../lib/transitionTypes'; +import { + CONFIG_PANEL_TRANSITION, + TOGGLE_INTERNALS_TRANSITION, +} from '../../lib/transitionTypes'; import {LRUCache} from 'lru-cache'; const MemoizedOutput = memo(Output); @@ -291,6 +294,7 @@ function OutputContent({store, compilerOutput}: Props): JSX.Element { dispatchStore({type: 'toggleInternals'})} + onChange={() => + startTransition(() => { + addTransitionType(TOGGLE_INTERNALS_TRANSITION); + dispatchStore({type: 'toggleInternals'}); + }) + } className="absolute opacity-0 cursor-pointer h-full w-full m-0" /> { + console.log(props.foo); + }); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: 'useCustomEffect', + }, + }, + }, + { + // Test settings-based additionalHooks - should work with dependencies + code: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: 'useCustomEffect', + }, + }, + }, + { + // Test that rule-level additionalHooks takes precedence over settings + code: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + options: [{additionalHooks: 'useAnotherEffect'}], + settings: { + 'react-hooks': { + additionalEffectHooks: 'useCustomEffect', + }, + }, + }, + { + // Test settings with multiple hooks pattern + code: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, [props.foo]); + useAnotherEffect(() => { + console.log(props.bar); + }, [props.bar]); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: '(useCustomEffect|useAnotherEffect)', + }, + }, + }, + { + code: normalizeIndent` + function MyComponent({ theme }) { + const onStuff = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onStuff(); + }, []); + React.useEffect(() => { + onStuff(); + }, []); + } + `, + }, ], invalid: [ { @@ -3714,6 +3793,40 @@ const tests = { }, ], }, + { + // Test settings-based additionalHooks - should detect missing dependency + code: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: 'useCustomEffect', + }, + }, + errors: [ + { + message: + "React Hook useCustomEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + }, + ], + }, + ], + }, { code: normalizeIndent` function MyComponent() { @@ -7721,31 +7834,6 @@ const tests = { }, ], }, - ], -}; - -if (__EXPERIMENTAL__) { - tests.valid = [ - ...tests.valid, - { - code: normalizeIndent` - function MyComponent({ theme }) { - const onStuff = useEffectEvent(() => { - showNotification(theme); - }); - useEffect(() => { - onStuff(); - }, []); - React.useEffect(() => { - onStuff(); - }, []); - } - `, - }, - ]; - - tests.invalid = [ - ...tests.invalid, { code: normalizeIndent` function MyComponent({ theme }) { @@ -7809,8 +7897,8 @@ if (__EXPERIMENTAL__) { }, ], }, - ]; -} + ], +}; // Tests that are only valid/invalid across parsers supporting Flow const testsFlow = { diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 2f3e14c5f95e4..bfde0e69e16a6 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -581,6 +581,164 @@ const allTests = { }; `, }, + { + code: normalizeIndent` + // Valid: useEffectEvent can be called in custom effect hooks configured via ESLint settings + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useMyEffect(() => { + onClick(); + }); + useServerEffect(() => { + onClick(); + }); + } + `, + settings: { + 'react-hooks': { + additionalEffectHooks: '(useMyEffect|useServerEffect)', + }, + }, + }, + { + code: normalizeIndent` + // Valid because functions created with useEffectEvent can be called in a useEffect. + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onClick(); + }); + React.useEffect(() => { + onClick(); + }); + } + `, + }, + { + code: normalizeIndent` + // Valid because functions created with useEffectEvent can be passed by reference in useEffect + // and useEffectEvent. + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + const onClick2 = useEffectEvent(() => { + debounce(onClick); + debounce(() => onClick()); + debounce(() => { onClick() }); + deboucne(() => debounce(onClick)); + }); + useEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + return null; + } + `, + }, + { + code: normalizeIndent` + function MyComponent({ theme }) { + useEffect(() => { + onClick(); + }); + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + } + `, + }, + { + code: normalizeIndent` + function MyComponent({ theme }) { + // Can receive arguments + const onEvent = useEffectEvent((text) => { + console.log(text); + }); + + useEffect(() => { + onEvent('Hello world'); + }); + React.useEffect(() => { + onEvent('Hello world'); + }); + } + `, + }, + { + code: normalizeIndent` + // Valid because functions created with useEffectEvent can be called in useLayoutEffect. + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useLayoutEffect(() => { + onClick(); + }); + React.useLayoutEffect(() => { + onClick(); + }); + } + `, + }, + { + code: normalizeIndent` + // Valid because functions created with useEffectEvent can be called in useInsertionEffect. + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + useInsertionEffect(() => { + onClick(); + }); + React.useInsertionEffect(() => { + onClick(); + }); + } + `, + }, + { + code: normalizeIndent` + // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect + // and useInsertionEffect. + function MyComponent({ theme }) { + const onClick = useEffectEvent(() => { + showNotification(theme); + }); + const onClick2 = useEffectEvent(() => { + debounce(onClick); + debounce(() => onClick()); + debounce(() => { onClick() }); + deboucne(() => debounce(onClick)); + }); + useLayoutEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useLayoutEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + useInsertionEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + React.useInsertionEffect(() => { + let id = setInterval(() => onClick(), 100); + return () => clearInterval(onClick); + }, []); + return null; + } + `, + }, ], invalid: [ { @@ -1353,152 +1511,39 @@ const allTests = { `, errors: [tryCatchUseError('use')], }, - ], -}; - -if (__EXPERIMENTAL__) { - allTests.valid = [ - ...allTests.valid, - { - code: normalizeIndent` - // Valid because functions created with useEffectEvent can be called in a useEffect. - function MyComponent({ theme }) { - const onClick = useEffectEvent(() => { - showNotification(theme); - }); - useEffect(() => { - onClick(); - }); - React.useEffect(() => { - onClick(); - }); - } - `, - }, - { - code: normalizeIndent` - // Valid because functions created with useEffectEvent can be passed by reference in useEffect - // and useEffectEvent. - function MyComponent({ theme }) { - const onClick = useEffectEvent(() => { - showNotification(theme); - }); - const onClick2 = useEffectEvent(() => { - debounce(onClick); - debounce(() => onClick()); - debounce(() => { onClick() }); - deboucne(() => debounce(onClick)); - }); - useEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - React.useEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - return null; - } - `, - }, - { - code: normalizeIndent` - function MyComponent({ theme }) { - useEffect(() => { - onClick(); - }); - const onClick = useEffectEvent(() => { - showNotification(theme); - }); - } - `, - }, - { - code: normalizeIndent` - function MyComponent({ theme }) { - // Can receive arguments - const onEvent = useEffectEvent((text) => { - console.log(text); - }); - - useEffect(() => { - onEvent('Hello world'); - }); - React.useEffect(() => { - onEvent('Hello world'); - }); - } - `, - }, { code: normalizeIndent` - // Valid because functions created with useEffectEvent can be called in useLayoutEffect. + // Invalid: useEffectEvent should not be callable in regular custom hooks without additional configuration function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); - useLayoutEffect(() => { - onClick(); - }); - React.useLayoutEffect(() => { + useCustomHook(() => { onClick(); }); } `, + errors: [useEffectEventError('onClick', true)], }, { code: normalizeIndent` - // Valid because functions created with useEffectEvent can be called in useInsertionEffect. + // Invalid: useEffectEvent should not be callable in hooks not matching the settings regex function MyComponent({ theme }) { const onClick = useEffectEvent(() => { showNotification(theme); }); - useInsertionEffect(() => { - onClick(); - }); - React.useInsertionEffect(() => { + useWrongHook(() => { onClick(); }); } `, + settings: { + 'react-hooks': { + additionalEffectHooks: 'useMyEffect', + }, + }, + errors: [useEffectEventError('onClick', true)], }, - { - code: normalizeIndent` - // Valid because functions created with useEffectEvent can be passed by reference in useLayoutEffect - // and useInsertionEffect. - function MyComponent({ theme }) { - const onClick = useEffectEvent(() => { - showNotification(theme); - }); - const onClick2 = useEffectEvent(() => { - debounce(onClick); - debounce(() => onClick()); - debounce(() => { onClick() }); - deboucne(() => debounce(onClick)); - }); - useLayoutEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - React.useLayoutEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - useInsertionEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - React.useInsertionEffect(() => { - let id = setInterval(() => onClick(), 100); - return () => clearInterval(onClick); - }, []); - return null; - } - `, - }, - ]; - allTests.invalid = [ - ...allTests.invalid, { code: normalizeIndent` function MyComponent({ theme }) { @@ -1605,8 +1650,8 @@ if (__EXPERIMENTAL__) { useEffectEventError('onClick', true), ], }, - ]; -} + ], +}; function conditionalError(hook, hasPreviousFinalizer = false) { return { diff --git a/packages/eslint-plugin-react-hooks/src/index.ts b/packages/eslint-plugin-react-hooks/src/index.ts index 65cb030d006d5..2235e8d5a6845 100644 --- a/packages/eslint-plugin-react-hooks/src/index.ts +++ b/packages/eslint-plugin-react-hooks/src/index.ts @@ -7,35 +7,18 @@ import type {Linter, Rule} from 'eslint'; import ExhaustiveDeps from './rules/ExhaustiveDeps'; -import { - allRules, - mapErrorSeverityToESlint, - recommendedRules, -} from './shared/ReactCompiler'; import RulesOfHooks from './rules/RulesOfHooks'; // All rules const rules = { 'exhaustive-deps': ExhaustiveDeps, 'rules-of-hooks': RulesOfHooks, - ...Object.fromEntries( - Object.entries(allRules).map(([name, config]) => [name, config.rule]) - ), } satisfies Record; // Config rules const ruleConfigs = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', - // Compiler rules - ...Object.fromEntries( - Object.entries(recommendedRules).map(([name, ruleConfig]) => { - return [ - 'react-hooks/' + name, - mapErrorSeverityToESlint(ruleConfig.severity), - ]; - }), - ), } satisfies Linter.RulesRecord; const plugin = { diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index d59a1ff79202c..05321ffb46f6e 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -21,6 +21,8 @@ import type { VariableDeclarator, } from 'estree'; +import { getAdditionalEffectHooksFromSettings } from '../shared/Utils'; + type DeclaredDependency = { key: string; node: Node; @@ -69,19 +71,22 @@ const rule = { }, requireExplicitEffectDeps: { type: 'boolean', - } + }, }, }, ], }, create(context: Rule.RuleContext) { const rawOptions = context.options && context.options[0]; + const settings = context.settings || {}; + // Parse the `additionalHooks` regex. + // Use rule-level additionalHooks if provided, otherwise fall back to settings const additionalHooks = rawOptions && rawOptions.additionalHooks ? new RegExp(rawOptions.additionalHooks) - : undefined; + : getAdditionalEffectHooksFromSettings(settings); const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean = (rawOptions && @@ -93,7 +98,8 @@ const rule = { ? rawOptions.experimental_autoDependenciesHooks : []; - const requireExplicitEffectDeps: boolean = rawOptions && rawOptions.requireExplicitEffectDeps || false; + const requireExplicitEffectDeps: boolean = + (rawOptions && rawOptions.requireExplicitEffectDeps) || false; const options = { additionalHooks, @@ -1351,7 +1357,7 @@ const rule = { node: reactiveHook, message: `React Hook ${reactiveHookName} always requires dependencies. ` + - `Please add a dependency array or an explicit \`undefined\`` + `Please add a dependency array or an explicit \`undefined\``, }); } @@ -2116,10 +2122,7 @@ function isAncestorNodeOf(a: Node, b: Node): boolean { } function isUseEffectEventIdentifier(node: Node): boolean { - if (__EXPERIMENTAL__) { - return node.type === 'Identifier' && node.name === 'useEffectEvent'; - } - return false; + return node.type === 'Identifier' && node.name === 'useEffectEvent'; } function getUnknownDependenciesMessage(reactiveHookName: string): string { diff --git a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts index 4c7618d8e084c..cb89bbfea9c49 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts @@ -20,6 +20,7 @@ import type { // @ts-expect-error untyped module import CodePathAnalyzer from '../code-path-analysis/code-path-analyzer'; +import {getAdditionalEffectHooksFromSettings} from '../shared/Utils'; /** * Catch all identifiers that begin with "use" followed by an uppercase Latin @@ -147,15 +148,27 @@ function getNodeWithoutReactNamespace( return node; } -function isEffectIdentifier(node: Node): boolean { - return node.type === 'Identifier' && (node.name === 'useEffect' || node.name === 'useLayoutEffect' || node.name === 'useInsertionEffect'); -} -function isUseEffectEventIdentifier(node: Node): boolean { - if (__EXPERIMENTAL__) { - return node.type === 'Identifier' && node.name === 'useEffectEvent'; +function isEffectIdentifier(node: Node, additionalHooks?: RegExp): boolean { + const isBuiltInEffect = + node.type === 'Identifier' && + (node.name === 'useEffect' || + node.name === 'useLayoutEffect' || + node.name === 'useInsertionEffect'); + + if (isBuiltInEffect) { + return true; } + + // Check if this matches additional hooks configured by the user + if (additionalHooks && node.type === 'Identifier') { + return additionalHooks.test(node.name); + } + return false; } +function isUseEffectEventIdentifier(node: Node): boolean { + return node.type === 'Identifier' && node.name === 'useEffectEvent'; +} function isUseIdentifier(node: Node): boolean { return isReactFunction(node, 'use'); @@ -169,8 +182,24 @@ const rule = { recommended: true, url: 'https://react.dev/reference/rules/rules-of-hooks', }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + additionalHooks: { + type: 'string', + }, + }, + }, + ], }, create(context: Rule.RuleContext) { + const settings = context.settings || {}; + + const additionalEffectHooks = + getAdditionalEffectHooksFromSettings(settings); + let lastEffect: CallExpression | null = null; const codePathReactHooksMapStack: Array< Map> @@ -726,7 +755,7 @@ const rule = { // Check all `useEffect` and `React.useEffect`, `useEffectEvent`, and `React.useEffectEvent` const nodeWithoutNamespace = getNodeWithoutReactNamespace(node.callee); if ( - (isEffectIdentifier(nodeWithoutNamespace) || + (isEffectIdentifier(nodeWithoutNamespace, additionalEffectHooks) || isUseEffectEventIdentifier(nodeWithoutNamespace)) && node.arguments.length > 0 ) { diff --git a/packages/eslint-plugin-react-hooks/src/shared/Utils.ts b/packages/eslint-plugin-react-hooks/src/shared/Utils.ts new file mode 100644 index 0000000000000..54bc21011972d --- /dev/null +++ b/packages/eslint-plugin-react-hooks/src/shared/Utils.ts @@ -0,0 +1,22 @@ +/** + * 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 { Rule } from 'eslint'; + +const SETTINGS_KEY = 'react-hooks'; +const SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY = 'additionalEffectHooks'; + +export function getAdditionalEffectHooksFromSettings( + settings: Rule.RuleContext['settings'], +): RegExp | undefined { + const additionalHooks = settings[SETTINGS_KEY]?.[SETTINGS_ADDITIONAL_EFFECT_HOOKS_KEY]; + if (additionalHooks != null && typeof additionalHooks === 'string') { + return new RegExp(additionalHooks); + } + + return undefined; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 5596257fa5df5..08bba4f9da41f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -17,7 +17,10 @@ import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import Icon from '../Icon'; import Toggle from '../Toggle'; -import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types'; +import { + ElementTypeSuspense, + ElementTypeRoot, +} from 'react-devtools-shared/src/frontend/types'; import InspectedElementView from './InspectedElementView'; import {InspectedElementContext} from './InspectedElementContext'; import {getAlwaysOpenInEditor} from '../../../utils'; @@ -205,6 +208,16 @@ export default function InspectedElementWrapper(_: Props): React.Node { ); } + let fullName = element.displayName || ''; + if (element.nameProp !== null) { + fullName += ' "' + element.nameProp + '"'; + } + if (element.type === ElementTypeRoot) { + // The root only has "suspended by" and it represents the things that block + // Initial Paint. + fullName = 'Initial Paint'; + } + return (
- {element.displayName} + title={fullName}> + {fullName}
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index b49d0b5eb9ad0..a9dcfc38f4214 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -25,7 +25,9 @@ export default function SuspenseBreadcrumbs(): React$Node { const store = useContext(StoreContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); - const {selectedSuspenseID, lineage} = useContext(SuspenseTreeStateContext); + const {selectedSuspenseID, selectedRootID, lineage} = useContext( + SuspenseTreeStateContext, + ); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); @@ -38,7 +40,24 @@ export default function SuspenseBreadcrumbs(): React$Node { return (
    - {lineage !== null && + {lineage === null ? null : lineage.length === 0 ? ( + // We selected the root. This means that we're currently viewing the Transition + // that rendered the whole screen. In laymans terms this is really "Initial Paint". + // TODO: Once we add subtree selection, then the equivalent should be called + // "Transition" since in that case it's really about a Transition within the page. + selectedRootID !== null ? ( +
  1. + +
  2. + ) : null + ) : ( lineage.map((id, index) => { const node = store.getSuspenseByID(id); @@ -57,7 +76,8 @@ export default function SuspenseBreadcrumbs(): React$Node { ); - })} + }) + )}
); } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 52b9b9ebe582c..7251e52dd6108 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -649,6 +649,7 @@ function updateOffscreenComponent( ? mergeLanes(prevState.baseLanes, renderLanes) : renderLanes; + let remainingChildLanes; if (current !== null) { // Reset to the current children let currentChild = (workInProgress.child = current.child); @@ -666,13 +667,12 @@ function updateOffscreenComponent( currentChild = currentChild.sibling; } const lanesWeJustAttempted = nextBaseLanes; - const remainingChildLanes = removeLanes( + remainingChildLanes = removeLanes( currentChildLanes, lanesWeJustAttempted, ); - workInProgress.childLanes = remainingChildLanes; } else { - workInProgress.childLanes = NoLanes; + remainingChildLanes = NoLanes; workInProgress.child = null; } @@ -681,6 +681,7 @@ function updateOffscreenComponent( workInProgress, nextBaseLanes, renderLanes, + remainingChildLanes, ); } @@ -707,8 +708,9 @@ function updateOffscreenComponent( // and resume this tree later. // Schedule this fiber to re-render at Offscreen priority - workInProgress.lanes = workInProgress.childLanes = - laneToLanes(OffscreenLane); + + const remainingChildLanes = (workInProgress.lanes = + laneToLanes(OffscreenLane)); // Include the base lanes from the last render const nextBaseLanes = @@ -721,6 +723,7 @@ function updateOffscreenComponent( workInProgress, nextBaseLanes, renderLanes, + remainingChildLanes, ); } else { // This is the second render. The surrounding visible content has already @@ -826,6 +829,7 @@ function deferHiddenOffscreenComponent( workInProgress: Fiber, nextBaseLanes: Lanes, renderLanes: Lanes, + remainingChildLanes: Lanes, ) { const nextState: OffscreenState = { baseLanes: nextBaseLanes, @@ -856,6 +860,13 @@ function deferHiddenOffscreenComponent( ); } + // We override the remaining child lanes to be the subset that we computed + // on the outside. We need to do this after propagating the context + // because propagateParentContextChangesToDeferredTree may schedule + // work which bubbles all the way up to the root and updates our child lanes. + // We want to dismiss that since we're not going to work on it yet. + workInProgress.childLanes = remainingChildLanes; + return null; } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 6be3300a70ca0..a39ce6d736904 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -130,10 +130,13 @@ import { popComponentEffectDuration, pushComponentEffectErrors, popComponentEffectErrors, + pushComponentEffectDidSpawnUpdate, + popComponentEffectDidSpawnUpdate, componentEffectStartTime, componentEffectEndTime, componentEffectDuration, componentEffectErrors, + componentEffectSpawnedUpdate, } from './ReactProfilerTimer'; import { logComponentRender, @@ -595,6 +598,7 @@ function commitLayoutEffectOnFiber( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); // When updating this function, also update reappearLayoutEffects, which does // most of the same things when an offscreen tree goes from hidden -> visible. const flags = finishedWork.flags; @@ -876,7 +880,7 @@ function commitLayoutEffectOnFiber( componentEffectStartTime >= 0 && componentEffectEndTime >= 0 ) { - if (componentEffectDuration > 0.05) { + if (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) { logComponentEffect( finishedWork, componentEffectStartTime, @@ -909,6 +913,7 @@ function commitLayoutEffectOnFiber( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); } function abortRootTransitions( @@ -1430,6 +1435,7 @@ function commitDeletionEffectsOnFiber( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); // The cases in this outer switch modify the stack before they traverse // into their subtree. There are simpler cases in the inner switch @@ -1750,7 +1756,7 @@ function commitDeletionEffectsOnFiber( (deletedFiber.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( deletedFiber, @@ -1764,6 +1770,7 @@ function commitDeletionEffectsOnFiber( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); } function commitSuspenseCallback(finishedWork: Fiber) { @@ -1987,6 +1994,7 @@ function commitMutationEffectsOnFiber( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); const current = finishedWork.alternate; const flags = finishedWork.flags; @@ -2611,7 +2619,7 @@ function commitMutationEffectsOnFiber( componentEffectStartTime >= 0 && componentEffectEndTime >= 0 ) { - if (componentEffectDuration > 0.05) { + if (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) { logComponentEffect( finishedWork, componentEffectStartTime, @@ -2644,6 +2652,7 @@ function commitMutationEffectsOnFiber( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); } function commitReconciliationEffects( @@ -2900,6 +2909,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: @@ -2990,7 +3000,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { (finishedWork.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( finishedWork, @@ -3004,6 +3014,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); } function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) { @@ -3027,6 +3038,7 @@ export function reappearLayoutEffects( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); // Turn on layout effects in a tree that previously disappeared. const flags = finishedWork.flags; switch (finishedWork.tag) { @@ -3224,7 +3236,7 @@ export function reappearLayoutEffects( (finishedWork.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( finishedWork, @@ -3238,6 +3250,7 @@ export function reappearLayoutEffects( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); } function recursivelyTraverseReappearLayoutEffects( @@ -3489,6 +3502,7 @@ function commitPassiveMountOnFiber( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); const prevDeepEquality = pushDeepEquality(); const isViewTransitionEligible = enableViewTransition @@ -4060,7 +4074,7 @@ function commitPassiveMountOnFiber( } } if (componentEffectStartTime >= 0 && componentEffectEndTime >= 0) { - if (componentEffectDuration > 0.05) { + if (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) { logComponentEffect( finishedWork, componentEffectStartTime, @@ -4082,6 +4096,7 @@ function commitPassiveMountOnFiber( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); popDeepEquality(prevDeepEquality); } @@ -4144,6 +4159,7 @@ export function reconnectPassiveEffects( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); const prevDeepEquality = pushDeepEquality(); // If this component rendered in Profiling mode (DEV or in Profiler component) then log its @@ -4334,7 +4350,7 @@ export function reconnectPassiveEffects( (finishedWork.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( finishedWork, @@ -4348,6 +4364,7 @@ export function reconnectPassiveEffects( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); popComponentEffectErrors(prevEffectErrors); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); popDeepEquality(prevDeepEquality); } @@ -4737,6 +4754,7 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); switch (finishedWork.tag) { case FunctionComponent: case ForwardRef: @@ -4833,7 +4851,7 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { (finishedWork.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( finishedWork, @@ -4846,6 +4864,7 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); popComponentEffectErrors(prevEffectErrors); } @@ -4903,6 +4922,7 @@ export function disconnectPassiveEffect(finishedWork: Fiber): void { const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); switch (finishedWork.tag) { case FunctionComponent: @@ -4942,7 +4962,7 @@ export function disconnectPassiveEffect(finishedWork: Fiber): void { (finishedWork.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( finishedWork, @@ -4955,6 +4975,7 @@ export function disconnectPassiveEffect(finishedWork: Fiber): void { popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); popComponentEffectErrors(prevEffectErrors); } @@ -5016,6 +5037,7 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( const prevEffectStart = pushComponentEffectStart(); const prevEffectDuration = pushComponentEffectDuration(); const prevEffectErrors = pushComponentEffectErrors(); + const prevEffectDidSpawnUpdate = pushComponentEffectDidSpawnUpdate(); switch (current.tag) { case FunctionComponent: case ForwardRef: @@ -5135,7 +5157,7 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( (current.mode & ProfileMode) !== NoMode && componentEffectStartTime >= 0 && componentEffectEndTime >= 0 && - componentEffectDuration > 0.05 + (componentEffectSpawnedUpdate || componentEffectDuration > 0.05) ) { logComponentEffect( current, @@ -5148,6 +5170,7 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber( popComponentEffectStart(prevEffectStart); popComponentEffectDuration(prevEffectDuration); + popComponentEffectDidSpawnUpdate(prevEffectDidSpawnUpdate); popComponentEffectErrors(prevEffectErrors); } diff --git a/packages/react-reconciler/src/ReactProfilerTimer.js b/packages/react-reconciler/src/ReactProfilerTimer.js index 152810f85068c..060d60e6d736f 100644 --- a/packages/react-reconciler/src/ReactProfilerTimer.js +++ b/packages/react-reconciler/src/ReactProfilerTimer.js @@ -64,6 +64,7 @@ export let componentEffectDuration: number = -0; export let componentEffectStartTime: number = -1.1; export let componentEffectEndTime: number = -1.1; export let componentEffectErrors: null | Array> = null; +export let componentEffectSpawnedUpdate: boolean = false; export let blockingClampTime: number = -0; export let blockingUpdateTime: number = -1.1; // First sync setState scheduled. @@ -153,6 +154,7 @@ export function startUpdateTimerByLane( blockingUpdateComponentName = getComponentNameFromFiber(fiber); } if (isAlreadyRendering()) { + componentEffectSpawnedUpdate = true; blockingUpdateType = SPAWNED_UPDATE; } const newEventTime = resolveEventTimeStamp(); @@ -495,6 +497,24 @@ export function popComponentEffectErrors( componentEffectErrors = prevErrors; } +export function pushComponentEffectDidSpawnUpdate(): boolean { + if (!enableProfilerTimer || !enableProfilerCommitHooks) { + return false; + } + + const prev = componentEffectSpawnedUpdate; + componentEffectSpawnedUpdate = false; // Reset. + return prev; +} + +export function popComponentEffectDidSpawnUpdate(previousValue: boolean): void { + if (!enableProfilerTimer || !enableProfilerCommitHooks) { + return; + } + + componentEffectSpawnedUpdate = previousValue; +} + /** * Tracks whether the current update was a nested/cascading update (scheduled from a layout effect). *