@samitouri / QOS-React-2 / commits / 1df756ba2c

Always wrap Suspense children in fragment (#18711)

Changes the internal fiber structure of the Suspense component. When a Suspense boundary can't finish rendering and switches to a fallback, we wrap the "primary" tree in a Fragment fiber and hide all its DOM nodes. Then we mount the fallback tree into a separate Fragment fiber. Both trees will render into the same parent DOM node (since React fragments aren't part of the host tree), but the wrappers ensure that the children in each tree are reconciled separately. The old implementation would try to be clever and only add the fragment wrapper when the fallback was in place, to save memory. This "worked" but was prone to regressions, since this is the only such place in the codebase where we wrap existing nodes in a new node. (In other words, it's a form of reparenting, which we don't implement elsewhere). Since the original implementation, we've also added lots of additional requirements to the Suspense component that have led to an explosion in complexity, like limited support in Legacy Mode (with very different semantics) and progressive hydration. We're planning to add even more features to the Suspense boundary, so we're going to sacrifice a bit more memory for a simpler implementation that is less prone to regressions. This ended up removing a lot of weird hacks and edge cases, but there are still plenty left over. Most of the remaining complexity is related to Legacy mode. That's the next thing we should aim to drop support for. Because this is a risky change, I've only changed this in the new reconciler. It blocks some other features, but as of now we're not planning to implement those in the old reconciler. If that changes, this should cherry-pick to the other implementation without much effort.

