diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
index c8a44764d7712..167faa30f41c8 100644
--- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
@@ -17,6 +17,7 @@ let Scheduler;
let ReactFeatureFlags;
let Suspense;
let SuspenseList;
+let Offscreen;
let act;
let IdleEventPriority;
@@ -106,6 +107,7 @@ describe('ReactDOMServerPartialHydration', () => {
ReactDOMServer = require('react-dom/server');
Scheduler = require('scheduler');
Suspense = React.Suspense;
+ Offscreen = React.unstable_Offscreen;
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.SuspenseList;
}
@@ -3283,6 +3285,103 @@ describe('ReactDOMServerPartialHydration', () => {
expect(ref.current.innerHTML).toBe('Hidden child');
});
+ // @gate enableOffscreen
+ it('a visible Offscreen component acts like a fragment', async () => {
+ const ref = React.createRef();
+
+ function App() {
+ return (
+
+ Child
+
+ );
+ }
+
+ const finalHTML = ReactDOMServer.renderToString();
+ expect(Scheduler).toHaveYielded([]);
+
+ const container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ // Visible Offscreen boundaries behave exactly like fragments: a
+ // pure indirection.
+ expect(container).toMatchInlineSnapshot(`
+
+
+ Child
+
+
+ `);
+
+ const span = container.getElementsByTagName('span')[0];
+
+ // The tree successfully hydrates
+ ReactDOMClient.hydrateRoot(container, );
+ expect(Scheduler).toFlushAndYield([]);
+ expect(ref.current).toBe(span);
+ });
+
+ // @gate enableOffscreen
+ it('a hidden Offscreen component is skipped over during server rendering', async () => {
+ const visibleRef = React.createRef();
+
+ function HiddenChild() {
+ Scheduler.unstable_yieldValue('HiddenChild');
+ return Hidden;
+ }
+
+ function App() {
+ Scheduler.unstable_yieldValue('App');
+ return (
+ <>
+ Visible
+
+
+
+ >
+ );
+ }
+
+ // During server rendering, the Child component should not be evaluated,
+ // because it's inside a hidden tree.
+ const finalHTML = ReactDOMServer.renderToString();
+ expect(Scheduler).toHaveYielded(['App']);
+
+ const container = document.createElement('div');
+ container.innerHTML = finalHTML;
+
+ // The hidden child is not part of the server rendered HTML
+ expect(container).toMatchInlineSnapshot(`
+
+
+ Visible
+
+
+ `);
+
+ const visibleSpan = container.getElementsByTagName('span')[0];
+
+ // The visible span successfully hydrates
+ ReactDOMClient.hydrateRoot(container, );
+ expect(Scheduler).toFlushUntilNextPaint(['App']);
+ expect(visibleRef.current).toBe(visibleSpan);
+
+ // Subsequently, the hidden child is prerendered on the client
+ expect(Scheduler).toFlushUntilNextPaint(['HiddenChild']);
+ expect(container).toMatchInlineSnapshot(`
+
+
+ Visible
+
+
+ Hidden
+
+
+ `);
+ });
+
function itHydratesWithoutMismatch(msg, App) {
it('hydrates without mismatch ' + msg, () => {
const container = document.createElement('div');
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index a8a3fe6932072..f2974e0507e1d 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -16,6 +16,7 @@ import type {
ReactNodeList,
ReactContext,
ReactProviderType,
+ OffscreenMode,
} from 'shared/ReactTypes';
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {
@@ -107,6 +108,7 @@ import {
REACT_PROVIDER_TYPE,
REACT_CONTEXT_TYPE,
REACT_SCOPE_TYPE,
+ REACT_OFFSCREEN_TYPE,
} from 'shared/ReactSymbols';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -1062,6 +1064,18 @@ function renderLazyComponent(
popComponentStackInDEV(task);
}
+function renderOffscreen(request: Request, task: Task, props: Object): void {
+ const mode: ?OffscreenMode = (props.mode: any);
+ if (mode === 'hidden') {
+ // A hidden Offscreen boundary is not server rendered. Prerendering happens
+ // on the client.
+ } else {
+ // A visible Offscreen boundary is treated exactly like a fragment: a
+ // pure indirection.
+ renderNodeDestructive(request, task, props.children);
+ }
+}
+
function renderElement(
request: Request,
task: Task,
@@ -1084,14 +1098,15 @@ function renderElement(
}
switch (type) {
- // TODO: LegacyHidden acts the same as a fragment. This only works
- // because we currently assume that every instance of LegacyHidden is
- // accompanied by a host component wrapper. In the hidden mode, the host
- // component is given a `hidden` attribute, which ensures that the
- // initial HTML is not visible. To support the use of LegacyHidden as a
- // true fragment, without an extra DOM node, we would have to hide the
- // initial HTML in some other way.
- // TODO: Add REACT_OFFSCREEN_TYPE here too with the same capability.
+ // LegacyHidden acts the same as a fragment. This only works because we
+ // currently assume that every instance of LegacyHidden is accompanied by a
+ // host component wrapper. In the hidden mode, the host component is given a
+ // `hidden` attribute, which ensures that the initial HTML is not visible.
+ // To support the use of LegacyHidden as a true fragment, without an extra
+ // DOM node, we would have to hide the initial HTML in some other way.
+ // TODO: Delete in LegacyHidden. It's an unstable API only used in the
+ // www build. As a migration step, we could add a special prop to Offscreen
+ // that simulates the old behavior (no hiding, no change to effects).
case REACT_LEGACY_HIDDEN_TYPE:
case REACT_DEBUG_TRACING_MODE_TYPE:
case REACT_STRICT_MODE_TYPE:
@@ -1100,6 +1115,10 @@ function renderElement(
renderNodeDestructive(request, task, props.children);
return;
}
+ case REACT_OFFSCREEN_TYPE: {
+ renderOffscreen(request, task, props);
+ return;
+ }
case REACT_SUSPENSE_LIST_TYPE: {
pushBuiltInComponentStackInDEV(task, 'SuspenseList');
// TODO: SuspenseList should control the boundaries.