@samitouri / QOS-React-2 / commits / 996e4c0d56

Offscreen add attach (#25603)

`Offscreen.attach` is imperative API to signal to Offscreen that its updates should be high priority and effects should be mounted. Coupled with `Offscreen.detach` it gives ability to manually control Offscreen. Unlike with mode `visible` and `hidden`, it is developers job to make sure contents of Offscreen are not visible to users. `Offscreen.attach` only works if mode is `manual`. Example uses: ```jsx let offscreenRef = useRef(null); <Offscreen mode={'manual'} ref={offscreenRef)}> <Child /> </Offscreen> // ------ // Offscreen is attached by default. // For example user scrolls away and Offscreen subtree is not visible anymore. offscreenRef.current.detach(); // User scrolls back and Offscreen subtree is visible again. offscreenRef.current.attach(); ``` Co-authored-by: Andrew Clark <git@andrewclark.io>

Samuel Susla committed Dec 12, 2022 at 14:00 UTC 996e4c0d56dabab382ca932cd5b8517e63020999
5 files changed +372 -43
packages/react-reconciler/src/ReactFiber.js
+8 -1
@@ -107,7 +107,10 @@ import {
107 REACT_TRACING_MARKER_TYPE,
108 } from 'shared/ReactSymbols';
109 import {TransitionTracingMarker} from './ReactFiberTracingMarkerComponent';
110 -import {detachOffscreenInstance} from './ReactFiberCommitWork';
110 +import {
111 + detachOffscreenInstance,
112 + attachOffscreenInstance,
113 +} from './ReactFiberCommitWork';
114 import {getHostContext} from './ReactFiberHostContext';
115
116 export type {Fiber};
@@ -750,11 +753,13 @@ export function createFiberFromOffscreen(
753 fiber.lanes = lanes;
754 const primaryChildInstance: OffscreenInstance = {
755 _visibility: OffscreenVisible,
756 + _pendingVisibility: OffscreenVisible,
757 _pendingMarkers: null,
758 _retryCache: null,
759 _transitions: null,
760 _current: null,
761 detach: () => detachOffscreenInstance(primaryChildInstance),
762 + attach: () => attachOffscreenInstance(primaryChildInstance),
763 };
764 fiber.stateNode = primaryChildInstance;
765 return fiber;
@@ -773,11 +778,13 @@ export function createFiberFromLegacyHidden(
778 // the offscreen implementation, which depends on a state node
779 const instance: OffscreenInstance = {
780 _visibility: OffscreenVisible,
781 + _pendingVisibility: OffscreenVisible,
782 _pendingMarkers: null,
783 _transitions: null,
784 _retryCache: null,
785 _current: null,
786 detach: () => detachOffscreenInstance(instance),
787 + attach: () => attachOffscreenInstance(instance),
788 };
789 fiber.stateNode = instance;
790 return fiber;
packages/react-reconciler/src/ReactFiberBeginWork.js
+3 -2
@@ -677,6 +677,8 @@ function updateOffscreenComponent(
677 ) {
678 const nextProps: OffscreenProps = workInProgress.pendingProps;
679 const nextChildren = nextProps.children;
680 + const nextIsDetached =
681 + (workInProgress.stateNode._pendingVisibility & OffscreenDetached) !== 0;
682
683 const prevState: OffscreenState | null =
684 current !== null ? current.memoizedState : null;
@@ -687,8 +689,7 @@ function updateOffscreenComponent(
689 nextProps.mode === 'hidden' ||
690 (enableLegacyHidden &&
691 nextProps.mode === 'unstable-defer-without-hiding') ||
690 - // TODO: remove read from stateNode.
691 - workInProgress.stateNode._visibility & OffscreenDetached
692 + nextIsDetached
693 ) {
694 // Rendering a hidden tree.
695
packages/react-reconciler/src/ReactFiberCommitWork.js
+46 -18
@@ -17,6 +17,7 @@ import type {
17 } from './ReactFiberHostConfig';
18 import type {Fiber, FiberRoot} from './ReactInternalTypes';
19 import type {Lanes} from './ReactFiberLane';
20 +import {NoTimestamp, SyncLane} from './ReactFiberLane';
21 import type {SuspenseState} from './ReactFiberSuspenseComponent';
22 import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
23 import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
@@ -152,7 +153,6 @@ import {
153 clearSingleton,
154 acquireSingletonInstance,
155 releaseSingletonInstance,
155 - scheduleMicrotask,
156 } from './ReactFiberHostConfig';
157 import {
158 captureCommitPhaseError,
@@ -169,7 +169,6 @@ import {
169 setIsRunningInsertionEffect,
170 getExecutionContext,
171 CommitContext,
172 - RenderContext,
172 NoContext,
173 } from './ReactFiberWorkLoop';
174 import {
@@ -205,6 +204,8 @@ import {
204 TransitionRoot,
205 TransitionTracingMarker,
206 } from './ReactFiberTracingMarkerComponent';
207 +import {scheduleUpdateOnFiber} from './ReactFiberWorkLoop';
208 +import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
209
210 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
211 if (__DEV__) {
@@ -2407,24 +2408,44 @@ function getRetryCache(finishedWork) {
2408 }
2409
2410 export function detachOffscreenInstance(instance: OffscreenInstance): void {
2410 - const currentOffscreenFiber = instance._current;
2411 - if (currentOffscreenFiber === null) {
2411 + const fiber = instance._current;
2412 + if (fiber === null) {
2413 throw new Error(
2414 'Calling Offscreen.detach before instance handle has been set.',
2415 );
2416 }
2417
2417 - const executionContext = getExecutionContext();
2418 - if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
2419 - scheduleMicrotask(() => {
2420 - instance._visibility |= OffscreenDetached;
2421 - disappearLayoutEffects(currentOffscreenFiber);
2422 - disconnectPassiveEffect(currentOffscreenFiber);
2423 - });
2424 - } else {
2425 - instance._visibility |= OffscreenDetached;
2426 - disappearLayoutEffects(currentOffscreenFiber);
2427 - disconnectPassiveEffect(currentOffscreenFiber);
2418 + if ((instance._pendingVisibility & OffscreenDetached) !== NoFlags) {
2419 + // The instance is already detached, this is a noop.
2420 + return;
2421 + }
2422 +
2423 + // TODO: There is an opportunity to optimise this by not entering commit phase
2424 + // and unmounting effects directly.
2425 + const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
2426 + if (root !== null) {
2427 + instance._pendingVisibility |= OffscreenDetached;
2428 + scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
2429 + }
2430 +}
2431 +
2432 +export function attachOffscreenInstance(instance: OffscreenInstance): void {
2433 + const fiber = instance._current;
2434 + if (fiber === null) {
2435 + throw new Error(
2436 + 'Calling Offscreen.detach before instance handle has been set.',
2437 + );
2438 + }
2439 +
2440 + if ((instance._pendingVisibility & OffscreenDetached) === NoFlags) {
2441 + // The instance is already attached, this is a noop.
2442 + return;
2443 + }
2444 +
2445 + const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
2446 + if (root !== null) {
2447 + instance._pendingVisibility &= ~OffscreenDetached;
2448 + scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
2449 }
2450 }
2451
@@ -2857,12 +2878,19 @@ function commitMutationEffectsOnFiber(
2878 }
2879
2880 commitReconciliationEffects(finishedWork);
2881 +
2882 + const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
2883 +
2884 // TODO: Add explicit effect flag to set _current.
2861 - finishedWork.stateNode._current = finishedWork;
2885 + offscreenInstance._current = finishedWork;
2886
2863 - if (flags & Visibility) {
2864 - const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
2887 + // Offscreen stores pending changes to visibility in `_pendingVisibility`. This is
2888 + // to support batching of `attach` and `detach` calls.
2889 + offscreenInstance._visibility &= ~OffscreenDetached;
2890 + offscreenInstance._visibility |=
2891 + offscreenInstance._pendingVisibility & OffscreenDetached;
2892
2893 + if (flags & Visibility) {
2894 // Track the current state on the Offscreen instance so we can
2895 // read it during an event
2896 if (isHidden) {
packages/react-reconciler/src/ReactFiberOffscreenComponent.js
+2 -2
@@ -50,6 +50,7 @@ export const OffscreenDetached = /* */ 0b010;
50 export const OffscreenPassiveEffectsConnected = /* */ 0b100;
51
52 export type OffscreenInstance = {
53 + _pendingVisibility: OffscreenVisibility,
54 _visibility: OffscreenVisibility,
55 _pendingMarkers: Set<TracingMarkerInstance> | null,
56 _transitions: Set<Transition> | null,
@@ -59,8 +60,7 @@ export type OffscreenInstance = {
60 // Represents the current Offscreen fiber
61 _current: Fiber | null,
62 detach: () => void,
62 -
63 - // TODO: attach
63 + attach: () => void,
64 };
65
66 export function isOffscreenManual(offscreenFiber: Fiber): boolean {
packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
+313 -20
@@ -31,7 +31,24 @@ describe('ReactOffscreen', () => {
31
32 function Text(props) {
33 Scheduler.unstable_yieldValue(props.text);
34 - return <span prop={props.text} />;
34 + return <span prop={props.text}>{props.children}</span>;
35 + }
36 +
37 + function LoggedText({text, children}) {
38 + useEffect(() => {
39 + Scheduler.unstable_yieldValue(`mount ${text}`);
40 + return () => {
41 + Scheduler.unstable_yieldValue(`unmount ${text}`);
42 + };
43 + });
44 +
45 + useLayoutEffect(() => {
46 + Scheduler.unstable_yieldValue(`mount layout ${text}`);
47 + return () => {
48 + Scheduler.unstable_yieldValue(`unmount layout ${text}`);
49 + };
50 + });
51 + return <Text text={text}>{children}</Text>;
52 }
53
54 // @gate enableLegacyHidden
@@ -1520,7 +1537,6 @@ describe('ReactOffscreen', () => {
1537 );
1538
1539 expect(offscreenRef.current).not.toBeNull();
1523 - expect(offscreenRef.current.detach).not.toBeNull();
1540
1541 // Offscreen is attached by default. State updates from offscreen are **not defered**.
1542 await act(async () => {
@@ -1538,8 +1554,9 @@ describe('ReactOffscreen', () => {
1554 );
1555 });
1556
1541 - // detaching offscreen.
1542 - offscreenRef.current.detach();
1557 + await act(async () => {
1558 + offscreenRef.current.detach();
1559 + });
1560
1561 // Offscreen is detached. State updates from offscreen are **defered**.
1562 await act(async () => {
@@ -1561,6 +1578,26 @@ describe('ReactOffscreen', () => {
1578 <span prop="Child 2" />
1579 </>,
1580 );
1581 +
1582 + await act(async () => {
1583 + offscreenRef.current.attach();
1584 + });
1585 +
1586 + // Offscreen is attached. State updates from offscreen are **not defered**.
1587 + await act(async () => {
1588 + updateChildState(3);
1589 + updateHighPriorityComponentState(3);
1590 + expect(Scheduler).toFlushUntilNextPaint([
1591 + 'HighPriorityComponent 3',
1592 + 'Child 3',
1593 + ]);
1594 + expect(root).toMatchRenderedOutput(
1595 + <>
1596 + <span prop="HighPriorityComponent 3" />
1597 + <span prop="Child 3" />
1598 + </>,
1599 + );
1600 + });
1601 });
1602
1603 // @gate enableOffscreen
@@ -1569,6 +1606,7 @@ describe('ReactOffscreen', () => {
1606 let updateHighPriorityComponentState;
1607 let offscreenRef;
1608 let nextRenderTriggerDetach = false;
1609 + let nextRenderTriggerAttach = false;
1610
1611 function Child() {
1612 const [state, _stateUpdate] = useState(0);
@@ -1583,11 +1621,16 @@ describe('ReactOffscreen', () => {
1621 const text = 'HighPriorityComponent ' + state;
1622 useLayoutEffect(() => {
1623 if (nextRenderTriggerDetach) {
1586 - offscreenRef.current.detach();
1624 _stateUpdate(state + 1);
1625 updateChildState(state + 1);
1626 + offscreenRef.current.detach();
1627 nextRenderTriggerDetach = false;
1628 }
1629 +
1630 + if (nextRenderTriggerAttach) {
1631 + offscreenRef.current.attach();
1632 + nextRenderTriggerAttach = false;
1633 + }
1634 });
1635 return (
1636 <>
@@ -1620,8 +1663,8 @@ describe('ReactOffscreen', () => {
1663
1664 nextRenderTriggerDetach = true;
1665
1623 - // Offscreen is attached. State updates from offscreen are **not defered**.
1624 - // Offscreen is detached inside useLayoutEffect;
1666 + // Offscreen is attached and gets detached inside useLayoutEffect.
1667 + // State updates from offscreen are **defered**.
1668 await act(async () => {
1669 updateChildState(1);
1670 updateHighPriorityComponentState(1);
@@ -1629,36 +1672,41 @@ describe('ReactOffscreen', () => {
1672 'HighPriorityComponent 1',
1673 'Child 1',
1674 'HighPriorityComponent 2',
1632 - 'Child 2',
1675 ]);
1676 expect(root).toMatchRenderedOutput(
1677 <>
1678 <span prop="HighPriorityComponent 2" />
1637 - <span prop="Child 2" />
1679 + <span prop="Child 1" />
1680 </>,
1681 );
1682 });
1683
1684 + expect(Scheduler).toHaveYielded(['Child 2']);
1685 + expect(root).toMatchRenderedOutput(
1686 + <>
1687 + <span prop="HighPriorityComponent 2" />
1688 + <span prop="Child 2" />
1689 + </>,
1690 + );
1691 +
1692 + nextRenderTriggerAttach = true;
1693 +
1694 // Offscreen is detached. State updates from offscreen are **defered**.
1695 + // Offscreen is attached inside useLayoutEffect;
1696 await act(async () => {
1697 updateChildState(3);
1698 updateHighPriorityComponentState(3);
1646 - expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 3']);
1699 + expect(Scheduler).toFlushUntilNextPaint([
1700 + 'HighPriorityComponent 3',
1701 + 'Child 3',
1702 + ]);
1703 expect(root).toMatchRenderedOutput(
1704 <>
1705 <span prop="HighPriorityComponent 3" />
1650 - <span prop="Child 2" />
1706 + <span prop="Child 3" />
1707 </>,
1708 );
1709 });
1654 -
1655 - expect(Scheduler).toHaveYielded(['Child 3']);
1656 - expect(root).toMatchRenderedOutput(
1657 - <>
1658 - <span prop="HighPriorityComponent 3" />
1659 - <span prop="Child 3" />
1660 - </>,
1661 - );
1710 });
1711 });
1712
@@ -1771,5 +1819,250 @@ describe('ReactOffscreen', () => {
1819 expect(offscreenRef.current._current === firstFiber).toBeFalsy();
1820 });
1821
1774 - // TODO: When attach/detach methods are implemented. Add tests for nested Offscreen case.
1822 + // @gate enableOffscreen
1823 + it('does not mount tree until attach is called', async () => {
1824 + let offscreenRef;
1825 + let spanRef;
1826 +
1827 + function Child() {
1828 + spanRef = useRef(null);
1829 + useEffect(() => {
1830 + Scheduler.unstable_yieldValue('Mount Child');
1831 + return () => {
1832 + Scheduler.unstable_yieldValue('Unmount Child');
1833 + };
1834 + });
1835 + useLayoutEffect(() => {
1836 + Scheduler.unstable_yieldValue('Mount Layout Child');
1837 + return () => {
1838 + Scheduler.unstable_yieldValue('Unmount Layout Child');
1839 + };
1840 + });
1841 +
1842 + return <span ref={spanRef}>Child</span>;
1843 + }
1844 +
1845 + function App() {
1846 + return (
1847 + <Offscreen mode={'manual'} ref={el => (offscreenRef = el)}>
1848 + <Child />
1849 + </Offscreen>
1850 + );
1851 + }
1852 +
1853 + const root = ReactNoop.createRoot();
1854 +
1855 + await act(async () => {
1856 + root.render(<App />);
1857 + });
1858 +
1859 + expect(offscreenRef).not.toBeNull();
1860 + expect(spanRef.current).not.toBeNull();
1861 + expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
1862 +
1863 + await act(async () => {
1864 + offscreenRef.detach();
1865 + });
1866 +
1867 + expect(spanRef.current).toBeNull();
1868 + expect(Scheduler).toHaveYielded(['Unmount Layout Child', 'Unmount Child']);
1869 +
1870 + // Calling attach on already attached Offscreen.
1871 + await act(async () => {
1872 + offscreenRef.detach();
1873 + });
1874 +
1875 + expect(Scheduler).toHaveYielded([]);
1876 +
1877 + await act(async () => {
1878 + offscreenRef.attach();
1879 + });
1880 +
1881 + expect(spanRef.current).not.toBeNull();
1882 + expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
1883 +
1884 + // Calling attach on already attached Offscreen
1885 + offscreenRef.attach();
1886 +
1887 + expect(Scheduler).toHaveYielded([]);
1888 + });
1889 +
1890 + // @gate enableOffscreen
1891 + it('handles nested manual offscreens', async () => {
1892 + let outerOffscreen;
1893 + let innerOffscreen;
1894 +
1895 + function App() {
1896 + return (
1897 + <LoggedText text={'outer'}>
1898 + <Offscreen mode={'manual'} ref={el => (outerOffscreen = el)}>
1899 + <LoggedText text={'middle'}>
1900 + <Offscreen mode={'manual'} ref={el => (innerOffscreen = el)}>
1901 + <LoggedText text={'inner'} />
1902 + </Offscreen>
1903 + </LoggedText>
1904 + </Offscreen>
1905 + </LoggedText>
1906 + );
1907 + }
1908 +
1909 + const root = ReactNoop.createRoot();
1910 +
1911 + await act(async () => {
1912 + root.render(<App />);
1913 + });
1914 +
1915 + expect(Scheduler).toHaveYielded([
1916 + 'outer',
1917 + 'middle',
1918 + 'inner',
1919 + 'mount layout inner',
1920 + 'mount layout middle',
1921 + 'mount layout outer',
1922 + 'mount inner',
1923 + 'mount middle',
1924 + 'mount outer',
1925 + ]);
1926 +
1927 + expect(outerOffscreen).not.toBeNull();
1928 + expect(innerOffscreen).not.toBeNull();
1929 +
1930 + await act(async () => {
1931 + outerOffscreen.detach();
1932 + });
1933 +
1934 + expect(innerOffscreen).toBeNull();
1935 +
1936 + expect(Scheduler).toHaveYielded([
1937 + 'unmount layout middle',
1938 + 'unmount layout inner',
1939 + 'unmount middle',
1940 + 'unmount inner',
1941 + ]);
1942 +
1943 + await act(async () => {
1944 + outerOffscreen.attach();
1945 + });
1946 +
1947 + expect(Scheduler).toHaveYielded([
1948 + 'mount layout inner',
1949 + 'mount layout middle',
1950 + 'mount inner',
1951 + 'mount middle',
1952 + ]);
1953 +
1954 + await act(async () => {
1955 + innerOffscreen.detach();
1956 + });
1957 +
1958 + expect(Scheduler).toHaveYielded(['unmount layout inner', 'unmount inner']);
1959 +
1960 + // Calling detach on already detached Offscreen.
1961 + await act(async () => {
1962 + innerOffscreen.detach();
1963 + });
1964 +
1965 + expect(Scheduler).toHaveYielded([]);
1966 +
1967 + await act(async () => {
1968 + innerOffscreen.attach();
1969 + });
1970 +
1971 + expect(Scheduler).toHaveYielded(['mount layout inner', 'mount inner']);
1972 +
1973 + await act(async () => {
1974 + innerOffscreen.detach();
1975 + outerOffscreen.attach();
1976 + });
1977 +
1978 + expect(Scheduler).toHaveYielded(['unmount layout inner', 'unmount inner']);
1979 + });
1980 +
1981 + // @gate enableOffscreen
1982 + it('batches multiple attach and detach calls scheduled from an event handler', async () => {
1983 + function Child() {
1984 + useEffect(() => {
1985 + Scheduler.unstable_yieldValue('attach child');
1986 + return () => {
1987 + Scheduler.unstable_yieldValue('detach child');
1988 + };
1989 + }, []);
1990 + return 'child';
1991 + }
1992 +
1993 + const offscreen = React.createRef(null);
1994 + function App() {
1995 + return (
1996 + <Offscreen ref={offscreen} mode="manual">
1997 + <Child />
1998 + </Offscreen>
1999 + );
2000 + }
2001 +
2002 + const root = ReactNoop.createRoot();
2003 + await act(() => {
2004 + root.render(<App />);
2005 + });
2006 +
2007 + expect(Scheduler).toHaveYielded(['attach child']);
2008 +
2009 + await act(async () => {
2010 + const instance = offscreen.current;
2011 + // Detach then immediately attach the instance.
2012 + instance.detach();
2013 + instance.attach();
2014 + });
2015 +
2016 + expect(Scheduler).toHaveYielded([]);
2017 +
2018 + await act(async () => {
2019 + const instance = offscreen.current;
2020 + instance.detach();
2021 + });
2022 +
2023 + expect(Scheduler).toHaveYielded(['detach child']);
2024 +
2025 + await act(async () => {
2026 + const instance = offscreen.current;
2027 + // Attach then immediately detach.
2028 + instance.attach();
2029 + instance.detach();
2030 + });
2031 +
2032 + expect(Scheduler).toHaveYielded([]);
2033 + });
2034 +
2035 + // @gate enableOffscreen
2036 + it('batches multiple attach and detach calls scheduled from an effect', async () => {
2037 + function Child() {
2038 + useEffect(() => {
2039 + Scheduler.unstable_yieldValue('attach child');
2040 + return () => {
2041 + Scheduler.unstable_yieldValue('detach child');
2042 + };
2043 + }, []);
2044 + return 'child';
2045 + }
2046 +
2047 + function App() {
2048 + const offscreen = useRef(null);
2049 + useLayoutEffect(() => {
2050 + const instance = offscreen.current;
2051 + // Detach then immediately attach the instance.
2052 + instance.detach();
2053 + instance.attach();
2054 + }, []);
2055 + return (
2056 + <Offscreen ref={offscreen} mode="manual">
2057 + <Child />
2058 + </Offscreen>
2059 + );
2060 + }
2061 +
2062 + const root = ReactNoop.createRoot();
2063 + await act(() => {
2064 + root.render(<App />);
2065 + });
2066 + expect(Scheduler).toHaveYielded(['attach child']);
2067 + });
2068 });