Andrew Clark committed Apr 25, 2020 at 00:09 UTC 1df756ba2cfe60405d1f711d552484ee4bd77fb5
5 files changed +376 -381
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+310 -331
@@ -1737,7 +1737,6 @@ function updateSuspenseComponent(
1737 workInProgress,
1738 renderExpirationTime,
1739 ) {
1740 - const mode = workInProgress.mode;
1740 const nextProps = workInProgress.pendingProps;
1741
1742 // This is used by DevTools to force a boundary to suspend.
@@ -1749,7 +1748,7 @@ function updateSuspenseComponent(
1748
1749 let suspenseContext: SuspenseContext = suspenseStackCursor.current;
1750
1752 - let nextDidTimeout = false;
1751 + let showFallback = false;
1752 const didSuspend = (workInProgress.effectTag & DidCapture) !== NoEffect;
1753
1754 if (
@@ -1763,7 +1762,7 @@ function updateSuspenseComponent(
1762 ) {
1763 // Something in this boundary's subtree already suspended. Switch to
1764 // rendering the fallback children.
1766 - nextDidTimeout = true;
1765 + showFallback = true;
1766 workInProgress.effectTag &= ~DidCapture;
1767 } else {
1768 // Attempting the main content
@@ -1792,29 +1791,30 @@ function updateSuspenseComponent(
1791
1792 pushSuspenseContext(workInProgress, suspenseContext);
1793
1795 - // This next part is a bit confusing. If the children timeout, we switch to
1796 - // showing the fallback children in place of the "primary" children.
1797 - // However, we don't want to delete the primary children because then their
1798 - // state will be lost (both the React state and the host state, e.g.
1799 - // uncontrolled form inputs). Instead we keep them mounted and hide them.
1800 - // Both the fallback children AND the primary children are rendered at the
1801 - // same time. Once the primary children are un-suspended, we can delete
1802 - // the fallback children — don't need to preserve their state.
1794 + // OK, the next part is confusing. We're about to reconcile the Suspense
1795 + // boundary's children. This involves some custom reconcilation logic. Two
1796 + // main reasons this is so complicated.
1797 //
1804 - // The two sets of children are siblings in the host environment, but
1805 - // semantically, for purposes of reconciliation, they are two separate sets.
1806 - // So we store them using two fragment fibers.
1798 + // First, Legacy Mode has different semantics for backwards compatibility. The
1799 + // primary tree will commit in an inconsistent state, so when we do the
1800 + // second pass to render the fallback, we do some exceedingly, uh, clever
1801 + // hacks to make that not totally break. Like transferring effects and
1802 + // deletions from hidden tree. In Concurrent Mode, it's much simpler,
1803 + // because we bailout on the primary tree completely and leave it in its old
1804 + // state, no effects. Same as what we do for Offscreen (except that
1805 + // Offscreen doesn't have the first render pass).
1806 //
1808 - // However, we want to avoid allocating extra fibers for every placeholder.
1809 - // They're only necessary when the children time out, because that's the
1810 - // only time when both sets are mounted.
1807 + // Second is hydration. During hydration, the Suspense fiber has a slightly
1808 + // different layout, where the child points to a dehydrated fragment, which
1809 + // contains the DOM rendered by the server.
1810 //
1812 - // So, the extra fragment fibers are only used if the children time out.
1813 - // Otherwise, we render the primary children directly. This requires some
1814 - // custom reconciliation logic to preserve the state of the primary
1815 - // children. It's essentially a very basic form of re-parenting.
1816 -
1811 + // Third, even if you set all that aside, Suspense is like error boundaries in
1812 + // that we first we try to render one tree, and if that fails, we render again
1813 + // and switch to a different tree. Like a try/catch block. So we have to track
1814 + // which branch we're currently rendering. Ideally we would model this using
1815 + // a stack.
1816 if (current === null) {
1817 + // Initial mount
1818 // If we're currently hydrating, try to hydrate this boundary.
1819 // But only if this has a fallback.
1820 if (nextProps.fallback !== undefined) {
@@ -1836,64 +1836,33 @@ function updateSuspenseComponent(
1836 }
1837 }
1838
1839 - // This is the initial mount. This branch is pretty simple because there's
1840 - // no previous state that needs to be preserved.
1841 - if (nextDidTimeout) {
1842 - // Mount separate fragments for primary and fallback children.
1839 + if (showFallback) {
1840 const nextFallbackChildren = nextProps.fallback;
1844 - const primaryChildFragment = createFiberFromFragment(
1845 - null,
1846 - mode,
1847 - NoWork,
1848 - null,
1849 - );
1850 - primaryChildFragment.return = workInProgress;
1851 -
1852 - if ((workInProgress.mode & BlockingMode) === NoMode) {
1853 - // Outside of blocking mode, we commit the effects from the
1854 - // partially completed, timed-out tree, too.
1855 - const progressedState: SuspenseState = workInProgress.memoizedState;
1856 - const progressedPrimaryChild: Fiber | null =
1857 - progressedState !== null
1858 - ? (workInProgress.child: any).child
1859 - : (workInProgress.child: any);
1860 - primaryChildFragment.child = progressedPrimaryChild;
1861 - let progressedChild = progressedPrimaryChild;
1862 - while (progressedChild !== null) {
1863 - progressedChild.return = primaryChildFragment;
1864 - progressedChild = progressedChild.sibling;
1865 - }
1866 - }
1867 -
1868 - const fallbackChildFragment = createFiberFromFragment(
1841 + const fallbackFragment = mountSuspenseFallbackChildren(
1842 + workInProgress,
1843 nextFallbackChildren,
1870 - mode,
1844 renderExpirationTime,
1872 - null,
1845 );
1874 - fallbackChildFragment.return = workInProgress;
1875 - primaryChildFragment.sibling = fallbackChildFragment;
1876 - // Skip the primary children, and continue working on the
1877 - // fallback children.
1846 workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
1879 - workInProgress.child = primaryChildFragment;
1880 - return fallbackChildFragment;
1847 + return fallbackFragment;
1848 } else {
1882 - // Mount the primary children without an intermediate fragment fiber.
1849 const nextPrimaryChildren = nextProps.children;
1884 - workInProgress.memoizedState = null;
1885 - return (workInProgress.child = mountChildFibers(
1850 + return mountSuspensePrimaryChildren(
1851 workInProgress,
1887 - null,
1852 nextPrimaryChildren,
1853 renderExpirationTime,
1890 - ));
1854 + );
1855 }
1856 } else {
1893 - // This is an update. This branch is more complicated because we need to
1894 - // ensure the state of the primary children is preserved.
1857 + // This is an update.
1858 +
1859 + // If the current fiber has a SuspenseState, that means it's already showing
1860 + // a fallback.
1861 const prevState: null | SuspenseState = current.memoizedState;
1862 if (prevState !== null) {
1863 + // The current tree is already showing a fallback
1864 +
1865 + // Special path for hydration
1866 if (enableSuspenseServerRenderer) {
1867 const dehydrated = prevState.dehydrated;
1868 if (dehydrated !== null) {
@@ -1917,238 +1886,67 @@ function updateSuspenseComponent(
1886 return null;
1887 } else {
1888 // Suspended but we should no longer be in dehydrated mode.
1920 - // Therefore we now have to render the fallback. Wrap the children
1921 - // in a fragment fiber to keep them separate from the fallback
1922 - // children.
1889 + // Therefore we now have to render the fallback.
1890 const nextFallbackChildren = nextProps.fallback;
1924 - const primaryChildFragment = createFiberFromFragment(
1925 - // It shouldn't matter what the pending props are because we aren't
1926 - // going to render this fragment.
1927 - null,
1928 - mode,
1929 - (NoWork: ExpirationTimeOpaque),
1930 - null,
1931 - );
1932 - primaryChildFragment.return = workInProgress;
1933 -
1934 - // This is always null since we never want the previous child
1935 - // that we're not going to hydrate.
1936 - primaryChildFragment.child = null;
1937 -
1938 - if ((workInProgress.mode & BlockingMode) === NoMode) {
1939 - // Outside of blocking mode, we commit the effects from the
1940 - // partially completed, timed-out tree, too.
1941 - let progressedChild = (primaryChildFragment.child =
1942 - workInProgress.child);
1943 - while (progressedChild !== null) {
1944 - progressedChild.return = primaryChildFragment;
1945 - progressedChild = progressedChild.sibling;
1946 - }
1947 - } else {
1948 - // We will have dropped the effect list which contains the deletion.
1949 - // We need to reconcile to delete the current child.
1950 - reconcileChildFibers(
1951 - workInProgress,
1952 - current.child,
1953 - null,
1954 - renderExpirationTime,
1955 - );
1956 - }
1957 -
1958 - // Because primaryChildFragment is a new fiber that we're inserting as the
1959 - // parent of a new tree, we need to set its treeBaseDuration.
1960 - if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
1961 - // treeBaseDuration is the sum of all the child tree base durations.
1962 - let treeBaseDuration = 0;
1963 - let hiddenChild = primaryChildFragment.child;
1964 - while (hiddenChild !== null) {
1965 - treeBaseDuration += hiddenChild.treeBaseDuration;
1966 - hiddenChild = hiddenChild.sibling;
1967 - }
1968 - primaryChildFragment.treeBaseDuration = treeBaseDuration;
1969 - }
1970 -
1971 - // Create a fragment from the fallback children, too.
1972 - const fallbackChildFragment = createFiberFromFragment(
1973 - nextFallbackChildren,
1974 - mode,
1975 - renderExpirationTime,
1976 - null,
1977 - );
1978 - fallbackChildFragment.return = workInProgress;
1979 - primaryChildFragment.sibling = fallbackChildFragment;
1980 - fallbackChildFragment.effectTag |= Placement;
1981 - primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
1891 + const fallbackChildFragment = mountSuspenseFallbackAfterRetryWithoutHydrating(
1892 current,
1893 workInProgress,
1894 + nextFallbackChildren,
1895 renderExpirationTime,
1896 );
1897 +
1898 workInProgress.memoizedState = updateSuspenseState(
1899 current.memoizedState,
1900 renderExpirationTime,
1901 );
1990 - workInProgress.child = primaryChildFragment;
1902
1992 - // Skip the primary children, and continue working on the
1993 - // fallback children.
1903 return fallbackChildFragment;
1904 }
1905 }
1906 }
1998 - // The current tree already timed out. That means each child set is
1999 - // wrapped in a fragment fiber.
2000 - const currentPrimaryChildFragment: Fiber = (current.child: any);
2001 - const currentFallbackChildFragment: Fiber = (currentPrimaryChildFragment.sibling: any);
2002 - if (nextDidTimeout) {
2003 - // Still timed out. Reuse the current primary children by cloning
2004 - // its fragment. We're going to skip over these entirely.
2005 - const nextFallbackChildren = nextProps.fallback;
2006 - const primaryChildFragment = createWorkInProgress(
2007 - currentPrimaryChildFragment,
2008 - currentPrimaryChildFragment.pendingProps,
2009 - );
2010 - primaryChildFragment.return = workInProgress;
2011 -
2012 - if ((workInProgress.mode & BlockingMode) === NoMode) {
2013 - // Outside of blocking mode, we commit the effects from the
2014 - // partially completed, timed-out tree, too.
2015 - const progressedState: SuspenseState = workInProgress.memoizedState;
2016 - const progressedPrimaryChild: Fiber | null =
2017 - progressedState !== null
2018 - ? (workInProgress.child: any).child
2019 - : (workInProgress.child: any);
2020 - if (progressedPrimaryChild !== currentPrimaryChildFragment.child) {
2021 - primaryChildFragment.child = progressedPrimaryChild;
2022 - let progressedChild = progressedPrimaryChild;
2023 - while (progressedChild !== null) {
2024 - progressedChild.return = primaryChildFragment;
2025 - progressedChild = progressedChild.sibling;
2026 - }
2027 - }
2028 - }
2029 -
2030 - // Because primaryChildFragment is a new fiber that we're inserting as the
2031 - // parent of a new tree, we need to set its treeBaseDuration.
2032 - if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
2033 - // treeBaseDuration is the sum of all the child tree base durations.
2034 - let treeBaseDuration = 0;
2035 - let hiddenChild = primaryChildFragment.child;
2036 - while (hiddenChild !== null) {
2037 - treeBaseDuration += hiddenChild.treeBaseDuration;
2038 - hiddenChild = hiddenChild.sibling;
2039 - }
2040 - primaryChildFragment.treeBaseDuration = treeBaseDuration;
2041 - }
1907
2043 - // Clone the fallback child fragment, too. These we'll continue
2044 - // working on.
2045 - const fallbackChildFragment = createWorkInProgress(
2046 - currentFallbackChildFragment,
1908 + if (showFallback) {
1909 + const nextFallbackChildren = nextProps.fallback;
1910 + const fallbackChildFragment = updateSuspenseFallbackChildren(
1911 + current,
1912 + workInProgress,
1913 nextFallbackChildren,
1914 + renderExpirationTime,
1915 );
2049 - fallbackChildFragment.return = workInProgress;
2050 - primaryChildFragment.sibling = fallbackChildFragment;
1916 + const primaryChildFragment: Fiber = (workInProgress.child: any);
1917 primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
1918 current,
1919 workInProgress,
1920 renderExpirationTime,
1921 );
2056 - // Skip the primary children, and continue working on the
2057 - // fallback children.
1922 workInProgress.memoizedState = updateSuspenseState(
1923 current.memoizedState,
1924 renderExpirationTime,
1925 );
2062 - workInProgress.child = primaryChildFragment;
1926 return fallbackChildFragment;
1927 } else {
2065 - // No longer suspended. Switch back to showing the primary children,
2066 - // and remove the intermediate fragment fiber.
1928 const nextPrimaryChildren = nextProps.children;
2068 - const currentPrimaryChild = currentPrimaryChildFragment.child;
2069 - const primaryChild = reconcileChildFibers(
1929 + const primaryChildFragment = updateSuspensePrimaryChildren(
1930 + current,
1931 workInProgress,
2071 - currentPrimaryChild,
1932 nextPrimaryChildren,
1933 renderExpirationTime,
1934 );
2075 -
2076 - // If this render doesn't suspend, we need to delete the fallback
2077 - // children. Wait until the complete phase, after we've confirmed the
2078 - // fallback is no longer needed.
2079 - // TODO: Would it be better to store the fallback fragment on
2080 - // the stateNode?
2081 -
2082 - // Continue rendering the children, like we normally do.
1935 workInProgress.memoizedState = null;
2084 - return (workInProgress.child = primaryChild);
1936 + return primaryChildFragment;
1937 }
1938 } else {
2087 - // The current tree has not already timed out. That means the primary
2088 - // children are not wrapped in a fragment fiber.
2089 - const currentPrimaryChild = current.child;
2090 - if (nextDidTimeout) {
2091 - // Timed out. Wrap the children in a fragment fiber to keep them
2092 - // separate from the fallback children.
1939 + // The current tree is not already showing a fallback.
1940 + if (showFallback) {
1941 + // Timed out.
1942 const nextFallbackChildren = nextProps.fallback;
2094 - const primaryChildFragment = createFiberFromFragment(
2095 - // It shouldn't matter what the pending props are because we aren't
2096 - // going to render this fragment.
2097 - null,
2098 - mode,
2099 - NoWork,
2100 - null,
2101 - );
2102 - primaryChildFragment.return = workInProgress;
2103 - primaryChildFragment.child = currentPrimaryChild;
2104 - if (currentPrimaryChild !== null) {
2105 - currentPrimaryChild.return = primaryChildFragment;
2106 - }
2107 -
2108 - // Even though we're creating a new fiber, there are no new children,
2109 - // because we're reusing an already mounted tree. So we don't need to
2110 - // schedule a placement.
2111 - // primaryChildFragment.effectTag |= Placement;
2112 -
2113 - if ((workInProgress.mode & BlockingMode) === NoMode) {
2114 - // Outside of blocking mode, we commit the effects from the
2115 - // partially completed, timed-out tree, too.
2116 - const progressedState: SuspenseState = workInProgress.memoizedState;
2117 - const progressedPrimaryChild: Fiber | null =
2118 - progressedState !== null
2119 - ? (workInProgress.child: any).child
2120 - : (workInProgress.child: any);
2121 - primaryChildFragment.child = progressedPrimaryChild;
2122 - let progressedChild = progressedPrimaryChild;
2123 - while (progressedChild !== null) {
2124 - progressedChild.return = primaryChildFragment;
2125 - progressedChild = progressedChild.sibling;
2126 - }
2127 - }
2128 -
2129 - // Because primaryChildFragment is a new fiber that we're inserting as the
2130 - // parent of a new tree, we need to set its treeBaseDuration.
2131 - if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
2132 - // treeBaseDuration is the sum of all the child tree base durations.
2133 - let treeBaseDuration = 0;
2134 - let hiddenChild = primaryChildFragment.child;
2135 - while (hiddenChild !== null) {
2136 - treeBaseDuration += hiddenChild.treeBaseDuration;
2137 - hiddenChild = hiddenChild.sibling;
2138 - }
2139 - primaryChildFragment.treeBaseDuration = treeBaseDuration;
2140 - }
2141 -
2142 - // Create a fragment from the fallback children, too.
2143 - const fallbackChildFragment = createFiberFromFragment(
1943 + const fallbackChildFragment = updateSuspenseFallbackChildren(
1944 + current,
1945 + workInProgress,
1946 nextFallbackChildren,
2145 - mode,
1947 renderExpirationTime,
2147 - null,
1948 );
2149 - fallbackChildFragment.return = workInProgress;
2150 - primaryChildFragment.sibling = fallbackChildFragment;
2151 - fallbackChildFragment.effectTag |= Placement;
1949 + const primaryChildFragment: Fiber = (workInProgress.child: any);
1950 primaryChildFragment.childExpirationTime_opaque = getRemainingWorkInPrimaryTree(
1951 current,
1952 workInProgress,
@@ -2157,44 +1955,270 @@ function updateSuspenseComponent(
1955 // Skip the primary children, and continue working on the
1956 // fallback children.
1957 workInProgress.memoizedState = mountSuspenseState(renderExpirationTime);
2160 - workInProgress.child = primaryChildFragment;
1958 return fallbackChildFragment;
1959 } else {
1960 // Still haven't timed out. Continue rendering the children, like we
1961 // normally do.
2165 - workInProgress.memoizedState = null;
1962 const nextPrimaryChildren = nextProps.children;
2167 - return (workInProgress.child = reconcileChildFibers(
1963 + const primaryChildFragment = updateSuspensePrimaryChildren(
1964 + current,
1965 workInProgress,
2169 - currentPrimaryChild,
1966 nextPrimaryChildren,
1967 renderExpirationTime,
2172 - ));
1968 + );
1969 + workInProgress.memoizedState = null;
1970 + return primaryChildFragment;
1971 }
1972 }
1973 }
1974 }
1975
1976 +function mountSuspensePrimaryChildren(
1977 + workInProgress,
1978 + primaryChildren,
1979 + renderExpirationTime,
1980 +) {
1981 + const mode = workInProgress.mode;
1982 + const primaryChildFragment = createFiberFromFragment(
1983 + primaryChildren,
1984 + mode,
1985 + renderExpirationTime,
1986 + null,
1987 + );
1988 + primaryChildFragment.return = workInProgress;
1989 + workInProgress.child = primaryChildFragment;
1990 + return primaryChildFragment;
1991 +}
1992 +
1993 +function mountSuspenseFallbackChildren(
1994 + workInProgress,
1995 + fallbackChildren,
1996 + renderExpirationTime,
1997 +) {
1998 + const mode = workInProgress.mode;
1999 +
2000 + const progressedPrimaryFragment: Fiber | null = workInProgress.child;
2001 +
2002 + let primaryChildFragment;
2003 + let fallbackChildFragment;
2004 + if ((mode & BlockingMode) === NoMode && progressedPrimaryFragment !== null) {
2005 + // In legacy mode, we commit the primary tree as if it successfully
2006 + // completed, even though it's in an inconsistent state.
2007 + primaryChildFragment = progressedPrimaryFragment;
2008 + primaryChildFragment.childExpirationTime_opaque = NoWork;
2009 +
2010 + if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
2011 + // Reset the durations from the first pass so they aren't included in the
2012 + // final amounts. This seems counterintuitive, since we're intentionally
2013 + // not measuring part of the render phase, but this makes it match what we
2014 + // do in Concurrent Mode.
2015 + primaryChildFragment.actualDuration = 0;
2016 + primaryChildFragment.actualStartTime = -1;
2017 + primaryChildFragment.selfBaseDuration = 0;
2018 + primaryChildFragment.treeBaseDuration = 0;
2019 + }
2020 +
2021 + fallbackChildFragment = createFiberFromFragment(
2022 + fallbackChildren,
2023 + mode,
2024 + renderExpirationTime,
2025 + null,
2026 + );
2027 + } else {
2028 + primaryChildFragment = createFiberFromFragment(null, mode, NoWork, null);
2029 + fallbackChildFragment = createFiberFromFragment(
2030 + fallbackChildren,
2031 + mode,
2032 + renderExpirationTime,
2033 + null,
2034 + );
2035 + }
2036 +
2037 + primaryChildFragment.return = workInProgress;
2038 + fallbackChildFragment.return = workInProgress;
2039 + primaryChildFragment.sibling = fallbackChildFragment;
2040 + workInProgress.child = primaryChildFragment;
2041 + return fallbackChildFragment;
2042 +}
2043 +
2044 +function updateSuspensePrimaryChildren(
2045 + current,
2046 + workInProgress,
2047 + primaryChildren,
2048 + renderExpirationTime,
2049 +) {
2050 + const currentPrimaryChildFragment: Fiber = (current.child: any);
2051 + const currentFallbackChildFragment: Fiber | null =
2052 + currentPrimaryChildFragment.sibling;
2053 +
2054 + const primaryChildFragment = createWorkInProgress(
2055 + currentPrimaryChildFragment,
2056 + primaryChildren,
2057 + );
2058 + if ((workInProgress.mode & BlockingMode) === NoMode) {
2059 + primaryChildFragment.expirationTime_opaque = renderExpirationTime;
2060 + }
2061 + primaryChildFragment.return = workInProgress;
2062 + primaryChildFragment.sibling = null;
2063 + if (currentFallbackChildFragment !== null) {
2064 + // Delete the fallback child fragment
2065 + currentFallbackChildFragment.nextEffect = null;
2066 + currentFallbackChildFragment.effectTag = Deletion;
2067 + workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChildFragment;
2068 + }
2069 +
2070 + workInProgress.child = primaryChildFragment;
2071 + return primaryChildFragment;
2072 +}
2073 +
2074 +function updateSuspenseFallbackChildren(
2075 + current,
2076 + workInProgress,
2077 + fallbackChildren,
2078 + renderExpirationTime,
2079 +) {
2080 + const mode = workInProgress.mode;
2081 + const currentPrimaryChildFragment: Fiber = (current.child: any);
2082 + const currentFallbackChildFragment: Fiber | null =
2083 + currentPrimaryChildFragment.sibling;
2084 +
2085 + let primaryChildFragment;
2086 + if ((mode & BlockingMode) === NoMode) {
2087 + // In legacy mode, we commit the primary tree as if it successfully
2088 + // completed, even though it's in an inconsistent state.
2089 + const progressedPrimaryFragment: Fiber = (workInProgress.child: any);
2090 + primaryChildFragment = progressedPrimaryFragment;
2091 + primaryChildFragment.childExpirationTime_opaque = NoWork;
2092 +
2093 + if (enableProfilerTimer && workInProgress.mode & ProfileMode) {
2094 + // Reset the durations from the first pass so they aren't included in the
2095 + // final amounts. This seems counterintuitive, since we're intentionally
2096 + // not measuring part of the render phase, but this makes it match what we
2097 + // do in Concurrent Mode.
2098 + primaryChildFragment.actualDuration = 0;
2099 + primaryChildFragment.actualStartTime = -1;
2100 + primaryChildFragment.selfBaseDuration =
2101 + currentPrimaryChildFragment.selfBaseDuration;
2102 + primaryChildFragment.treeBaseDuration =
2103 + currentPrimaryChildFragment.treeBaseDuration;
2104 + }
2105 +
2106 + // The fallback fiber was added as a deletion effect during the first pass.
2107 + // However, since we're going to remain on the fallback, we no longer want
2108 + // to delete it. So we need to remove it from the list. Deletions are stored
2109 + // on the same list as effects. We want to keep the effects from the primary
2110 + // tree. So we copy the primary child fragment's effect list, which does not
2111 + // include the fallback deletion effect.
2112 + const progressedLastEffect = primaryChildFragment.lastEffect;
2113 + if (progressedLastEffect !== null) {
2114 + workInProgress.firstEffect = primaryChildFragment.firstEffect;
2115 + workInProgress.lastEffect = progressedLastEffect;
2116 + progressedLastEffect.nextEffect = null;
2117 + } else {
2118 + // TODO: Reset this somewhere else? Lol legacy mode is so weird.
2119 + workInProgress.firstEffect = workInProgress.lastEffect = null;
2120 + }
2121 + } else {
2122 + primaryChildFragment = createWorkInProgress(
2123 + currentPrimaryChildFragment,
2124 + currentPrimaryChildFragment.pendingProps,
2125 + );
2126 + }
2127 + let fallbackChildFragment;
2128 + if (currentFallbackChildFragment !== null) {
2129 + fallbackChildFragment = createWorkInProgress(
2130 + currentFallbackChildFragment,
2131 + fallbackChildren,
2132 + );
2133 + } else {
2134 + fallbackChildFragment = createFiberFromFragment(
2135 + fallbackChildren,
2136 + mode,
2137 + renderExpirationTime,
2138 + null,
2139 + );
2140 + // Needs a placement effect because the parent (the Suspense boundary) already
2141 + // mounted but this is a new fiber.
2142 + fallbackChildFragment.effectTag |= Placement;
2143 + }
2144 +
2145 + fallbackChildFragment.return = workInProgress;
2146 + primaryChildFragment.return = workInProgress;
2147 + primaryChildFragment.sibling = fallbackChildFragment;
2148 + workInProgress.child = primaryChildFragment;
2149 +
2150 + return fallbackChildFragment;
2151 +}
2152 +
2153 function retrySuspenseComponentWithoutHydrating(
2154 current: Fiber,
2155 workInProgress: Fiber,
2156 renderExpirationTime: ExpirationTimeOpaque,
2157 ) {
2158 + // This will add the old fiber to the deletion list
2159 + reconcileChildFibers(
2160 + workInProgress,
2161 + current.child,
2162 + null,
2163 + renderExpirationTime,
2164 + );
2165 +
2166 // We're now not suspended nor dehydrated.
2184 - workInProgress.memoizedState = null;
2185 - // Retry with the full children.
2167 const nextProps = workInProgress.pendingProps;
2187 - const nextChildren = nextProps.children;
2188 - // This will ensure that the children get Placement effects and
2189 - // that the old child gets a Deletion effect.
2190 - // We could also call forceUnmountCurrentAndReconcile.
2191 - reconcileChildren(
2192 - current,
2168 + const primaryChildren = nextProps.children;
2169 + const primaryChildFragment = mountSuspensePrimaryChildren(
2170 workInProgress,
2194 - nextChildren,
2171 + primaryChildren,
2172 renderExpirationTime,
2173 );
2197 - return workInProgress.child;
2174 + // Needs a placement effect because the parent (the Suspense boundary) already
2175 + // mounted but this is a new fiber.
2176 + primaryChildFragment.effectTag |= Placement;
2177 + workInProgress.memoizedState = null;
2178 +
2179 + return primaryChildFragment;
2180 +}
2181 +
2182 +function mountSuspenseFallbackAfterRetryWithoutHydrating(
2183 + current,
2184 + workInProgress,
2185 + fallbackChildren,
2186 + renderExpirationTime,
2187 +) {
2188 + const mode = workInProgress.mode;
2189 + const primaryChildFragment = createFiberFromFragment(
2190 + null,
2191 + mode,
2192 + NoWork,
2193 + null,
2194 + );
2195 + const fallbackChildFragment = createFiberFromFragment(
2196 + fallbackChildren,
2197 + mode,
2198 + renderExpirationTime,
2199 + null,
2200 + );
2201 + // Needs a placement effect because the parent (the Suspense
2202 + // boundary) already mounted but this is a new fiber.
2203 + fallbackChildFragment.effectTag |= Placement;
2204 +
2205 + primaryChildFragment.return = workInProgress;
2206 + fallbackChildFragment.return = workInProgress;
2207 + primaryChildFragment.sibling = fallbackChildFragment;
2208 + workInProgress.child = primaryChildFragment;
2209 +
2210 + if ((workInProgress.mode & BlockingMode) !== NoMode) {
2211 + // We will have dropped the effect list which contains the
2212 + // deletion. We need to reconcile to delete the current child.
2213 + reconcileChildFibers(
2214 + workInProgress,
2215 + current.child,
2216 + null,
2217 + renderExpirationTime,
2218 + );
2219 + }
2220 +
2221 + return fallbackChildFragment;
2222 }
2223
2224 function mountDehydratedSuspenseComponent(
@@ -2346,26 +2370,20 @@ function updateDehydratedSuspenseComponent(
2370 suspenseInstance,
2371 );
2372 const nextProps = workInProgress.pendingProps;
2349 - const nextChildren = nextProps.children;
2350 - const child = mountChildFibers(
2373 + const primaryChildren = nextProps.children;
2374 + const primaryChildFragment = mountSuspensePrimaryChildren(
2375 workInProgress,
2352 - null,
2353 - nextChildren,
2376 + primaryChildren,
2377 renderExpirationTime,
2378 );
2356 - let node = child;
2357 - while (node) {
2358 - // Mark each child as hydrating. This is a fast path to know whether this
2359 - // tree is part of a hydrating tree. This is used to determine if a child
2360 - // node has fully mounted yet, and for scheduling event replaying.
2361 - // Conceptually this is similar to Placement in that a new subtree is
2362 - // inserted into the React tree here. It just happens to not need DOM
2363 - // mutations because it already exists.
2364 - node.effectTag |= Hydrating;
2365 - node = node.sibling;
2366 - }
2367 - workInProgress.child = child;
2368 - return workInProgress.child;
2379 + // Mark the children as hydrating. This is a fast path to know whether this
2380 + // tree is part of a hydrating tree. This is used to determine if a child
2381 + // node has fully mounted yet, and for scheduling event replaying.
2382 + // Conceptually this is similar to Placement in that a new subtree is
2383 + // inserted into the React tree here. It just happens to not need DOM
2384 + // mutations because it already exists.
2385 + primaryChildFragment.effectTag |= Hydrating;
2386 + return primaryChildFragment;
2387 }
2388 }
2389
@@ -2687,7 +2705,7 @@ function updateSuspenseListComponent(
2705 pushSuspenseContext(workInProgress, suspenseContext);
2706
2707 if ((workInProgress.mode & BlockingMode) === NoMode) {
2690 - // Outside of blocking mode, SuspenseList doesn't work so we just
2708 + // In legacy mode, SuspenseList doesn't work so we just
2709 // use make it a noop by treating it as the default revealOrder.
2710 workInProgress.memoizedState = null;
2711 } else {
@@ -3200,46 +3218,7 @@ function beginWork(
3218 );
3219 } else {
3220 // The primary child fragment does not have pending work marked
3203 - // on it...
3204 -
3205 - // ...usually. There's an unfortunate edge case where the fragment
3206 - // fiber is not part of the return path of the children, so when
3207 - // an update happens, the fragment doesn't get marked during
3208 - // setState. This is something we should consider addressing when
3209 - // we refactor the Fiber data structure. (There's a test with more
3210 - // details; to find it, comment out the following block and see
3211 - // which one fails.)
3212 - //
3213 - // As a workaround, we need to recompute the `childExpirationTime`
3214 - // by bubbling it up from the next level of children. This is
3215 - // based on similar logic in `resetChildExpirationTime`.
3216 - let primaryChild = primaryChildFragment.child;
3217 - while (primaryChild !== null) {
3218 - const childUpdateExpirationTime =
3219 - primaryChild.expirationTime_opaque;
3220 - const childChildExpirationTime =
3221 - primaryChild.childExpirationTime_opaque;
3222 - if (
3223 - isSameOrHigherPriority(
3224 - childUpdateExpirationTime,
3225 - renderExpirationTime,
3226 - ) ||
3227 - isSameOrHigherPriority(
3228 - childChildExpirationTime,
3229 - renderExpirationTime,
3230 - )
3231 - ) {
3232 - // Found a child with an update with sufficient priority.
3233 - // Use the normal path to render the primary children again.
3234 - return updateSuspenseComponent(
3235 - current,
3236 - workInProgress,
3237 - renderExpirationTime,
3238 - );
3239 - }
3240 - primaryChild = primaryChild.sibling;
3241 - }
3242 -
3221 + // on it
3222 pushSuspenseContext(
3223 workInProgress,
3224 setDefaultShallowSuspenseContext(suspenseStackCursor.current),
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+1 -27
@@ -55,13 +55,7 @@ import {
55 Block,
56 } from './ReactWorkTags';
57 import {NoMode, BlockingMode} from './ReactTypeOfMode';
58 -import {
59 - Ref,
60 - Update,
61 - NoEffect,
62 - DidCapture,
63 - Deletion,
64 -} from './ReactSideEffectTags';
58 +import {Ref, Update, NoEffect, DidCapture} from './ReactSideEffectTags';
59 import invariant from 'shared/invariant';
60
61 import {
@@ -888,26 +882,6 @@ function completeWork(
882 } else {
883 const prevState: null | SuspenseState = current.memoizedState;
884 prevDidTimeout = prevState !== null;
891 - if (!nextDidTimeout && prevState !== null) {
892 - // We just switched from the fallback to the normal children.
893 - // Delete the fallback.
894 - // TODO: Would it be better to store the fallback fragment on
895 - // the stateNode during the begin phase?
896 - const currentFallbackChild: Fiber | null = (current.child: any)
897 - .sibling;
898 - if (currentFallbackChild !== null) {
899 - // Deletions go at the beginning of the return fiber's effect list
900 - const first = workInProgress.firstEffect;
901 - if (first !== null) {
902 - workInProgress.firstEffect = currentFallbackChild;
903 - currentFallbackChild.nextEffect = first;
904 - } else {
905 - workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild;
906 - currentFallbackChild.nextEffect = null;
907 - }
908 - currentFallbackChild.effectTag = Deletion;
909 - }
910 - }
885 }
886
887 if (nextDidTimeout && !prevDidTimeout) {
packages/react-reconciler/src/__tests__/ReactSuspenseList-test.js
+15 -7
@@ -292,13 +292,21 @@ describe('ReactSuspenseList', () => {
292
293 await C.resolve();
294
295 - expect(Scheduler).toFlushAndYield([
296 - // TODO: Ideally we wouldn't have to retry B. This is an implementation
297 - // trade off.
298 - 'Suspend! [B]',
299 -
300 - 'C',
301 - ]);
295 + expect(Scheduler).toFlushAndYield(
296 + gate(flags =>
297 + flags.new
298 + ? ['C']
299 + : [
300 + // Note: Old reconciler has an issue where the primary fragment
301 + // fiber isn't marked during setState, so as a compromise we
302 + // sometimes over-render the primary child even when it hasn't
303 + // been updated.
304 + 'Suspend! [B]',
305 +
306 + 'C',
307 + ],
308 + ),
309 + );
310
311 expect(ReactNoop).toMatchRenderedOutput(
312 <>
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js
+28 -9
@@ -403,10 +403,21 @@ describe('ReactSuspensePlaceholder', () => {
403 expect(onRender).toHaveBeenCalledTimes(2);
404
405 // The suspense update should only show the "Loading..." Fallback.
406 - // Both durations should include 10ms spent rendering Fallback
407 - // plus the 8ms rendering the (hidden) components.
408 - expect(onRender.mock.calls[1][2]).toBe(18);
409 - expect(onRender.mock.calls[1][3]).toBe(18);
406 + // The actual duration should include 10ms spent rendering Fallback,
407 + // plus the 8ms render all of the hidden, suspended subtree.
408 + // Note from Andrew to Brian: I don't fully understand why this one
409 + // diverges, but I checked and it matches the times we get when
410 + // we run this same test in Concurrent Mode.
411 + if (gate(flags => flags.new)) {
412 + // But the tree base duration should only include 10ms spent rendering Fallback,
413 + // plus the 5ms rendering the previously committed version of the hidden tree.
414 + expect(onRender.mock.calls[1][2]).toBe(18);
415 + expect(onRender.mock.calls[1][3]).toBe(15);
416 + } else {
417 + // Old behavior includes the time spent on the primary tree.
418 + expect(onRender.mock.calls[1][2]).toBe(18);
419 + expect(onRender.mock.calls[1][3]).toBe(18);
420 + }
421
422 ReactNoop.renderLegacySyncRoot(
423 <App shouldSuspend={true} text="New" textRenderDuration={6} />,
@@ -421,11 +432,19 @@ describe('ReactSuspensePlaceholder', () => {
432 expect(ReactNoop).toMatchRenderedOutput('Loading...');
433 expect(onRender).toHaveBeenCalledTimes(3);
434
424 - // If we force another update while still timed out,
425 - // but this time the Text component took 1ms longer to render.
426 - // This should impact both actualDuration and treeBaseDuration.
427 - expect(onRender.mock.calls[2][2]).toBe(19);
428 - expect(onRender.mock.calls[2][3]).toBe(19);
435 + // Note from Andrew to Brian: I don't fully understand why this one
436 + // diverges, but I checked and it matches the times we get when
437 + // we run this same test in Concurrent Mode.
438 + if (gate(flags => flags.new)) {
439 + expect(onRender.mock.calls[1][2]).toBe(18);
440 + expect(onRender.mock.calls[1][3]).toBe(15);
441 + } else {
442 + // If we force another update while still timed out,
443 + // but this time the Text component took 1ms longer to render.
444 + // This should impact both actualDuration and treeBaseDuration.
445 + expect(onRender.mock.calls[2][2]).toBe(19);
446 + expect(onRender.mock.calls[2][3]).toBe(19);
447 + }
448
449 jest.advanceTimersByTime(1000);
450
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+22 -7
@@ -1343,13 +1343,28 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1343
1344 ReactNoop.renderLegacySyncRoot(<Demo />);
1345
1346 - expect(Scheduler).toHaveYielded([
1347 - 'Suspend! [Hi]',
1348 - 'Loading...',
1349 - // Re-render due to lifecycle update
1350 - 'Suspend! [Hi]',
1351 - 'Loading...',
1352 - ]);
1346 + expect(Scheduler).toHaveYielded(
1347 + gate(flags =>
1348 + flags.new
1349 + ? [
1350 + 'Suspend! [Hi]',
1351 + 'Loading...',
1352 + // Re-render due to lifecycle update
1353 + 'Loading...',
1354 + ]
1355 + : [
1356 + 'Suspend! [Hi]',
1357 + 'Loading...',
1358 + // Re-render due to lifecycle update
1359 + // Note: Old reconciler has an issue where the primary fragment
1360 + // fiber isn't marked during setState, so as a compromise we
1361 + // sometimes over-render the primary child even when it hasn't
1362 + // been updated.
1363 + 'Suspend! [Hi]',
1364 + 'Loading...',
1365 + ],
1366 + ),
1367 + );
1368 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1369 await advanceTimers(100);
1370 expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);