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.