From b45cd193f81862ef87a1de974e29583cecd8f108 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 25 Jul 2022 11:49:45 -0400 Subject: [PATCH] Implement Offscreen in Fizz During server rendering, a visible Offscreen subtree acts exactly like a fragment: a pure indirection. A hidden Offscreen subtree is not server rendered at all. It's ignored during hydration, too. Prerendering happens only on the client. We considered prerendering hidden trees on the server, too, but our conclusion is that it's a waste of bytes and server computation. We can't think of any compelling cases where it's the right trade off. (If we ever change our mind, though, the way we'll likely model it is to treat it as if it's a Suspense boundary with an empty fallback.) --- ...DOMServerPartialHydration-test.internal.js | 99 +++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 35 +++++-- 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index c8a44764d77..167faa30f41 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 a8a3fe69320..f2974e0507e 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.