diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 4215cade8f87b..7d0e37491a217 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -126,6 +126,7 @@ import { setShallowSuspenseListContext, ForceSuspenseFallback, setDefaultShallowSuspenseListContext, + isBadSuspenseFallback, } from './ReactFiberSuspenseContext'; import {popHiddenContext} from './ReactFiberHiddenContext'; import {findFirstSuspended} from './ReactFiberSuspenseComponent'; @@ -147,6 +148,8 @@ import { upgradeHydrationErrorsToRecoverable, } from './ReactFiberHydrationContext'; import { + renderDidSuspend, + renderDidSuspendDelayIfPossible, renderHasNotSuspendedYet, getRenderTargetTime, getWorkInProgressTransitions, @@ -1224,6 +1227,24 @@ function completeWork( if (nextDidTimeout) { const offscreenFiber: Fiber = (workInProgress.child: any); offscreenFiber.flags |= Visibility; + + // TODO: This will still suspend a synchronous tree if anything + // in the concurrent tree already suspended during this render. + // This is a known bug. + if ((workInProgress.mode & ConcurrentMode) !== NoMode) { + // TODO: Move this back to throwException because this is too late + // if this is a large tree which is common for initial loads. We + // don't know if we should restart a render or not until we get + // this marker, and this is too late. + // If this render already had a ping or lower pri updates, + // and this is the first time we know we're going to suspend we + // should be able to immediately restart from within throwException. + if (isBadSuspenseFallback(current, newProps)) { + renderDidSuspendDelayIfPossible(); + } else { + renderDidSuspend(); + } + } } } diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.js index a64c8e90843b7..dbf0d340b0444 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.js @@ -9,85 +9,93 @@ import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack'; -import type {SuspenseProps, SuspenseState} from './ReactFiberSuspenseComponent'; -import type {OffscreenState} from './ReactFiberOffscreenComponent'; +import type {SuspenseState, SuspenseProps} from './ReactFiberSuspenseComponent'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack'; import {isCurrentTreeHidden} from './ReactFiberHiddenContext'; -import {OffscreenComponent} from './ReactWorkTags'; +import {SuspenseComponent, OffscreenComponent} from './ReactWorkTags'; // The Suspense handler is the boundary that should capture if something // suspends, i.e. it's the nearest `catch` block on the stack. const suspenseHandlerStackCursor: StackCursor = createCursor(null); -// Represents the outermost boundary that is not visible in the current tree. -// Everything above this is the "shell". When this is null, it means we're -// rendering in the shell of the app. If it's non-null, it means we're rendering -// deeper than the shell, inside a new tree that wasn't already visible. -// -// The main way we use this concept is to determine whether showing a fallback -// would result in a desirable or undesirable loading state. Activing a fallback -// in the shell is considered an undersirable loading state, because it would -// mean hiding visible (albeit stale) content in the current tree — we prefer to -// show the stale content, rather than switch to a fallback. But showing a -// fallback in a new tree is fine, because there's no stale content to -// prefer instead. -let shellBoundary: Fiber | null = null; - -export function getShellBoundary(): Fiber | null { - return shellBoundary; +function shouldAvoidedBoundaryCapture( + workInProgress: Fiber, + handlerOnStack: Fiber, + props: any, +): boolean { + if (enableSuspenseAvoidThisFallback) { + // If the parent is already showing content, and we're not inside a hidden + // tree, then we should show the avoided fallback. + if (handlerOnStack.alternate !== null && !isCurrentTreeHidden()) { + return true; + } + + // If the handler on the stack is also an avoided boundary, then we should + // favor this inner one. + if ( + handlerOnStack.tag === SuspenseComponent && + handlerOnStack.memoizedProps.unstable_avoidThisFallback === true + ) { + return true; + } + + // If this avoided boundary is dehydrated, then it should capture. + const suspenseState: SuspenseState | null = workInProgress.memoizedState; + if (suspenseState !== null && suspenseState.dehydrated !== null) { + return true; + } + } + + // If none of those cases apply, then we should avoid this fallback and show + // the outer one instead. + return false; } -export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { - // TODO: Pass as argument - const current = handler.alternate; - const props: SuspenseProps = handler.pendingProps; - - // Experimental feature: Some Suspense boundaries are marked as having an - // undesirable fallback state. These have special behavior where we only - // activate the fallback if there's no other boundary on the stack that we can - // use instead. +export function isBadSuspenseFallback( + current: Fiber | null, + nextProps: SuspenseProps, +): boolean { + // Check if this is a "bad" fallback state or a good one. A bad fallback state + // is one that we only show as a last resort; if this is a transition, we'll + // block it from displaying, and wait for more data to arrive. + if (current !== null) { + const prevState: SuspenseState = current.memoizedState; + const isShowingFallback = prevState !== null; + if (!isShowingFallback && !isCurrentTreeHidden()) { + // It's bad to switch to a fallback if content is already visible + return true; + } + } + if ( enableSuspenseAvoidThisFallback && - props.unstable_avoidThisFallback === true && - // If an avoided boundary is already visible, it behaves identically to - // a regular Suspense boundary. - (current === null || isCurrentTreeHidden()) + nextProps.unstable_avoidThisFallback === true ) { - if (shellBoundary === null) { - // We're rendering in the shell. There's no parent Suspense boundary that - // can provide a desirable fallback state. We'll use this boundary. - push(suspenseHandlerStackCursor, handler, handler); - - // However, because this is not a desirable fallback, the children are - // still considered part of the shell. So we intentionally don't assign - // to `shellBoundary`. - } else { - // There's already a parent Suspense boundary that can provide a desirable - // fallback state. Prefer that one. - const handlerOnStack = suspenseHandlerStackCursor.current; - push(suspenseHandlerStackCursor, handlerOnStack, handler); - } - return; + // Experimental: Some fallbacks are always bad + return true; } - // TODO: If the parent Suspense handler already suspended, there's no reason - // to push a nested Suspense handler, because it will get replaced by the - // outer fallback, anyway. Consider this as a future optimization. - push(suspenseHandlerStackCursor, handler, handler); - if (shellBoundary === null) { - if (current === null || isCurrentTreeHidden()) { - // This boundary is not visible in the current UI. - shellBoundary = handler; - } else { - const prevState: SuspenseState = current.memoizedState; - if (prevState !== null) { - // This boundary is showing a fallback in the current UI. - shellBoundary = handler; - } - } + return false; +} + +export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void { + const props = handler.pendingProps; + const handlerOnStack = suspenseHandlerStackCursor.current; + if ( + enableSuspenseAvoidThisFallback && + props.unstable_avoidThisFallback === true && + handlerOnStack !== null && + !shouldAvoidedBoundaryCapture(handler, handlerOnStack, props) + ) { + // This boundary should not capture if something suspends. Reuse the + // existing handler on the stack. + push(suspenseHandlerStackCursor, handlerOnStack, handler); + } else { + // Push this handler onto the stack. + push(suspenseHandlerStackCursor, handler, handler); } } @@ -101,20 +109,6 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void { export function pushOffscreenSuspenseHandler(fiber: Fiber): void { if (fiber.tag === OffscreenComponent) { push(suspenseHandlerStackCursor, fiber, fiber); - if (shellBoundary !== null) { - // A parent boundary is showing a fallback, so we've already rendered - // deeper than the shell. - } else { - const current = fiber.alternate; - if (current !== null) { - const prevState: OffscreenState = current.memoizedState; - if (prevState !== null) { - // This is the first boundary in the stack that's already showing - // a fallback. So everything outside is considered the shell. - shellBoundary = fiber; - } - } - } } else { // This is a LegacyHidden component. reuseSuspenseHandlerOnStack(fiber); @@ -131,10 +125,6 @@ export function getSuspenseHandler(): Fiber | null { export function popSuspenseHandler(fiber: Fiber): void { pop(suspenseHandlerStackCursor, fiber); - if (shellBoundary === fiber) { - // Popping back into the shell. - shellBoundary = null; - } } // SuspenseList context diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index 5d82b8dbe7e97..5741ef5bfa3a9 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -49,10 +49,7 @@ import { enqueueUpdate, } from './ReactFiberClassUpdateQueue'; import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading'; -import { - getShellBoundary, - getSuspenseHandler, -} from './ReactFiberSuspenseContext'; +import {getSuspenseHandler} from './ReactFiberSuspenseContext'; import { renderDidError, renderDidSuspendDelayIfPossible, @@ -61,7 +58,6 @@ import { isAlreadyFailedLegacyErrorBoundary, attachPingListener, restorePendingUpdaters, - renderDidSuspend, } from './ReactFiberWorkLoop'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext'; import {logCapturedError} from './ReactFiberErrorLogger'; @@ -353,46 +349,11 @@ function throwException( } } - // Mark the nearest Suspense boundary to switch to rendering a fallback. + // Schedule the nearest Suspense to re-render the timed out view. const suspenseBoundary = getSuspenseHandler(); if (suspenseBoundary !== null) { switch (suspenseBoundary.tag) { case SuspenseComponent: { - // If this suspense boundary is not already showing a fallback, mark - // the in-progress render as suspended. We try to perform this logic - // as soon as soon as possible during the render phase, so the work - // loop can know things like whether it's OK to switch to other tasks, - // or whether it can wait for data to resolve before continuing. - // TODO: Most of these checks are already performed when entering a - // Suspense boundary. We should track the information on the stack so - // we don't have to recompute it on demand. This would also allow us - // to unify with `use` which needs to perform this logic even sooner, - // before `throwException` is called. - if (sourceFiber.mode & ConcurrentMode) { - if (getShellBoundary() === null) { - // Suspended in the "shell" of the app. This is an undesirable - // loading state. We should avoid committing this tree. - renderDidSuspendDelayIfPossible(); - } else { - // If we suspended deeper than the shell, we don't need to delay - // the commmit. However, we still call renderDidSuspend if this is - // a new boundary, to tell the work loop that a new fallback has - // appeared during this render. - // TODO: Theoretically we should be able to delete this branch. - // It's currently used for two things: 1) to throttle the - // appearance of successive loading states, and 2) in - // SuspenseList, to determine whether the children include any - // pending fallbacks. For 1, we should apply throttling to all - // retries, not just ones that render an additional fallback. For - // 2, we should check subtreeFlags instead. Then we can delete - // this branch. - const current = suspenseBoundary.alternate; - if (current === null) { - renderDidSuspend(); - } - } - } - suspenseBoundary.flags &= ~ForceClientRender; markSuspenseBoundaryShouldCapture( suspenseBoundary, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 6459cd0775aa6..69cf05dbcf4eb 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -12,7 +12,7 @@ import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols'; import type {Wakeable, Thenable} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane'; -import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type {SuspenseProps, SuspenseState} from './ReactFiberSuspenseComponent'; import type {FunctionComponentUpdateQueue} from './ReactFiberHooks'; import type {EventPriority} from './ReactEventPriorities'; import type { @@ -275,7 +275,7 @@ import { import {schedulePostPaintCallback} from './ReactPostPaintCallback'; import { getSuspenseHandler, - getShellBoundary, + isBadSuspenseFallback, } from './ReactFiberSuspenseContext'; import {resolveDefaultProps} from './ReactFiberLazyComponent'; import {resetChildReconcilerOnUnwind} from './ReactChildFiber'; @@ -1857,11 +1857,18 @@ function handleThrow(root: FiberRoot, thrownValue: any): void { } function shouldAttemptToSuspendUntilDataResolves() { + // TODO: We should be able to move the + // renderDidSuspend/renderDidSuspendDelayIfPossible logic into this function, + // instead of repeating it in the complete phase. Or something to that effect. + + if (includesOnlyRetries(workInProgressRootRenderLanes)) { + // We can always wait during a retry. + return true; + } + // Check if there are other pending updates that might possibly unblock this // component from suspending. This mirrors the check in // renderDidSuspendDelayIfPossible. We should attempt to unify them somehow. - // TODO: Consider unwinding immediately, using the - // SuspendedOnHydration mechanism. if ( includesNonIdleWork(workInProgressRootSkippedLanes) || includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes) @@ -1874,24 +1881,27 @@ function shouldAttemptToSuspendUntilDataResolves() { // TODO: We should be able to remove the equivalent check in // finishConcurrentRender, and rely just on this one. if (includesOnlyTransitions(workInProgressRootRenderLanes)) { - // If we're rendering inside the "shell" of the app, it's better to suspend - // rendering and wait for the data to resolve. Otherwise, we should switch - // to a fallback and continue rendering. - return getShellBoundary() === null; - } - - const handler = getSuspenseHandler(); - if (handler === null) { - // TODO: We should support suspending in the case where there's no - // parent Suspense boundary, even outside a transition. Somehow. Otherwise, - // an uncached promise can fall into an infinite loop. - } else { - if (includesOnlyRetries(workInProgressRootRenderLanes)) { - // During a retry, we can suspend rendering if the nearest Suspense boundary - // is the boundary of the "shell", because we're guaranteed not to block - // any new content from appearing. - return handler === getShellBoundary(); + const suspenseHandler = getSuspenseHandler(); + if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) { + const currentSuspenseHandler = suspenseHandler.alternate; + const nextProps: SuspenseProps = suspenseHandler.memoizedProps; + if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) { + // The nearest Suspense boundary is already showing content. We should + // avoid replacing it with a fallback, and instead wait until the + // data finishes loading. + return true; + } else { + // This is not a bad fallback condition. We should show a fallback + // immediately instead of waiting for the data to resolve. This includes + // when suspending inside new trees. + return false; + } } + + // During a transition, if there is no Suspense boundary (i.e. suspending in + // the "shell" of an application), or if we're inside a hidden tree, then + // we should wait until the data finishes loading. + return true; } // For all other Lanes besides Transitions and Retries, we should not wait @@ -1969,8 +1979,6 @@ export function renderDidSuspendDelayIfPossible(): void { // (inside this function), since by suspending at the end of the render // phase introduces a potential mistake where we suspend lanes that were // pinged or updated while we were rendering. - // TODO: Consider unwinding immediately, using the - // SuspendedOnHydration mechanism. // $FlowFixMe[incompatible-call] need null check workInProgressRoot markRootSuspended(workInProgressRoot, workInProgressRootRenderLanes); } @@ -2189,10 +2197,6 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) { } // The work loop is suspended on data. We should wait for it to // resolve before continuing to render. - // TODO: Handle the case where the promise resolves synchronously. - // Usually this is handled when we instrument the promise to add a - // `status` field, but if the promise already has a status, we won't - // have added a listener until right here. const onResolution = () => { // Check if the root is still suspended on this promise. if ( diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 4d3abff868cb8..4db7475989b33 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -1021,10 +1021,14 @@ describe('ReactSuspenseWithNoopRenderer', () => { await waitFor(['Suspend! [Async]', 'Sibling']); await resolveText('Async'); - - // Because we're already showing a fallback, interrupt the current render - // and restart immediately. - await waitForAll(['Async', 'Sibling']); + await waitForAll([ + // We've now pinged the boundary but we don't know if we should restart yet, + // because we haven't completed the suspense boundary. + 'Loading...', + // Once we've completed the boundary we restarted. + 'Async', + 'Sibling', + ]); expect(root).toMatchRenderedOutput( <> diff --git a/packages/react-reconciler/src/__tests__/ReactUse-test.js b/packages/react-reconciler/src/__tests__/ReactUse-test.js index c013bd50fd384..6c4823297d051 100644 --- a/packages/react-reconciler/src/__tests__/ReactUse-test.js +++ b/packages/react-reconciler/src/__tests__/ReactUse-test.js @@ -11,7 +11,6 @@ let useMemo; let useEffect; let Suspense; let startTransition; -let cache; let pendingTextRequests; let waitFor; let waitForPaint; @@ -33,7 +32,6 @@ describe('ReactUse', () => { useEffect = React.useEffect; Suspense = React.Suspense; startTransition = React.startTransition; - cache = React.cache; const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; @@ -1011,481 +1009,4 @@ describe('ReactUse', () => { ]); }, ); - - // @gate enableUseHook - test('load multiple nested Suspense boundaries', async () => { - const getCachedAsyncText = cache(getAsyncText); - - function AsyncText({text}) { - return ; - } - - const root = ReactNoop.createRoot(); - await act(() => { - root.render( - }> - - }> - - }> - - - - , - ); - }); - assertLog([ - 'Async text requested [A]', - 'Async text requested [B]', - 'Async text requested [C]', - '(Loading C...)', - '(Loading B...)', - '(Loading A...)', - ]); - expect(root).toMatchRenderedOutput('(Loading A...)'); - - await act(() => { - resolveTextRequests('A'); - }); - assertLog(['A', '(Loading C...)', '(Loading B...)']); - expect(root).toMatchRenderedOutput('A(Loading B...)'); - - await act(() => { - resolveTextRequests('B'); - }); - assertLog(['B', '(Loading C...)']); - expect(root).toMatchRenderedOutput('AB(Loading C...)'); - - await act(() => { - resolveTextRequests('C'); - }); - assertLog(['C']); - expect(root).toMatchRenderedOutput('ABC'); - }); - - // @gate enableUseHook - test('load multiple nested Suspense boundaries (uncached requests)', async () => { - // This the same as the previous test, except the requests are not cached. - // The tree should still eventually resolve, despite the - // duplicate requests. - function AsyncText({text}) { - // This initiates a new request on each render. - return ; - } - - const root = ReactNoop.createRoot(); - await act(() => { - root.render( - }> - - }> - - }> - - - - , - ); - }); - assertLog([ - 'Async text requested [A]', - 'Async text requested [B]', - 'Async text requested [C]', - '(Loading C...)', - '(Loading B...)', - '(Loading A...)', - ]); - expect(root).toMatchRenderedOutput('(Loading A...)'); - - await act(() => { - resolveTextRequests('A'); - }); - assertLog(['Async text requested [A]']); - expect(root).toMatchRenderedOutput('(Loading A...)'); - - await act(() => { - resolveTextRequests('A'); - }); - assertLog([ - // React suspends until A finishes loading. - 'Async text requested [A]', - 'A', - - // Now React can continue rendering the rest of the tree. - - // React does not suspend on the inner requests, because that would - // block A from appearing. Instead it shows a fallback. - 'Async text requested [B]', - 'Async text requested [C]', - '(Loading C...)', - '(Loading B...)', - ]); - expect(root).toMatchRenderedOutput('A(Loading B...)'); - - await act(() => { - resolveTextRequests('B'); - }); - assertLog(['Async text requested [B]']); - expect(root).toMatchRenderedOutput('A(Loading B...)'); - - await act(() => { - resolveTextRequests('B'); - }); - assertLog([ - // React suspends until B finishes loading. - 'Async text requested [B]', - 'B', - - // React does not suspend on C, because that would block B from appearing. - 'Async text requested [C]', - '(Loading C...)', - ]); - expect(root).toMatchRenderedOutput('AB(Loading C...)'); - - await act(() => { - resolveTextRequests('C'); - }); - assertLog(['Async text requested [C]']); - expect(root).toMatchRenderedOutput('AB(Loading C...)'); - - await act(() => { - resolveTextRequests('C'); - }); - assertLog(['Async text requested [C]', 'C']); - expect(root).toMatchRenderedOutput('ABC'); - }); - - // @gate enableUseHook - test('use() combined with render phase updates', async () => { - function Async() { - const a = use(Promise.resolve('A')); - const [count, setCount] = useState(0); - if (count === 0) { - setCount(1); - } - const usedCount = use(Promise.resolve(count)); - return ; - } - - function App() { - return ( - }> - - - ); - } - - const root = ReactNoop.createRoot(); - await act(() => { - startTransition(() => { - root.render(); - }); - }); - assertLog(['A1']); - expect(root).toMatchRenderedOutput('A1'); - }); - - test('basic promise as child', async () => { - const promise = Promise.resolve(); - const root = ReactNoop.createRoot(); - await act(() => { - startTransition(() => { - root.render(promise); - }); - }); - assertLog(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); - }); - - test('basic async component', async () => { - async function App() { - await getAsyncText('Hi'); - return ; - } - - const root = ReactNoop.createRoot(); - await act(() => { - startTransition(() => { - root.render(); - }); - }); - assertLog(['Async text requested [Hi]']); - - await act(() => resolveTextRequests('Hi')); - assertLog([ - // TODO: We shouldn't have to replay the function body again. Skip - // straight to reconciliation. - 'Async text requested [Hi]', - 'Hi', - ]); - expect(root).toMatchRenderedOutput('Hi'); - }); - - test('async child of a non-function component (e.g. a class)', async () => { - class App extends React.Component { - async render() { - const text = await getAsyncText('Hi'); - return ; - } - } - - const root = ReactNoop.createRoot(); - await act(async () => { - startTransition(() => { - root.render(); - }); - }); - assertLog(['Async text requested [Hi]']); - - await act(async () => resolveTextRequests('Hi')); - assertLog([ - // TODO: We shouldn't have to replay the render function again. We could - // skip straight to reconciliation. However, it's not as urgent to fix - // this for fiber types that aren't function components, so we can special - // case those in the meantime. - 'Async text requested [Hi]', - 'Hi', - ]); - expect(root).toMatchRenderedOutput('Hi'); - }); - - test('async children are recursively unwrapped', async () => { - // This is a Usable of a Usable. `use` would only unwrap a single level, but - // when passed as a child, the reconciler recurisvely unwraps until it - // resolves to a non-Usable value. - const thenable = { - then() {}, - status: 'fulfilled', - value: { - then() {}, - status: 'fulfilled', - value: , - }, - }; - const root = ReactNoop.createRoot(); - await act(() => { - root.render(thenable); - }); - assertLog(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); - }); - - test('async children are transparently unwrapped before being reconciled (top level)', async () => { - function Child({text}) { - useEffect(() => { - Scheduler.log(`Mount: ${text}`); - }, [text]); - return ; - } - - async function App({text}) { - // The child returned by this component is always a promise (async - // functions always return promises). React should unwrap it and reconcile - // the result, not the promise itself. - return ; - } - - const root = ReactNoop.createRoot(); - await act(() => { - startTransition(() => { - root.render(); - }); - }); - assertLog(['A', 'Mount: A']); - expect(root).toMatchRenderedOutput('A'); - - // Update the child's props. It should not remount. - await act(() => { - startTransition(() => { - root.render(); - }); - }); - assertLog(['B', 'Mount: B']); - expect(root).toMatchRenderedOutput('B'); - }); - - test('async children are transparently unwrapped before being reconciled (siblings)', async () => { - function Child({text}) { - useEffect(() => { - Scheduler.log(`Mount: ${text}`); - }, [text]); - return ; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - startTransition(() => { - root.render( - <> - {Promise.resolve()} - {Promise.resolve()} - {Promise.resolve()} - , - ); - }); - }); - assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']); - expect(root).toMatchRenderedOutput('ABC'); - - await act(() => { - startTransition(() => { - root.render( - <> - {Promise.resolve()} - {Promise.resolve()} - {Promise.resolve()} - , - ); - }); - }); - // Nothing should have remounted - assertLog(['A', 'B', 'C']); - expect(root).toMatchRenderedOutput('ABC'); - }); - - test('async children are transparently unwrapped before being reconciled (siblings, reordered)', async () => { - function Child({text}) { - useEffect(() => { - Scheduler.log(`Mount: ${text}`); - }, [text]); - return ; - } - - const root = ReactNoop.createRoot(); - await act(() => { - startTransition(() => { - root.render( - <> - {Promise.resolve()} - {Promise.resolve()} - {Promise.resolve()} - , - ); - }); - }); - assertLog(['A', 'B', 'C', 'Mount: A', 'Mount: B', 'Mount: C']); - expect(root).toMatchRenderedOutput('ABC'); - - await act(() => { - startTransition(() => { - root.render( - <> - {Promise.resolve()} - {Promise.resolve()} - {Promise.resolve()} - , - ); - }); - }); - // Nothing should have remounted - assertLog(['B', 'A', 'C']); - expect(root).toMatchRenderedOutput('BAC'); - }); - - test('basic Context as node', async () => { - const Context = React.createContext(null); - - function Indirection({children}) { - Scheduler.log('Indirection'); - return children; - } - - function ParentOfContextNode() { - Scheduler.log('ParentOfContextNode'); - return Context; - } - - function Child({text}) { - useEffect(() => { - Scheduler.log('Mount'); - return () => { - Scheduler.log('Unmount'); - }; - }, []); - return ; - } - - function App({contextValue, children}) { - const memoizedChildren = useMemo( - () => ( - - - - ), - [children], - ); - return ( - - {memoizedChildren} - - ); - } - - // Initial render - const root = ReactNoop.createRoot(); - await act(() => { - root.render(} />); - }); - assertLog(['Indirection', 'ParentOfContextNode', 'A', 'Mount']); - expect(root).toMatchRenderedOutput('A'); - - // Update the child to a new value - await act(async () => { - root.render(} />); - }); - assertLog([ - // Notice that the did not rerender, because the - // update was sent via Context. - - // TODO: We shouldn't have to re-render the parent of the context node. - // This happens because we need to reconcile the parent's children again. - // However, we should be able to skip directly to reconcilation without - // evaluating the component. One way to do this might be to mark the - // context dependency with a flag that says it was added - // during reconcilation. - 'ParentOfContextNode', - - // Notice that this was an update, not a remount. - 'B', - ]); - expect(root).toMatchRenderedOutput('B'); - - // Delete the old child and replace it with a new one, by changing the key - await act(async () => { - root.render(} />); - }); - assertLog([ - 'ParentOfContextNode', - - // A new instance is mounted - 'C', - 'Unmount', - 'Mount', - ]); - }); - - test('context as node, at the root', async () => { - const Context = React.createContext(); - const root = ReactNoop.createRoot(); - await act(async () => { - startTransition(() => { - root.render(Context); - }); - }); - assertLog(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); - }); - - test('promises that resolves to a context, rendered as a node', async () => { - const Context = React.createContext(); - const promise = Promise.resolve(Context); - const root = ReactNoop.createRoot(); - await act(async () => { - startTransition(() => { - root.render(promise); - }); - }); - assertLog(['Hi']); - expect(root).toMatchRenderedOutput('Hi'); - }); });