Skip to content

Commit 2c7dea7

Browse files
authored
Implement Offscreen in Fizz (#24988)
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.)
1 parent 5f34b05 commit 2c7dea7

File tree

2 files changed

+126
-8
lines changed

2 files changed

+126
-8
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

+99
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ let Scheduler;
1717
let ReactFeatureFlags;
1818
let Suspense;
1919
let SuspenseList;
20+
let Offscreen;
2021
let act;
2122
let IdleEventPriority;
2223

@@ -106,6 +107,7 @@ describe('ReactDOMServerPartialHydration', () => {
106107
ReactDOMServer = require('react-dom/server');
107108
Scheduler = require('scheduler');
108109
Suspense = React.Suspense;
110+
Offscreen = React.unstable_Offscreen;
109111
if (gate(flags => flags.enableSuspenseList)) {
110112
SuspenseList = React.SuspenseList;
111113
}
@@ -3283,6 +3285,103 @@ describe('ReactDOMServerPartialHydration', () => {
32833285
expect(ref.current.innerHTML).toBe('Hidden child');
32843286
});
32853287

3288+
// @gate enableOffscreen
3289+
it('a visible Offscreen component acts like a fragment', async () => {
3290+
const ref = React.createRef();
3291+
3292+
function App() {
3293+
return (
3294+
<Offscreen mode="visible">
3295+
<span ref={ref}>Child</span>
3296+
</Offscreen>
3297+
);
3298+
}
3299+
3300+
const finalHTML = ReactDOMServer.renderToString(<App />);
3301+
expect(Scheduler).toHaveYielded([]);
3302+
3303+
const container = document.createElement('div');
3304+
container.innerHTML = finalHTML;
3305+
3306+
// Visible Offscreen boundaries behave exactly like fragments: a
3307+
// pure indirection.
3308+
expect(container).toMatchInlineSnapshot(`
3309+
<div>
3310+
<span>
3311+
Child
3312+
</span>
3313+
</div>
3314+
`);
3315+
3316+
const span = container.getElementsByTagName('span')[0];
3317+
3318+
// The tree successfully hydrates
3319+
ReactDOMClient.hydrateRoot(container, <App />);
3320+
expect(Scheduler).toFlushAndYield([]);
3321+
expect(ref.current).toBe(span);
3322+
});
3323+
3324+
// @gate enableOffscreen
3325+
it('a hidden Offscreen component is skipped over during server rendering', async () => {
3326+
const visibleRef = React.createRef();
3327+
3328+
function HiddenChild() {
3329+
Scheduler.unstable_yieldValue('HiddenChild');
3330+
return <span>Hidden</span>;
3331+
}
3332+
3333+
function App() {
3334+
Scheduler.unstable_yieldValue('App');
3335+
return (
3336+
<>
3337+
<span ref={visibleRef}>Visible</span>
3338+
<Offscreen mode="hidden">
3339+
<HiddenChild />
3340+
</Offscreen>
3341+
</>
3342+
);
3343+
}
3344+
3345+
// During server rendering, the Child component should not be evaluated,
3346+
// because it's inside a hidden tree.
3347+
const finalHTML = ReactDOMServer.renderToString(<App />);
3348+
expect(Scheduler).toHaveYielded(['App']);
3349+
3350+
const container = document.createElement('div');
3351+
container.innerHTML = finalHTML;
3352+
3353+
// The hidden child is not part of the server rendered HTML
3354+
expect(container).toMatchInlineSnapshot(`
3355+
<div>
3356+
<span>
3357+
Visible
3358+
</span>
3359+
</div>
3360+
`);
3361+
3362+
const visibleSpan = container.getElementsByTagName('span')[0];
3363+
3364+
// The visible span successfully hydrates
3365+
ReactDOMClient.hydrateRoot(container, <App />);
3366+
expect(Scheduler).toFlushUntilNextPaint(['App']);
3367+
expect(visibleRef.current).toBe(visibleSpan);
3368+
3369+
// Subsequently, the hidden child is prerendered on the client
3370+
expect(Scheduler).toFlushUntilNextPaint(['HiddenChild']);
3371+
expect(container).toMatchInlineSnapshot(`
3372+
<div>
3373+
<span>
3374+
Visible
3375+
</span>
3376+
<span
3377+
style="display: none;"
3378+
>
3379+
Hidden
3380+
</span>
3381+
</div>
3382+
`);
3383+
});
3384+
32863385
function itHydratesWithoutMismatch(msg, App) {
32873386
it('hydrates without mismatch ' + msg, () => {
32883387
const container = document.createElement('div');

packages/react-server/src/ReactFizzServer.js

+27-8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ReactNodeList,
1717
ReactContext,
1818
ReactProviderType,
19+
OffscreenMode,
1920
} from 'shared/ReactTypes';
2021
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
2122
import type {
@@ -107,6 +108,7 @@ import {
107108
REACT_PROVIDER_TYPE,
108109
REACT_CONTEXT_TYPE,
109110
REACT_SCOPE_TYPE,
111+
REACT_OFFSCREEN_TYPE,
110112
} from 'shared/ReactSymbols';
111113
import ReactSharedInternals from 'shared/ReactSharedInternals';
112114
import {
@@ -1062,6 +1064,18 @@ function renderLazyComponent(
10621064
popComponentStackInDEV(task);
10631065
}
10641066

1067+
function renderOffscreen(request: Request, task: Task, props: Object): void {
1068+
const mode: ?OffscreenMode = (props.mode: any);
1069+
if (mode === 'hidden') {
1070+
// A hidden Offscreen boundary is not server rendered. Prerendering happens
1071+
// on the client.
1072+
} else {
1073+
// A visible Offscreen boundary is treated exactly like a fragment: a
1074+
// pure indirection.
1075+
renderNodeDestructive(request, task, props.children);
1076+
}
1077+
}
1078+
10651079
function renderElement(
10661080
request: Request,
10671081
task: Task,
@@ -1084,14 +1098,15 @@ function renderElement(
10841098
}
10851099

10861100
switch (type) {
1087-
// TODO: LegacyHidden acts the same as a fragment. This only works
1088-
// because we currently assume that every instance of LegacyHidden is
1089-
// accompanied by a host component wrapper. In the hidden mode, the host
1090-
// component is given a `hidden` attribute, which ensures that the
1091-
// initial HTML is not visible. To support the use of LegacyHidden as a
1092-
// true fragment, without an extra DOM node, we would have to hide the
1093-
// initial HTML in some other way.
1094-
// TODO: Add REACT_OFFSCREEN_TYPE here too with the same capability.
1101+
// LegacyHidden acts the same as a fragment. This only works because we
1102+
// currently assume that every instance of LegacyHidden is accompanied by a
1103+
// host component wrapper. In the hidden mode, the host component is given a
1104+
// `hidden` attribute, which ensures that the initial HTML is not visible.
1105+
// To support the use of LegacyHidden as a true fragment, without an extra
1106+
// DOM node, we would have to hide the initial HTML in some other way.
1107+
// TODO: Delete in LegacyHidden. It's an unstable API only used in the
1108+
// www build. As a migration step, we could add a special prop to Offscreen
1109+
// that simulates the old behavior (no hiding, no change to effects).
10951110
case REACT_LEGACY_HIDDEN_TYPE:
10961111
case REACT_DEBUG_TRACING_MODE_TYPE:
10971112
case REACT_STRICT_MODE_TYPE:
@@ -1100,6 +1115,10 @@ function renderElement(
11001115
renderNodeDestructive(request, task, props.children);
11011116
return;
11021117
}
1118+
case REACT_OFFSCREEN_TYPE: {
1119+
renderOffscreen(request, task, props);
1120+
return;
1121+
}
11031122
case REACT_SUSPENSE_LIST_TYPE: {
11041123
pushBuiltInComponentStackInDEV(task, 'SuspenseList');
11051124
// TODO: SuspenseList should control the boundaries.

0 commit comments

Comments
 (0)