diff --git a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js index f501d1724dd8a..85fa21e0ea5be 100644 --- a/packages/react-dom/src/__tests__/ReactDOMUseId-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMUseId-test.js @@ -10,9 +10,11 @@ let JSDOM; let React; let ReactDOM; +let Scheduler; let clientAct; let ReactDOMFizzServer; let Stream; +let Suspense; let useId; let document; let writable; @@ -27,9 +29,11 @@ describe('useId', () => { JSDOM = require('jsdom').JSDOM; React = require('react'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); clientAct = require('jest-react').act; ReactDOMFizzServer = require('react-dom/server'); Stream = require('stream'); + Suspense = React.Suspense; useId = React.unstable_useId; // Test Environment @@ -86,6 +90,11 @@ describe('useId', () => { } } + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + function normalizeTreeIdForTesting(id) { const [serverClientPrefix, base32, hookIndex] = id.split(':'); if (serverClientPrefix === 'r') { @@ -282,4 +291,126 @@ describe('useId', () => { expect(divs[i].id).toMatch(/^R:.(((al)*a?)((la)*l?))*$/); } }); + + test('basic incremental hydration', async () => { + function App() { + return ( +
+ + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + await clientAct(async () => { + ReactDOM.hydrateRoot(container, ); + }); + expect(container).toMatchInlineSnapshot(` +
+
+ +
+
+ +
+
+
+ `); + }); + + test('inserting a sibling before a dehydrated Suspense boundary', async () => { + function App({showMore}) { + const siblings = showMore + ? [, ] + : []; + + return ( +
+ {siblings} + + + + + +
+ ); + } + + await serverAct(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(Scheduler).toHaveYielded(['A']); + await clientAct(async () => { + const root = ReactDOM.hydrateRoot(container, ); + expect(Scheduler).toFlushUntilNextPaint(['A']); + expect(container).toMatchInlineSnapshot(` +
+
+ A + + +
+
+ +
+
+
+ `); + // Insert another sibling before the Suspense boundary + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'A', + 'B', + // The update triggers selective hydration so we render again + 'A', + 'B', + ]); + expect(container).toMatchInlineSnapshot(` +
+
+ A + + + B +
+
+ +
+
+
+ `); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index c19615a62286a..c8853f16ead83 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -1886,6 +1886,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2734,6 +2735,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index e1d686c1b8774..ab1fda0cddc95 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -1886,6 +1886,7 @@ function validateFunctionComponentInDev(workInProgress: Fiber, Component: any) { const SUSPENDED_MARKER: SuspenseState = { dehydrated: null, + treeContext: null, retryLane: NoLane, }; @@ -2734,6 +2735,7 @@ function updateDehydratedSuspenseComponent( reenterHydrationStateFromDehydratedSuspenseInstance( workInProgress, suspenseInstance, + suspenseState.treeContext, ); const nextProps = workInProgress.pendingProps; const primaryChildren = nextProps.children; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 7275f1663cad8..eabc5e43116bb 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.new'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 654de3f9a2894..48e60581e0f28 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -17,6 +17,7 @@ import type { HostContext, } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; import { HostComponent, @@ -62,6 +63,10 @@ import { } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.old'; +import { + getSuspendedTreeContext, + restoreSuspendedTreeContext, +} from './ReactFiberTreeContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -96,6 +101,7 @@ function enterHydrationState(fiber: Fiber): boolean { function reenterHydrationStateFromDehydratedSuspenseInstance( fiber: Fiber, suspenseInstance: SuspenseInstance, + treeContext: TreeContext | null, ): boolean { if (!supportsHydration) { return false; @@ -105,6 +111,9 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( ); hydrationParentFiber = fiber; isHydrating = true; + if (treeContext !== null) { + restoreSuspendedTreeContext(fiber, treeContext); + } return true; } @@ -287,6 +296,7 @@ function tryHydrate(fiber, nextInstance) { if (suspenseInstance !== null) { const suspenseState: SuspenseState = { dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), retryLane: OffscreenLane, }; fiber.memoizedState = suspenseState; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js index 5ad7ae650249a..9dbaf7fb76efd 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.new.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.new'; +import type {TreeContext} from './ReactFiberTreeContext.new'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js index 51bef1df3a568..726f0ca52005f 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.old.js @@ -11,6 +11,8 @@ import type {ReactNodeList, Wakeable} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import type {Lane} from './ReactFiberLane.old'; +import type {TreeContext} from './ReactFiberTreeContext.old'; + import {SuspenseComponent, SuspenseListComponent} from './ReactWorkTags'; import {NoFlags, DidCapture} from './ReactFiberFlags'; import { @@ -40,6 +42,7 @@ export type SuspenseState = {| // here to indicate that it is dehydrated (flag) and for quick access // to check things like isSuspenseInstancePending. dehydrated: null | SuspenseInstance, + treeContext: null | TreeContext, // Represents the lane we should attempt to hydrate a dehydrated boundary at. // OffscreenLane is the default for dehydrated boundaries. // NoLane is the default for normal boundaries, which turns into "normal" pri. diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.new.js b/packages/react-reconciler/src/ReactFiberTreeContext.new.js index 118769cf3633c..9d5ae31f8cba0 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.new.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.new.js @@ -67,6 +67,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.new'; import {clz32} from './clz32'; import {Forked, NoFlags} from './ReactFiberFlags'; +export type TreeContext = { + id: number, + length: number, + overflow: string, +}; + let treeContextId: number = 0; let treeContextLength: number = 0; let treeContextOverflow: string = ''; @@ -199,6 +205,37 @@ export function popTreeContext(workInProgress: Fiber) { } } +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + + if (treeIdProvider !== null) { + return { + id: treeContextId, + length: treeContextLength, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + stack[stackIndex++] = treeContextId; + stack[stackIndex++] = treeContextLength; + stack[stackIndex++] = treeContextOverflow; + stack[stackIndex++] = treeIdProvider; + + treeContextId = suspendedContext.id; + treeContextLength = suspendedContext.length; + treeContextOverflow = suspendedContext.overflow; + treeIdProvider = workInProgress; +} + function warnIfNotHydrating() { if (__DEV__) { if (!getIsHydrating()) { diff --git a/packages/react-reconciler/src/ReactFiberTreeContext.old.js b/packages/react-reconciler/src/ReactFiberTreeContext.old.js index 27e33be5a8b47..46c1ce20a50e4 100644 --- a/packages/react-reconciler/src/ReactFiberTreeContext.old.js +++ b/packages/react-reconciler/src/ReactFiberTreeContext.old.js @@ -67,6 +67,12 @@ import {getIsHydrating} from './ReactFiberHydrationContext.old'; import {clz32} from './clz32'; import {Forked, NoFlags} from './ReactFiberFlags'; +export type TreeContext = { + id: number, + length: number, + overflow: string, +}; + let treeContextId: number = 0; let treeContextLength: number = 0; let treeContextOverflow: string = ''; @@ -199,6 +205,37 @@ export function popTreeContext(workInProgress: Fiber) { } } +export function getSuspendedTreeContext(): TreeContext | null { + warnIfNotHydrating(); + + if (treeIdProvider !== null) { + return { + id: treeContextId, + length: treeContextLength, + overflow: treeContextOverflow, + }; + } else { + return null; + } +} + +export function restoreSuspendedTreeContext( + workInProgress: Fiber, + suspendedContext: TreeContext, +) { + warnIfNotHydrating(); + + stack[stackIndex++] = treeContextId; + stack[stackIndex++] = treeContextLength; + stack[stackIndex++] = treeContextOverflow; + stack[stackIndex++] = treeIdProvider; + + treeContextId = suspendedContext.id; + treeContextLength = suspendedContext.length; + treeContextOverflow = suspendedContext.overflow; + treeIdProvider = workInProgress; +} + function warnIfNotHydrating() { if (__DEV__) { if (!getIsHydrating()) {