From 620c838fb64c87c92691ff0fe83b320a7f50f617 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Tue, 22 Apr 2025 22:20:21 +0200 Subject: [PATCH 1/4] Build `react-server-dom-webpack` for codesandbox (#32990) This allows us to test Flight changes in a codesandbox. [Example](https://codesandbox.io/p/devbox/zkjk7y) --- .codesandbox/ci.json | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index f395115a4bc2d..f644328ad927e 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,10 +1,11 @@ { - "packages": ["packages/react", "packages/react-dom", "packages/scheduler"], + "packages": ["packages/react", "packages/react-dom", "packages/react-server-dom-webpack", "packages/scheduler"], "buildCommand": "download-build-in-codesandbox-ci", "node": "18", "publishDirectory": { "react": "build/oss-experimental/react", "react-dom": "build/oss-experimental/react-dom", + "react-server-dom-webpack": "build/oss-experimental/react-server-dom-webpack", "scheduler": "build/oss-experimental/scheduler" }, "sandboxes": ["new"], diff --git a/package.json b/package.json index 0fcb952cc6f6d..875986ebdaf6a 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "publish-prereleases": "echo 'This command has been deprecated. Please refer to https://github.com/facebook/react/tree/main/scripts/release#trigger-an-automated-prerelease'", "download-build": "node ./scripts/release/download-experimental-build.js", "download-build-for-head": "node ./scripts/release/download-experimental-build.js --commit=$(git rev-parse HEAD)", - "download-build-in-codesandbox-ci": "yarn build --type=node react/index react-dom/index react-dom/client react-dom/src/server react-dom/test-utils scheduler/index react/jsx-runtime react/jsx-dev-runtime", + "download-build-in-codesandbox-ci": "yarn build --type=node react/index react.react-server react-dom/index react-dom/client react-dom/src/server react-dom/test-utils react-dom.react-server scheduler/index react/jsx-runtime react/jsx-dev-runtime react-server-dom-webpack", "check-release-dependencies": "node ./scripts/release/check-release-dependencies", "generate-inline-fizz-runtime": "node ./scripts/rollup/generate-inline-fizz-runtime.js", "flags": "node ./scripts/flags/flags.js" From ebf7318e87cf2e10b6bd9a6bb0ad8bf6f6186f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 22 Apr 2025 19:29:12 -0400 Subject: [PATCH 2/4] Hide/unhide the content of dehydrated suspense boundaries if they resuspend (#32900) Found this bug while working on Activity. There's a weird edge case when a dehydrated Suspense boundary is a direct child of another Suspense boundary which is hydrated but then it resuspends without forcing the inner one to hydrate/delete. It used to just leave that in place because hiding/unhiding didn't deal with dehydrated fragments. Not sure this is really worth fixing. --- .../src/client/ReactFiberConfigDOM.js | 61 +++++++++++++ ...DOMServerPartialHydration-test.internal.js | 90 +++++++++++++++++++ .../src/ReactFiberCommitHostEffects.js | 23 +++++ .../src/ReactFiberCommitWork.js | 5 ++ .../src/ReactFiberConfigWithNoHydration.js | 2 + .../src/forks/ReactFiberConfig.custom.js | 2 + 6 files changed, 183 insertions(+) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2d29781dfee97..2e6f4f2d4d43d 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -1127,6 +1127,61 @@ export function clearSuspenseBoundaryFromContainer( retryIfBlockedOn(container); } +function hideOrUnhideSuspenseBoundary( + suspenseInstance: SuspenseInstance, + isHidden: boolean, +) { + let node: Node = suspenseInstance; + // Unhide all nodes within this suspense boundary. + let depth = 0; + do { + const nextNode = node.nextSibling; + if (node.nodeType === ELEMENT_NODE) { + const instance = ((node: any): HTMLElement & {_stashedDisplay?: string}); + if (isHidden) { + instance._stashedDisplay = instance.style.display; + instance.style.display = 'none'; + } else { + instance.style.display = instance._stashedDisplay || ''; + if (instance.getAttribute('style') === '') { + instance.removeAttribute('style'); + } + } + } else if (node.nodeType === TEXT_NODE) { + const textNode = ((node: any): Text & {_stashedText?: string}); + if (isHidden) { + textNode._stashedText = textNode.nodeValue; + textNode.nodeValue = ''; + } else { + textNode.nodeValue = textNode._stashedText || ''; + } + } + if (nextNode && nextNode.nodeType === COMMENT_NODE) { + const data = ((nextNode: any).data: string); + if (data === SUSPENSE_END_DATA) { + if (depth === 0) { + return; + } else { + depth--; + } + } else if ( + data === SUSPENSE_START_DATA || + data === SUSPENSE_PENDING_START_DATA || + data === SUSPENSE_FALLBACK_START_DATA + ) { + depth++; + } + // TODO: Should we hide preamble contribution in this case? + } + // $FlowFixMe[incompatible-type] we bail out when we get a null + node = nextNode; + } while (node); +} + +export function hideSuspenseBoundary(suspenseInstance: SuspenseInstance): void { + hideOrUnhideSuspenseBoundary(suspenseInstance, true); +} + export function hideInstance(instance: Instance): void { // TODO: Does this work for all element types? What about MathML? Should we // pass host context to this method? @@ -1144,6 +1199,12 @@ export function hideTextInstance(textInstance: TextInstance): void { textInstance.nodeValue = ''; } +export function unhideSuspenseBoundary( + suspenseInstance: SuspenseInstance, +): void { + hideOrUnhideSuspenseBoundary(suspenseInstance, false); +} + export function unhideInstance(instance: Instance, props: Props): void { instance = ((instance: any): HTMLElement); const styleProp = props[STYLE]; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index deef07d6ef663..7e95ad64e7694 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -3986,4 +3986,94 @@ describe('ReactDOMServerPartialHydration', () => { "onRecoverableError: Hydration failed because the server rendered text didn't match the client.", ]); }); + + it('hides a dehydrated suspense boundary if the parent resuspends', async () => { + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + const ref = React.createRef(); + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return text; + } + } + + function Sibling({resuspend}) { + if (suspend && resuspend) { + throw promise; + } else { + return null; + } + } + + function Component({text}) { + return ( + + + World + + ); + } + + function App({text, resuspend}) { + const memoized = React.useMemo(() => , [text]); + return ( +
+ + {memoized} + + +
+ ); + } + + suspend = false; + const finalHTML = ReactDOMServer.renderToString(); + const container = document.createElement('div'); + container.innerHTML = finalHTML; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + const root = ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } + }, + }); + await waitForAll([]); + + expect(ref.current).toBe(null); // Still dehydrated + const span = container.getElementsByTagName('span')[0]; + const textNode = span.previousSibling; + expect(textNode.nodeValue).toBe('Hello'); + expect(span.textContent).toBe('World'); + + // Render an update, that resuspends the parent boundary. + // Flushing now now hide the text content. + await act(() => { + root.render(); + }); + + expect(ref.current).toBe(null); + expect(span.style.display).toBe('none'); + expect(textNode.nodeValue).toBe(''); + + // Unsuspending shows the content. + await act(async () => { + suspend = false; + resolve(); + await promise; + }); + + expect(textNode.nodeValue).toBe('Hello'); + expect(span.textContent).toBe('World'); + expect(span.style.display).toBe(''); + expect(ref.current).toBe(span); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index 2ca49f677de1f..4548e30ec65ac 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -41,8 +41,10 @@ import { insertBefore, insertInContainerBefore, replaceContainerChildren, + hideSuspenseBoundary, hideInstance, hideTextInstance, + unhideSuspenseBoundary, unhideInstance, unhideTextInstance, commitHydratedContainer, @@ -152,6 +154,27 @@ export function commitHostResetTextContent(finishedWork: Fiber) { } } +export function commitShowHideSuspenseBoundary(node: Fiber, isHidden: boolean) { + try { + const instance = node.stateNode; + if (isHidden) { + if (__DEV__) { + runWithFiberInDEV(node, hideSuspenseBoundary, instance); + } else { + hideSuspenseBoundary(instance); + } + } else { + if (__DEV__) { + runWithFiberInDEV(node, unhideSuspenseBoundary, node.stateNode); + } else { + unhideSuspenseBoundary(node.stateNode); + } + } + } catch (error) { + captureCommitPhaseError(node, node.return, error); + } +} + export function commitShowHideHostInstance(node: Fiber, isHidden: boolean) { try { const instance = node.stateNode; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 92e6883c8c652..8065432370add 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -227,6 +227,7 @@ import { commitHostUpdate, commitHostTextUpdate, commitHostResetTextContent, + commitShowHideSuspenseBoundary, commitShowHideHostInstance, commitShowHideHostTextInstance, commitHostPlacement, @@ -1158,6 +1159,10 @@ function hideOrUnhideAllChildren(finishedWork: Fiber, isHidden: boolean) { if (hostSubtreeRoot === null) { commitShowHideHostTextInstance(node, isHidden); } + } else if (node.tag === DehydratedFragment) { + if (hostSubtreeRoot === null) { + commitShowHideSuspenseBoundary(node, isHidden); + } } else if ( (node.tag === OffscreenComponent || node.tag === LegacyHiddenComponent) && diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js index 0bb85246dfe24..fb190d410bba0 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js @@ -44,6 +44,8 @@ export const commitHydratedContainer = shim; export const commitHydratedSuspenseInstance = shim; export const clearSuspenseBoundary = shim; export const clearSuspenseBoundaryFromContainer = shim; +export const hideSuspenseBoundary = shim; +export const unhideSuspenseBoundary = shim; export const shouldDeleteUnhydratedTailInstances = shim; export const diffHydratedPropsForDevWarnings = shim; export const diffHydratedTextForDevWarnings = shim; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index f890a78a80e71..983b20ab33dc7 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -220,6 +220,8 @@ export const commitHydratedSuspenseInstance = export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary; export const clearSuspenseBoundaryFromContainer = $$$config.clearSuspenseBoundaryFromContainer; +export const hideSuspenseBoundary = $$$config.hideSuspenseBoundary; +export const unhideSuspenseBoundary = $$$config.unhideSuspenseBoundary; export const shouldDeleteUnhydratedTailInstances = $$$config.shouldDeleteUnhydratedTailInstances; export const diffHydratedPropsForDevWarnings = From 3fbd6b7b50e3a174883633695586b892249e5635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 22 Apr 2025 19:39:09 -0400 Subject: [PATCH 3/4] Set hidden Offscreen to the shellBoundary regardless of previous state (#32844) I think this was probably just copy-paste from the Suspense path. It shouldn't matter what the previous state of an Offscreen boundary was. What matters is that it's now hidden and therefore if it suspends, we can just leave it as is without the tree becoming inconsistent. --- .../src/ReactFiberSuspenseContext.js | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberSuspenseContext.js b/packages/react-reconciler/src/ReactFiberSuspenseContext.js index 7bc365e84a383..8f712b1b106fc 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseContext.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseContext.js @@ -11,7 +11,6 @@ import type {SuspenseProps} from 'shared/ReactTypes'; import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; -import type {OffscreenState} from './ReactFiberOffscreenComponent'; import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags'; import {createCursor, push, pop} from './ReactFiberStack'; @@ -115,19 +114,10 @@ export function pushOffscreenSuspenseHandler(fiber: Fiber): void { // into separate functions for Suspense and Offscreen. pushSuspenseListContext(fiber, suspenseStackCursor.current); 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; - } - } + if (shellBoundary === null) { + // We're rendering hidden content. If it suspends, we can handle it by + // just not committing the offscreen boundary. + shellBoundary = fiber; } } else { // This is a LegacyHidden component. From 17f88c80ed20b4e5f21255d9e1268542a2fbc1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 22 Apr 2025 19:44:14 -0400 Subject: [PATCH 4/4] Implement ActivityInstance in FiberConfigDOM (#32842) Stacked on #32851 and #32900. This implements the equivalent Configs for ActivityInstance as we have for SuspenseInstance. These can be implemented as comments but they don't have to be and can be implemented differently in the renderer. This seems like a lot duplication but it's actually ends mostly just calling the same methods underneath and the wrappers compiles out. This doesn't leave the Activity dehydrated yet. It just hydrates into it immediately. --- .../src/client/ReactDOMComponentTree.js | 43 ++-- .../src/client/ReactFiberConfigDOM.js | 189 ++++++++++++++---- .../src/events/ReactDOMEventListener.js | 32 ++- .../src/events/ReactDOMEventReplaying.js | 39 +++- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../ReactDOMFizzInstructionSetShared.js | 17 +- packages/react-dom/src/client/ReactDOMRoot.js | 4 +- .../src/ReactFiberBeginWork.js | 5 + .../src/ReactFiberCommitHostEffects.js | 12 +- .../src/ReactFiberCompleteWork.js | 11 +- .../src/ReactFiberConfigWithNoHydration.js | 12 +- .../src/ReactFiberHydrationContext.js | 47 +++++ .../src/ReactFiberHydrationDiffs.js | 3 + .../src/ReactFiberTreeReflection.js | 14 +- .../src/ReactInternalTypes.js | 7 +- .../src/forks/ReactFiberConfig.custom.js | 16 +- 16 files changed, 362 insertions(+), 91 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index a5b070be494e4..7fef110511160 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -17,6 +17,7 @@ import type { Container, TextInstance, Instance, + ActivityInstance, SuspenseInstance, Props, HoistableRoot, @@ -30,9 +31,10 @@ import { HostText, HostRoot, SuspenseComponent, + ActivityComponent, } from 'react-reconciler/src/ReactWorkTags'; -import {getParentSuspenseInstance} from './ReactFiberConfigDOM'; +import {getParentHydrationBoundary} from './ReactFiberConfigDOM'; import {enableScopeAPI} from 'shared/ReactFeatureFlags'; @@ -59,7 +61,12 @@ export function detachDeletedInstance(node: Instance): void { export function precacheFiberNode( hostInst: Fiber, - node: Instance | TextInstance | SuspenseInstance | ReactScopeInstance, + node: + | Instance + | TextInstance + | SuspenseInstance + | ActivityInstance + | ReactScopeInstance, ): void { (node: any)[internalInstanceKey] = hostInst; } @@ -81,15 +88,16 @@ export function isContainerMarkedAsRoot(node: Container): boolean { // Given a DOM node, return the closest HostComponent or HostText fiber ancestor. // If the target node is part of a hydrated or not yet rendered subtree, then -// this may also return a SuspenseComponent or HostRoot to indicate that. +// this may also return a SuspenseComponent, ActivityComponent or HostRoot to +// indicate that. // Conceptually the HostRoot fiber is a child of the Container node. So if you // pass the Container node as the targetNode, you will not actually get the // HostRoot back. To get to the HostRoot, you need to pass a child of it. -// The same thing applies to Suspense boundaries. +// The same thing applies to Suspense and Activity boundaries. export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { let targetInst = (targetNode: any)[internalInstanceKey]; if (targetInst) { - // Don't return HostRoot or SuspenseComponent here. + // Don't return HostRoot, SuspenseComponent or ActivityComponent here. return targetInst; } // If the direct event target isn't a React owned DOM node, we need to look @@ -129,8 +137,8 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { ) { // Next we need to figure out if the node that skipped past is // nested within a dehydrated boundary and if so, which one. - let suspenseInstance = getParentSuspenseInstance(targetNode); - while (suspenseInstance !== null) { + let hydrationInstance = getParentHydrationBoundary(targetNode); + while (hydrationInstance !== null) { // We found a suspense instance. That means that we haven't // hydrated it yet. Even though we leave the comments in the // DOM after hydrating, and there are boundaries in the DOM @@ -140,15 +148,15 @@ export function getClosestInstanceFromNode(targetNode: Node): null | Fiber { // Let's get the fiber associated with the SuspenseComponent // as the deepest instance. // $FlowFixMe[prop-missing] - const targetSuspenseInst = suspenseInstance[internalInstanceKey]; - if (targetSuspenseInst) { - return targetSuspenseInst; + const targetFiber = hydrationInstance[internalInstanceKey]; + if (targetFiber) { + return targetFiber; } // If we don't find a Fiber on the comment, it might be because // we haven't gotten to hydrate it yet. There might still be a // parent boundary that hasn't above this one so we need to find // the outer most that is known. - suspenseInstance = getParentSuspenseInstance(suspenseInstance); + hydrationInstance = getParentHydrationBoundary(hydrationInstance); // If we don't find one, then that should mean that the parent // host component also hasn't hydrated yet. We can return it // below since it will bail out on the isMounted check later. @@ -176,6 +184,7 @@ export function getInstanceFromNode(node: Node): Fiber | null { tag === HostComponent || tag === HostText || tag === SuspenseComponent || + tag === ActivityComponent || tag === HostHoistable || tag === HostSingleton || tag === HostRoot @@ -211,15 +220,17 @@ export function getNodeFromInstance(inst: Fiber): Instance | TextInstance { } export function getFiberCurrentPropsFromNode( - node: Container | Instance | TextInstance | SuspenseInstance, + node: + | Container + | Instance + | TextInstance + | SuspenseInstance + | ActivityInstance, ): Props { return (node: any)[internalPropsKey] || null; } -export function updateFiberProps( - node: Instance | TextInstance | SuspenseInstance, - props: Props, -): void { +export function updateFiberProps(node: Instance, props: Props): void { (node: any)[internalPropsKey] = props; } diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 2e6f4f2d4d43d..ad0477232ca02 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -187,13 +187,20 @@ export type Container = | interface extends DocumentFragment {_reactRootContainer?: FiberRoot}; export type Instance = Element; export type TextInstance = Text; -export interface SuspenseInstance extends Comment { - _reactRetry?: () => void; + +declare class ActivityInterface extends Comment {} +declare class SuspenseInterface extends Comment { + _reactRetry: void | (() => void); } + +export type ActivityInstance = ActivityInterface; +export type SuspenseInstance = SuspenseInterface; + type FormStateMarkerInstance = Comment; export type HydratableInstance = | Instance | TextInstance + | ActivityInstance | SuspenseInstance | FormStateMarkerInstance; export type PublicInstance = Element | Text; @@ -226,6 +233,8 @@ type SelectionInformation = { const SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; +const ACTIVITY_START_DATA = '&'; +const ACTIVITY_END_DATA = '/&'; const SUSPENSE_START_DATA = '$'; const SUSPENSE_END_DATA = '/$'; const SUSPENSE_PENDING_START_DATA = '$?'; @@ -947,7 +956,7 @@ export function appendChildToContainer( export function insertBefore( parentInstance: Instance, child: Instance | TextInstance, - beforeChild: Instance | TextInstance | SuspenseInstance, + beforeChild: Instance | TextInstance | SuspenseInstance | ActivityInstance, ): void { if (supportsMoveBefore && child.parentNode !== null) { // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. @@ -960,7 +969,7 @@ export function insertBefore( export function insertInContainerBefore( container: Container, child: Instance | TextInstance, - beforeChild: Instance | TextInstance | SuspenseInstance, + beforeChild: Instance | TextInstance | SuspenseInstance | ActivityInstance, ): void { if (__DEV__) { warnForReactChildrenConflict(container); @@ -1024,14 +1033,14 @@ function dispatchAfterDetachedBlur(target: HTMLElement): void { export function removeChild( parentInstance: Instance, - child: Instance | TextInstance | SuspenseInstance, + child: Instance | TextInstance | SuspenseInstance | ActivityInstance, ): void { parentInstance.removeChild(child); } export function removeChildFromContainer( container: Container, - child: Instance | TextInstance | SuspenseInstance, + child: Instance | TextInstance | SuspenseInstance | ActivityInstance, ): void { let parentNode: DocumentFragment | Element; if (container.nodeType === DOCUMENT_NODE) { @@ -1049,11 +1058,11 @@ export function removeChildFromContainer( parentNode.removeChild(child); } -export function clearSuspenseBoundary( +function clearHydrationBoundary( parentInstance: Instance, - suspenseInstance: SuspenseInstance, + hydrationInstance: SuspenseInstance | ActivityInstance, ): void { - let node: Node = suspenseInstance; + let node: Node = hydrationInstance; // Delete all nodes within this suspense boundary. // There might be nested nodes so we need to keep track of how // deep we are and only break out when we're back on top. @@ -1063,11 +1072,11 @@ export function clearSuspenseBoundary( parentInstance.removeChild(node); if (nextNode && nextNode.nodeType === COMMENT_NODE) { const data = ((nextNode: any).data: string); - if (data === SUSPENSE_END_DATA) { + if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { if (depth === 0) { parentInstance.removeChild(nextNode); // Retry if any event replaying was blocked on this. - retryIfBlockedOn(suspenseInstance); + retryIfBlockedOn(hydrationInstance); return; } else { depth--; @@ -1075,7 +1084,8 @@ export function clearSuspenseBoundary( } else if ( data === SUSPENSE_START_DATA || data === SUSPENSE_PENDING_START_DATA || - data === SUSPENSE_FALLBACK_START_DATA + data === SUSPENSE_FALLBACK_START_DATA || + data === ACTIVITY_START_DATA ) { depth++; } else if (data === PREAMBLE_CONTRIBUTION_HTML) { @@ -1102,12 +1112,26 @@ export function clearSuspenseBoundary( } while (node); // TODO: Warn, we didn't find the end comment boundary. // Retry if any event replaying was blocked on this. - retryIfBlockedOn(suspenseInstance); + retryIfBlockedOn(hydrationInstance); } -export function clearSuspenseBoundaryFromContainer( - container: Container, +export function clearActivityBoundary( + parentInstance: Instance, + activityInstance: ActivityInstance, +): void { + clearHydrationBoundary(parentInstance, activityInstance); +} + +export function clearSuspenseBoundary( + parentInstance: Instance, suspenseInstance: SuspenseInstance, +): void { + clearHydrationBoundary(parentInstance, suspenseInstance); +} + +function clearHydrationBoundaryFromContainer( + container: Container, + hydrationInstance: SuspenseInstance | ActivityInstance, ): void { let parentNode: DocumentFragment | Element; if (container.nodeType === DOCUMENT_NODE) { @@ -1122,13 +1146,27 @@ export function clearSuspenseBoundaryFromContainer( } else { parentNode = (container: any); } - clearSuspenseBoundary(parentNode, suspenseInstance); + clearHydrationBoundary(parentNode, hydrationInstance); // Retry if any event replaying was blocked on this. retryIfBlockedOn(container); } -function hideOrUnhideSuspenseBoundary( +export function clearActivityBoundaryFromContainer( + container: Container, + activityInstance: ActivityInstance, +): void { + clearHydrationBoundaryFromContainer(container, activityInstance); +} + +export function clearSuspenseBoundaryFromContainer( + container: Container, suspenseInstance: SuspenseInstance, +): void { + clearHydrationBoundaryFromContainer(container, suspenseInstance); +} + +function hideOrUnhideDehydratedBoundary( + suspenseInstance: SuspenseInstance | ActivityInstance, isHidden: boolean, ) { let node: Node = suspenseInstance; @@ -1178,8 +1216,10 @@ function hideOrUnhideSuspenseBoundary( } while (node); } -export function hideSuspenseBoundary(suspenseInstance: SuspenseInstance): void { - hideOrUnhideSuspenseBoundary(suspenseInstance, true); +export function hideDehydratedBoundary( + suspenseInstance: SuspenseInstance, +): void { + hideOrUnhideDehydratedBoundary(suspenseInstance, true); } export function hideInstance(instance: Instance): void { @@ -1199,10 +1239,10 @@ export function hideTextInstance(textInstance: TextInstance): void { textInstance.nodeValue = ''; } -export function unhideSuspenseBoundary( - suspenseInstance: SuspenseInstance, +export function unhideDehydratedBoundary( + dehydratedInstance: SuspenseInstance | ActivityInstance, ): void { - hideOrUnhideSuspenseBoundary(suspenseInstance, false); + hideOrUnhideDehydratedBoundary(dehydratedInstance, false); } export function unhideInstance(instance: Instance, props: Props): void { @@ -3047,10 +3087,10 @@ export function canHydrateTextInstance( return ((instance: any): TextInstance); } -export function canHydrateSuspenseInstance( +function canHydrateHydrationBoundary( instance: HydratableInstance, inRootOrSingleton: boolean, -): null | SuspenseInstance { +): null | SuspenseInstance | ActivityInstance { while (instance.nodeType !== COMMENT_NODE) { if (!inRootOrSingleton) { return null; @@ -3061,8 +3101,42 @@ export function canHydrateSuspenseInstance( } instance = nextInstance; } - // This has now been refined to a suspense node. - return ((instance: any): SuspenseInstance); + // This has now been refined to a hydration boundary node. + return (instance: any); +} + +export function canHydrateActivityInstance( + instance: HydratableInstance, + inRootOrSingleton: boolean, +): null | ActivityInstance { + const hydratableInstance = canHydrateHydrationBoundary( + instance, + inRootOrSingleton, + ); + if ( + hydratableInstance !== null && + hydratableInstance.data === ACTIVITY_START_DATA + ) { + return (hydratableInstance: any); + } + return null; +} + +export function canHydrateSuspenseInstance( + instance: HydratableInstance, + inRootOrSingleton: boolean, +): null | SuspenseInstance { + const hydratableInstance = canHydrateHydrationBoundary( + instance, + inRootOrSingleton, + ); + if ( + hydratableInstance !== null && + hydratableInstance.data !== ACTIVITY_START_DATA + ) { + return (hydratableInstance: any); + } + return null; } export function isSuspenseInstancePending(instance: SuspenseInstance): boolean { @@ -3186,12 +3260,13 @@ function getNextHydratable(node: ?Node) { nodeData === SUSPENSE_START_DATA || nodeData === SUSPENSE_FALLBACK_START_DATA || nodeData === SUSPENSE_PENDING_START_DATA || + nodeData === ACTIVITY_START_DATA || nodeData === FORM_STATE_IS_MATCHING || nodeData === FORM_STATE_IS_NOT_MATCHING ) { break; } - if (nodeData === SUSPENSE_END_DATA) { + if (nodeData === SUSPENSE_END_DATA || nodeData === ACTIVITY_END_DATA) { return null; } } @@ -3230,6 +3305,12 @@ export function getFirstHydratableChildWithinContainer( return getNextHydratable(parentElement.firstChild); } +export function getFirstHydratableChildWithinActivityInstance( + parentInstance: ActivityInstance, +): null | HydratableInstance { + return getNextHydratable(parentInstance.nextSibling); +} + export function getFirstHydratableChildWithinSuspenseInstance( parentInstance: SuspenseInstance, ): null | HydratableInstance { @@ -3281,6 +3362,12 @@ export function describeHydratableInstanceForDevWarnings( props: getPropsFromElement((instance: any)), }; } else if (instance.nodeType === COMMENT_NODE) { + if (instance.data === ACTIVITY_START_DATA) { + return { + type: 'Activity', + props: {}, + }; + } return { type: 'Suspense', props: {}, @@ -3372,6 +3459,13 @@ export function diffHydratedTextForDevWarnings( return null; } +export function hydrateActivityInstance( + activityInstance: ActivityInstance, + internalInstanceHandle: Object, +) { + precacheFiberNode(internalInstanceHandle, activityInstance); +} + export function hydrateSuspenseInstance( suspenseInstance: SuspenseInstance, internalInstanceHandle: Object, @@ -3379,10 +3473,10 @@ export function hydrateSuspenseInstance( precacheFiberNode(internalInstanceHandle, suspenseInstance); } -export function getNextHydratableInstanceAfterSuspenseInstance( - suspenseInstance: SuspenseInstance, +function getNextHydratableInstanceAfterHydrationBoundary( + hydrationInstance: SuspenseInstance | ActivityInstance, ): null | HydratableInstance { - let node = suspenseInstance.nextSibling; + let node = hydrationInstance.nextSibling; // Skip past all nodes within this suspense boundary. // There might be nested nodes so we need to keep track of how // deep we are and only break out when we're back on top. @@ -3390,7 +3484,7 @@ export function getNextHydratableInstanceAfterSuspenseInstance( while (node) { if (node.nodeType === COMMENT_NODE) { const data = ((node: any).data: string); - if (data === SUSPENSE_END_DATA) { + if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { if (depth === 0) { return getNextHydratableSibling((node: any)); } else { @@ -3399,7 +3493,8 @@ export function getNextHydratableInstanceAfterSuspenseInstance( } else if ( data === SUSPENSE_START_DATA || data === SUSPENSE_FALLBACK_START_DATA || - data === SUSPENSE_PENDING_START_DATA + data === SUSPENSE_PENDING_START_DATA || + data === ACTIVITY_START_DATA ) { depth++; } @@ -3410,12 +3505,24 @@ export function getNextHydratableInstanceAfterSuspenseInstance( return null; } +export function getNextHydratableInstanceAfterActivityInstance( + activityInstance: ActivityInstance, +): null | HydratableInstance { + return getNextHydratableInstanceAfterHydrationBoundary(activityInstance); +} + +export function getNextHydratableInstanceAfterSuspenseInstance( + suspenseInstance: SuspenseInstance, +): null | HydratableInstance { + return getNextHydratableInstanceAfterHydrationBoundary(suspenseInstance); +} + // Returns the SuspenseInstance if this node is a direct child of a // SuspenseInstance. I.e. if its previous sibling is a Comment with // SUSPENSE_x_START_DATA. Otherwise, null. -export function getParentSuspenseInstance( +export function getParentHydrationBoundary( targetInstance: Node, -): null | SuspenseInstance { +): null | SuspenseInstance | ActivityInstance { let node = targetInstance.previousSibling; // Skip past all nodes within this suspense boundary. // There might be nested nodes so we need to keep track of how @@ -3427,14 +3534,15 @@ export function getParentSuspenseInstance( if ( data === SUSPENSE_START_DATA || data === SUSPENSE_FALLBACK_START_DATA || - data === SUSPENSE_PENDING_START_DATA + data === SUSPENSE_PENDING_START_DATA || + data === ACTIVITY_START_DATA ) { if (depth === 0) { - return ((node: any): SuspenseInstance); + return ((node: any): SuspenseInstance | ActivityInstance); } else { depth--; } - } else if (data === SUSPENSE_END_DATA) { + } else if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { depth++; } } @@ -3448,6 +3556,13 @@ export function commitHydratedContainer(container: Container): void { retryIfBlockedOn(container); } +export function commitHydratedActivityInstance( + activityInstance: ActivityInstance, +): void { + // Retry if any event replaying was blocked on this. + retryIfBlockedOn(activityInstance); +} + export function commitHydratedSuspenseInstance( suspenseInstance: SuspenseInstance, ): void { diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js index 243042ba6fde4..139d76ab01ddb 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventListener.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventListener.js @@ -10,7 +10,11 @@ import type {EventPriority} from 'react-reconciler/src/ReactEventPriorities'; import type {AnyNativeEvent} from '../events/PluginModuleType'; import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; -import type {Container, SuspenseInstance} from '../client/ReactFiberConfigDOM'; +import type { + Container, + ActivityInstance, + SuspenseInstance, +} from '../client/ReactFiberConfigDOM'; import type {DOMEventName} from '../events/DOMEventNames'; import { @@ -22,9 +26,14 @@ import {attemptSynchronousHydration} from 'react-reconciler/src/ReactFiberReconc import { getNearestMountedFiber, getContainerFromFiber, + getActivityInstanceFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; -import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; +import { + HostRoot, + ActivityComponent, + SuspenseComponent, +} from 'react-reconciler/src/ReactWorkTags'; import {type EventSystemFlags, IS_CAPTURE_PHASE} from './EventSystemFlags'; import getEventTarget from './getEventTarget'; @@ -227,18 +236,18 @@ export function dispatchEvent( export function findInstanceBlockingEvent( nativeEvent: AnyNativeEvent, -): null | Container | SuspenseInstance { +): null | Container | SuspenseInstance | ActivityInstance { const nativeEventTarget = getEventTarget(nativeEvent); return findInstanceBlockingTarget(nativeEventTarget); } export let return_targetInst: null | Fiber = null; -// Returns a SuspenseInstance or Container if it's blocked. +// Returns a SuspenseInstance, ActivityInstance or Container if it's blocked. // The return_targetInst field above is conceptually part of the return value. export function findInstanceBlockingTarget( targetNode: Node, -): null | Container | SuspenseInstance { +): null | Container | SuspenseInstance | ActivityInstance { // TODO: Warn if _enabled is false. return_targetInst = null; @@ -265,6 +274,19 @@ export function findInstanceBlockingTarget( // the whole system, dispatch the event without a target. // TODO: Warn. targetInst = null; + } else if (tag === ActivityComponent) { + const instance = getActivityInstanceFromFiber(nearestMounted); + if (instance !== null) { + // Queue the event to be replayed later. Abort dispatching since we + // don't want this event dispatched twice through the event system. + // TODO: If this is the first discrete event in the queue. Schedule an increased + // priority for this boundary. + return instance; + } + // This shouldn't happen, something went wrong but to avoid blocking + // the whole system, dispatch the event without a target. + // TODO: Warn. + targetInst = null; } else if (tag === HostRoot) { const root: FiberRoot = nearestMounted.stateNode; if (isRootDehydrated(root)) { diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index 52cfb07aaa2d5..a6f6f3055ae70 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -8,7 +8,11 @@ */ import type {AnyNativeEvent} from '../events/PluginModuleType'; -import type {Container, SuspenseInstance} from '../client/ReactFiberConfigDOM'; +import type { + Container, + ActivityInstance, + SuspenseInstance, +} from '../client/ReactFiberConfigDOM'; import type {DOMEventName} from '../events/DOMEventNames'; import type {EventSystemFlags} from './EventSystemFlags'; import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; @@ -21,6 +25,7 @@ import { import { getNearestMountedFiber, getContainerFromFiber, + getActivityInstanceFromFiber, getSuspenseInstanceFromFiber, } from 'react-reconciler/src/ReactFiberTreeReflection'; import { @@ -33,7 +38,11 @@ import { getClosestInstanceFromNode, getFiberCurrentPropsFromNode, } from '../client/ReactDOMComponentTree'; -import {HostRoot, SuspenseComponent} from 'react-reconciler/src/ReactWorkTags'; +import { + HostRoot, + ActivityComponent, + SuspenseComponent, +} from 'react-reconciler/src/ReactWorkTags'; import {isHigherEventPriority} from 'react-reconciler/src/ReactEventPriorities'; import {isRootDehydrated} from 'react-reconciler/src/ReactFiberShellHydration'; import {dispatchReplayedFormAction} from './plugins/FormActionEventPlugin'; @@ -56,7 +65,7 @@ type PointerEvent = Event & { }; type QueuedReplayableEvent = { - blockedOn: null | Container | SuspenseInstance, + blockedOn: null | Container | ActivityInstance | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, nativeEvent: AnyNativeEvent, @@ -76,7 +85,7 @@ const queuedPointerCaptures: Map = new Map(); // We could consider replaying selectionchange and touchmoves too. type QueuedHydrationTarget = { - blockedOn: null | Container | SuspenseInstance, + blockedOn: null | Container | ActivityInstance | SuspenseInstance, target: Node, priority: EventPriority, }; @@ -120,7 +129,7 @@ export function isDiscreteEventThatRequiresHydration( } function createQueuedReplayableEvent( - blockedOn: null | Container | SuspenseInstance, + blockedOn: null | Container | ActivityInstance | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -170,7 +179,7 @@ export function clearIfContinuousEvent( function accumulateOrCreateContinuousQueuedReplayableEvent( existingQueuedEvent: null | QueuedReplayableEvent, - blockedOn: null | Container | SuspenseInstance, + blockedOn: null | Container | ActivityInstance | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -212,7 +221,7 @@ function accumulateOrCreateContinuousQueuedReplayableEvent( } export function queueIfContinuousEvent( - blockedOn: null | Container | SuspenseInstance, + blockedOn: null | Container | ActivityInstance | SuspenseInstance, domEventName: DOMEventName, eventSystemFlags: EventSystemFlags, targetContainer: EventTarget, @@ -316,6 +325,18 @@ function attemptExplicitHydrationTarget( attemptHydrationAtCurrentPriority(nearestMounted); }); + return; + } + } else if (tag === ActivityComponent) { + const instance = getActivityInstanceFromFiber(nearestMounted); + if (instance !== null) { + // We're blocked on hydrating this boundary. + // Increase its priority. + queuedTarget.blockedOn = instance; + attemptHydrationAtPriority(queuedTarget.priority, () => { + attemptHydrationAtCurrentPriority(nearestMounted); + }); + return; } } else if (tag === HostRoot) { @@ -418,7 +439,7 @@ function replayUnblockedEvents() { function scheduleCallbackIfUnblocked( queuedEvent: QueuedReplayableEvent, - unblocked: Container | SuspenseInstance, + unblocked: Container | SuspenseInstance | ActivityInstance, ) { if (queuedEvent.blockedOn === unblocked) { queuedEvent.blockedOn = null; @@ -494,7 +515,7 @@ function scheduleReplayQueueIfNeeded(formReplayingQueue: FormReplayingQueue) { } export function retryIfBlockedOn( - unblocked: Container | SuspenseInstance, + unblocked: Container | SuspenseInstance | ActivityInstance, ): void { if (queuedFocus !== null) { scheduleCallbackIfUnblocked(queuedFocus, unblocked); diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 6e47c3b0658e3..cd7ecbbb9b022 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -4,7 +4,7 @@ export const clientRenderBoundary = '$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};'; export const completeBoundary = - '$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};'; + '$RC=function(b,d,e){d=document.getElementById(d);d.parentNode.removeChild(d);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var c=a.data;if("/$"===c||"/&"===c)if(0===f)break;else f--;else"$"!==c&&"$?"!==c&&"$!"!==c&&"&"!==c||f++}c=a.nextSibling;e.removeChild(a);a=c}while(a);for(;d.firstChild;)e.insertBefore(d.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};'; export const completeBoundaryWithStyles = '$RM=new Map;\n$RR=function(t,u,y){function v(n){this._p=null;n()}for(var w=$RC,p=$RM,q=new Map,r=document,g,b,h=r.querySelectorAll("link[data-precedence],style[data-precedence]"),x=[],k=0;b=h[k++];)"not all"===b.getAttribute("media")?x.push(b):("LINK"===b.tagName&&p.set(b.getAttribute("href"),b),q.set(b.dataset.precedence,g=b));b=0;h=[];var l,a;for(k=!0;;){if(k){var e=y[b++];if(!e){k=!1;b=0;continue}var c=!1,m=0;var d=e[m++];if(a=p.get(d)){var f=a._p;c=!0}else{a=r.createElement("link");a.href=\nd;a.rel="stylesheet";for(a.dataset.precedence=l=e[m++];f=e[m++];)a.setAttribute(f,e[m++]);f=a._p=new Promise(function(n,z){a.onload=v.bind(a,n);a.onerror=v.bind(a,z)});p.set(d,a)}d=a.getAttribute("media");!f||d&&!matchMedia(d).matches||h.push(f);if(c)continue}else{a=x[b++];if(!a)break;l=a.getAttribute("data-precedence");a.removeAttribute("media")}c=q.get(l)||g;c===g&&(g=a);q.set(l,a);c?c.parentNode.insertBefore(a,c.nextSibling):(c=r.head,c.insertBefore(a,c.firstChild))}Promise.all(h).then(w.bind(null,\nt,u,""),w.bind(null,t,u,"Resource failed to load"))};'; export const completeSegment = diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js index f9139094aa9b5..044a547492889 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js @@ -3,11 +3,13 @@ // Shared implementation and constants between the inline script and external // runtime instruction sets. -export const COMMENT_NODE = 8; -export const SUSPENSE_START_DATA = '$'; -export const SUSPENSE_END_DATA = '/$'; -export const SUSPENSE_PENDING_START_DATA = '$?'; -export const SUSPENSE_FALLBACK_START_DATA = '$!'; +const COMMENT_NODE = 8; +const ACTIVITY_START_DATA = '&'; +const ACTIVITY_END_DATA = '/&'; +const SUSPENSE_START_DATA = '$'; +const SUSPENSE_END_DATA = '/$'; +const SUSPENSE_PENDING_START_DATA = '$?'; +const SUSPENSE_FALLBACK_START_DATA = '$!'; // TODO: Symbols that are referenced outside this module use dynamic accessor // notation instead of dot notation to prevent Closure's advanced compilation @@ -74,7 +76,7 @@ export function completeBoundary(suspenseBoundaryID, contentID, errorDigest) { do { if (node && node.nodeType === COMMENT_NODE) { const data = node.data; - if (data === SUSPENSE_END_DATA) { + if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { if (depth === 0) { break; } else { @@ -83,7 +85,8 @@ export function completeBoundary(suspenseBoundaryID, contentID, errorDigest) { } else if ( data === SUSPENSE_START_DATA || data === SUSPENSE_PENDING_START_DATA || - data === SUSPENSE_FALLBACK_START_DATA + data === SUSPENSE_FALLBACK_START_DATA || + data === ACTIVITY_START_DATA ) { depth++; } diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 37e994cc7bb71..940f0d4f3b124 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -47,8 +47,8 @@ export type CreateRootOptions = { export type HydrateRootOptions = { // Hydration options - onHydrated?: (suspenseNode: Comment) => void, - onDeleted?: (suspenseNode: Comment) => void, + onHydrated?: (hydrationBoundary: Comment) => void, + onDeleted?: (hydrationBoundary: Comment) => void, // Options for all roots unstable_strictMode?: boolean, unstable_transitionCallbacks?: TransitionTracingCallbacks, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 68a832e72e70e..058ecb6bfd688 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -244,6 +244,7 @@ import { claimHydratableSingleton, tryToClaimNextHydratableInstance, tryToClaimNextHydratableTextInstance, + claimNextHydratableActivityInstance, claimNextHydratableSuspenseInstance, warnIfHydrating, queueHydrationError, @@ -905,6 +906,10 @@ function updateActivityComponent( }; if (current === null) { + if (getIsHydrating()) { + claimNextHydratableActivityInstance(workInProgress); + } + const primaryChildFragment = mountWorkInProgressOffscreenFiber( offscreenChildProps, mode, diff --git a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js index 4548e30ec65ac..7873e3ddfa9a2 100644 --- a/packages/react-reconciler/src/ReactFiberCommitHostEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitHostEffects.js @@ -41,10 +41,10 @@ import { insertBefore, insertInContainerBefore, replaceContainerChildren, - hideSuspenseBoundary, + hideDehydratedBoundary, hideInstance, hideTextInstance, - unhideSuspenseBoundary, + unhideDehydratedBoundary, unhideInstance, unhideTextInstance, commitHydratedContainer, @@ -159,15 +159,15 @@ export function commitShowHideSuspenseBoundary(node: Fiber, isHidden: boolean) { const instance = node.stateNode; if (isHidden) { if (__DEV__) { - runWithFiberInDEV(node, hideSuspenseBoundary, instance); + runWithFiberInDEV(node, hideDehydratedBoundary, instance); } else { - hideSuspenseBoundary(instance); + hideDehydratedBoundary(instance); } } else { if (__DEV__) { - runWithFiberInDEV(node, unhideSuspenseBoundary, node.stateNode); + runWithFiberInDEV(node, unhideDehydratedBoundary, node.stateNode); } else { - unhideSuspenseBoundary(node.stateNode); + unhideDehydratedBoundary(node.stateNode); } } } catch (error) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 2837b8939aa79..9ddfca471e2d7 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -997,7 +997,6 @@ function completeWork( } // Fallthrough } - case ActivityComponent: case LazyComponent: case SimpleMemoComponent: case FunctionComponent: @@ -1393,6 +1392,16 @@ function completeWork( bubbleProperties(workInProgress); return null; } + case ActivityComponent: { + if (current === null) { + const wasHydrated = popHydrationState(workInProgress); + if (wasHydrated) { + // TODO: Implement prepareToHydrateActivityInstance + } + } + bubbleProperties(workInProgress); + return null; + } case SuspenseComponent: { const nextState: null | SuspenseState = workInProgress.memoizedState; diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js index fb190d410bba0..9b907b673f892 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoHydration.js @@ -19,6 +19,7 @@ function shim(...args: any): empty { } // Hydration (when unsupported) +export type ActivityInstance = mixed; export type SuspenseInstance = mixed; export const supportsHydration = false; export const isSuspenseInstancePending = shim; @@ -31,21 +32,28 @@ export const getNextHydratableSibling = shim; export const getNextHydratableSiblingAfterSingleton = shim; export const getFirstHydratableChild = shim; export const getFirstHydratableChildWithinContainer = shim; +export const getFirstHydratableChildWithinActivityInstance = shim; export const getFirstHydratableChildWithinSuspenseInstance = shim; export const getFirstHydratableChildWithinSingleton = shim; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; +export const canHydrateActivityInstance = shim; export const canHydrateSuspenseInstance = shim; export const hydrateInstance = shim; export const hydrateTextInstance = shim; +export const hydrateActivityInstance = shim; export const hydrateSuspenseInstance = shim; +export const getNextHydratableInstanceAfterActivityInstance = shim; export const getNextHydratableInstanceAfterSuspenseInstance = shim; export const commitHydratedContainer = shim; +export const commitHydratedActivityInstance = shim; export const commitHydratedSuspenseInstance = shim; +export const clearActivityBoundary = shim; export const clearSuspenseBoundary = shim; +export const clearActivityBoundaryFromContainer = shim; export const clearSuspenseBoundaryFromContainer = shim; -export const hideSuspenseBoundary = shim; -export const unhideSuspenseBoundary = shim; +export const hideDehydratedBoundary = shim; +export const unhideDehydratedBoundary = shim; export const shouldDeleteUnhydratedTailInstances = shim; export const diffHydratedPropsForDevWarnings = shim; export const diffHydratedTextForDevWarnings = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index daa9c8ca7a52a..f9e7580e09bed 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -12,6 +12,7 @@ import type { Instance, TextInstance, HydratableInstance, + ActivityInstance, SuspenseInstance, Container, HostContext, @@ -26,6 +27,7 @@ import { HostSingleton, HostRoot, SuspenseComponent, + ActivityComponent, } from './ReactWorkTags'; import {favorSafetyOverHydrationPerf} from 'shared/ReactFeatureFlags'; @@ -40,6 +42,7 @@ import { getNextHydratableSiblingAfterSingleton, getFirstHydratableChild, getFirstHydratableChildWithinContainer, + getFirstHydratableChildWithinActivityInstance, getFirstHydratableChildWithinSuspenseInstance, getFirstHydratableChildWithinSingleton, hydrateInstance, @@ -48,11 +51,13 @@ import { hydrateTextInstance, diffHydratedTextForDevWarnings, hydrateSuspenseInstance, + getNextHydratableInstanceAfterActivityInstance, getNextHydratableInstanceAfterSuspenseInstance, shouldDeleteUnhydratedTailInstances, resolveSingletonInstance, canHydrateInstance, canHydrateTextInstance, + canHydrateActivityInstance, canHydrateSuspenseInstance, canHydrateFormStateMarker, isFormStateMarkerMatching, @@ -272,6 +277,26 @@ function tryHydrateText(fiber: Fiber, nextInstance: any) { return false; } +function tryHydrateActivity( + fiber: Fiber, + nextInstance: any, +): null | ActivityInstance { + // fiber is a SuspenseComponent Fiber + const activityInstance = canHydrateActivityInstance( + nextInstance, + rootOrSingletonContext, + ); + if (activityInstance !== null) { + // TODO: Implement dehydrated Activity state. + // TODO: Delete this from stateNode. It's only used to skip past it. + fiber.stateNode = activityInstance; + hydrationParentFiber = fiber; + nextHydratableInstance = + getFirstHydratableChildWithinActivityInstance(activityInstance); + } + return activityInstance; +} + function tryHydrateSuspense( fiber: Fiber, nextInstance: any, @@ -425,6 +450,18 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void { } } +function claimNextHydratableActivityInstance(fiber: Fiber): ActivityInstance { + const nextInstance = nextHydratableInstance; + const activityInstance = nextInstance + ? tryHydrateActivity(fiber, nextInstance) + : null; + if (activityInstance === null) { + warnNonHydratedInstance(fiber, nextInstance); + throw throwOnHydrationMismatch(fiber); + } + return activityInstance; +} + function claimNextHydratableSuspenseInstance(fiber: Fiber): SuspenseInstance { const nextInstance = nextHydratableInstance; const suspenseInstance = nextInstance @@ -576,6 +613,11 @@ function prepareToHydrateHostSuspenseInstance(fiber: Fiber): void { hydrateSuspenseInstance(suspenseInstance, fiber); } +function skipPastDehydratedActivityInstance( + fiber: Fiber, +): null | HydratableInstance { + return getNextHydratableInstanceAfterActivityInstance(fiber.stateNode); +} function skipPastDehydratedSuspenseInstance( fiber: Fiber, @@ -612,6 +654,8 @@ function popToNextHostParent(fiber: Fiber): void { case HostRoot: rootOrSingletonContext = true; return; + case ActivityComponent: + return; default: hydrationParentFiber = hydrationParentFiber.return; } @@ -677,6 +721,8 @@ function popHydrationState(fiber: Fiber): boolean { popToNextHostParent(fiber); if (tag === SuspenseComponent) { nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber); + } else if (tag === ActivityComponent) { + nextHydratableInstance = skipPastDehydratedActivityInstance(fiber); } else if (supportsSingletons && tag === HostSingleton) { nextHydratableInstance = getNextHydratableSiblingAfterSingleton( fiber.type, @@ -793,6 +839,7 @@ export { claimHydratableSingleton, tryToClaimNextHydratableInstance, tryToClaimNextHydratableTextInstance, + claimNextHydratableActivityInstance, claimNextHydratableSuspenseInstance, prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, diff --git a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js index a790bede9055b..51df2a25d6c53 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationDiffs.js +++ b/packages/react-reconciler/src/ReactFiberHydrationDiffs.js @@ -14,6 +14,7 @@ import { HostHoistable, HostSingleton, LazyComponent, + ActivityComponent, SuspenseComponent, SuspenseListComponent, FunctionComponent, @@ -83,6 +84,8 @@ function describeFiberType(fiber: Fiber): null | string { return fiber.type; case LazyComponent: return 'Lazy'; + case ActivityComponent: + return 'Activity'; case SuspenseComponent: return 'Suspense'; case SuspenseListComponent: diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 2e2466a4f5a83..9699e5897f797 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -8,7 +8,12 @@ */ import type {Fiber} from './ReactInternalTypes'; -import type {Container, SuspenseInstance, Instance} from './ReactFiberConfig'; +import type { + Container, + ActivityInstance, + SuspenseInstance, + Instance, +} from './ReactFiberConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; import { @@ -74,6 +79,13 @@ export function getSuspenseInstanceFromFiber( return null; } +export function getActivityInstanceFromFiber( + fiber: Fiber, +): null | ActivityInstance { + // TODO: Implement this on ActivityComponent. + return null; +} + export function getContainerFromFiber(fiber: Fiber): null | Container { return fiber.tag === HostRoot ? (fiber.stateNode.containerInfo: Container) diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index f8e06456eeecf..d083d189b3e5d 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -29,6 +29,7 @@ import type { Instance, TimeoutHandle, NoTimeout, + ActivityInstance, SuspenseInstance, TransitionStatus, } from './ReactFiberConfig'; @@ -297,8 +298,10 @@ type UpdaterTrackingOnlyFiberRootProperties = { }; export type SuspenseHydrationCallbacks = { - onHydrated?: (suspenseInstance: SuspenseInstance) => void, - onDeleted?: (suspenseInstance: SuspenseInstance) => void, + +onHydrated?: ( + hydrationBoundary: SuspenseInstance | ActivityInstance, + ) => void, + +onDeleted?: (hydrationBoundary: SuspenseInstance | ActivityInstance) => void, ... }; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 983b20ab33dc7..4e3fb62b09912 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -29,6 +29,7 @@ export opaque type Props = mixed; export opaque type Container = mixed; export opaque type Instance = mixed; export opaque type TextInstance = mixed; +export opaque type ActivityInstance = mixed; export opaque type SuspenseInstance = mixed; export opaque type HydratableInstance = mixed; export opaque type PublicInstance = mixed; @@ -202,26 +203,37 @@ export const getNextHydratableSiblingAfterSingleton = export const getFirstHydratableChild = $$$config.getFirstHydratableChild; export const getFirstHydratableChildWithinContainer = $$$config.getFirstHydratableChildWithinContainer; +export const getFirstHydratableChildWithinActivityInstance = + $$$config.getFirstHydratableChildWithinActivityInstance; export const getFirstHydratableChildWithinSuspenseInstance = $$$config.getFirstHydratableChildWithinSuspenseInstance; export const getFirstHydratableChildWithinSingleton = $$$config.getFirstHydratableChildWithinSingleton; export const canHydrateInstance = $$$config.canHydrateInstance; export const canHydrateTextInstance = $$$config.canHydrateTextInstance; +export const canHydrateActivityInstance = $$$config.canHydrateActivityInstance; export const canHydrateSuspenseInstance = $$$config.canHydrateSuspenseInstance; export const hydrateInstance = $$$config.hydrateInstance; export const hydrateTextInstance = $$$config.hydrateTextInstance; +export const hydrateActivityInstance = $$$config.hydrateActivityInstance; export const hydrateSuspenseInstance = $$$config.hydrateSuspenseInstance; +export const getNextHydratableInstanceAfterActivityInstance = + $$$config.getNextHydratableInstanceAfterActivityInstance; export const getNextHydratableInstanceAfterSuspenseInstance = $$$config.getNextHydratableInstanceAfterSuspenseInstance; export const commitHydratedContainer = $$$config.commitHydratedContainer; +export const commitHydratedActivityInstance = + $$$config.commitHydratedActivityInstance; export const commitHydratedSuspenseInstance = $$$config.commitHydratedSuspenseInstance; +export const clearActivityBoundary = $$$config.clearActivityBoundary; export const clearSuspenseBoundary = $$$config.clearSuspenseBoundary; +export const clearActivityBoundaryFromContainer = + $$$config.clearActivityBoundaryFromContainer; export const clearSuspenseBoundaryFromContainer = $$$config.clearSuspenseBoundaryFromContainer; -export const hideSuspenseBoundary = $$$config.hideSuspenseBoundary; -export const unhideSuspenseBoundary = $$$config.unhideSuspenseBoundary; +export const hideDehydratedBoundary = $$$config.hideDehydratedBoundary; +export const unhideDehydratedBoundary = $$$config.unhideDehydratedBoundary; export const shouldDeleteUnhydratedTailInstances = $$$config.shouldDeleteUnhydratedTailInstances; export const diffHydratedPropsForDevWarnings =