mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
* [Fizz] Disallow complex children in <title> elements <title> Elements in the DOM can only have Text content. In Fizz if more than one text node is emitted an HTML comment node is used as a text separator. Unfortunately because of the content restriction of the DOM representation of the title element this separator is displayed as escaped text which is not what the component author intended. This commit special cases title handling, primarily to issue warnings if you pass complex children to <title>. At the moment title expects to receive a single child or an array of length 1. In both cases the type of that child must be string or number. If anything more complex is provided a warning will be logged to the console explaining why this is problematic. There is no runtime behavior change so broken things are still broken (e.g. returning two text nodes which will cause a separator or using Suspense inside title children) but they should at least be accompanied by warnings that are useful. One edge case that will now warn but won't technically break an application is if you use a Component that returns a single string as a child of title. This is a form of indirection that works but becasue we cannot discriminate between a Component that will follow the rules and one that violates them the warning is issued regardless. * fixup dev warning conditional logic * lints * fix bugs
4620 lines
129 KiB
JavaScript
4620 lines
129 KiB
JavaScript
/**
|
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @emails react-core
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
let JSDOM;
|
|
let Stream;
|
|
let Scheduler;
|
|
let React;
|
|
let ReactDOMClient;
|
|
let ReactDOMFizzServer;
|
|
let Suspense;
|
|
let SuspenseList;
|
|
let useSyncExternalStore;
|
|
let useSyncExternalStoreWithSelector;
|
|
let PropTypes;
|
|
let textCache;
|
|
let window;
|
|
let document;
|
|
let writable;
|
|
let CSPnonce = null;
|
|
let container;
|
|
let buffer = '';
|
|
let hasErrored = false;
|
|
let fatalError = undefined;
|
|
|
|
describe('ReactDOMFizzServer', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
JSDOM = require('jsdom').JSDOM;
|
|
Scheduler = require('scheduler');
|
|
React = require('react');
|
|
ReactDOMClient = require('react-dom/client');
|
|
if (__EXPERIMENTAL__) {
|
|
ReactDOMFizzServer = require('react-dom/server');
|
|
}
|
|
Stream = require('stream');
|
|
Suspense = React.Suspense;
|
|
if (gate(flags => flags.enableSuspenseList)) {
|
|
SuspenseList = React.SuspenseList;
|
|
}
|
|
|
|
PropTypes = require('prop-types');
|
|
|
|
if (gate(flags => flags.source)) {
|
|
// The `with-selector` module composes the main `use-sync-external-store`
|
|
// entrypoint. In the compiled artifacts, this is resolved to the `shim`
|
|
// implementation by our build config, but when running the tests against
|
|
// the source files, we need to tell Jest how to resolve it. Because this
|
|
// is a source module, this mock has no affect on the build tests.
|
|
jest.mock('use-sync-external-store/src/useSyncExternalStore', () =>
|
|
jest.requireActual('react'),
|
|
);
|
|
}
|
|
useSyncExternalStore = React.useSyncExternalStore;
|
|
useSyncExternalStoreWithSelector = require('use-sync-external-store/with-selector')
|
|
.useSyncExternalStoreWithSelector;
|
|
|
|
textCache = new Map();
|
|
|
|
// Test Environment
|
|
const jsdom = new JSDOM(
|
|
'<!DOCTYPE html><html><head></head><body><div id="container">',
|
|
{
|
|
runScripts: 'dangerously',
|
|
},
|
|
);
|
|
window = jsdom.window;
|
|
document = jsdom.window.document;
|
|
container = document.getElementById('container');
|
|
|
|
buffer = '';
|
|
hasErrored = false;
|
|
|
|
writable = new Stream.PassThrough();
|
|
writable.setEncoding('utf8');
|
|
writable.on('data', chunk => {
|
|
buffer += chunk;
|
|
});
|
|
writable.on('error', error => {
|
|
hasErrored = true;
|
|
fatalError = error;
|
|
});
|
|
});
|
|
|
|
function expectErrors(errorsArr, toBeDevArr, toBeProdArr) {
|
|
const mappedErrows = errorsArr.map(({error, errorInfo}) => {
|
|
const stack = errorInfo && errorInfo.componentStack;
|
|
const digest = errorInfo && errorInfo.digest;
|
|
if (stack) {
|
|
return [error.message, digest, normalizeCodeLocInfo(stack)];
|
|
} else if (digest) {
|
|
return [error.message, digest];
|
|
}
|
|
return error.message;
|
|
});
|
|
if (__DEV__) {
|
|
expect(mappedErrows).toEqual(toBeDevArr);
|
|
} else {
|
|
expect(mappedErrows).toEqual(toBeProdArr);
|
|
}
|
|
}
|
|
|
|
function componentStack(components) {
|
|
return components
|
|
.map(component => `\n in ${component} (at **)`)
|
|
.join('');
|
|
}
|
|
|
|
async function act(callback) {
|
|
await callback();
|
|
// Await one turn around the event loop.
|
|
// This assumes that we'll flush everything we have so far.
|
|
await new Promise(resolve => {
|
|
setImmediate(resolve);
|
|
});
|
|
if (hasErrored) {
|
|
throw fatalError;
|
|
}
|
|
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
|
|
// We also want to execute any scripts that are embedded.
|
|
// We assume that we have now received a proper fragment of HTML.
|
|
const bufferedContent = buffer;
|
|
buffer = '';
|
|
const fakeBody = document.createElement('body');
|
|
fakeBody.innerHTML = bufferedContent;
|
|
while (fakeBody.firstChild) {
|
|
const node = fakeBody.firstChild;
|
|
if (
|
|
node.nodeName === 'SCRIPT' &&
|
|
(CSPnonce === null || node.getAttribute('nonce') === CSPnonce)
|
|
) {
|
|
const script = document.createElement('script');
|
|
script.textContent = node.textContent;
|
|
fakeBody.removeChild(node);
|
|
container.appendChild(script);
|
|
} else {
|
|
container.appendChild(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getVisibleChildren(element) {
|
|
const children = [];
|
|
let node = element.firstChild;
|
|
while (node) {
|
|
if (node.nodeType === 1) {
|
|
if (
|
|
node.tagName !== 'SCRIPT' &&
|
|
node.tagName !== 'TEMPLATE' &&
|
|
node.tagName !== 'template' &&
|
|
!node.hasAttribute('hidden') &&
|
|
!node.hasAttribute('aria-hidden')
|
|
) {
|
|
const props = {};
|
|
const attributes = node.attributes;
|
|
for (let i = 0; i < attributes.length; i++) {
|
|
if (
|
|
attributes[i].name === 'id' &&
|
|
attributes[i].value.includes(':')
|
|
) {
|
|
// We assume this is a React added ID that's a non-visual implementation detail.
|
|
continue;
|
|
}
|
|
props[attributes[i].name] = attributes[i].value;
|
|
}
|
|
props.children = getVisibleChildren(node);
|
|
children.push(React.createElement(node.tagName.toLowerCase(), props));
|
|
}
|
|
} else if (node.nodeType === 3) {
|
|
children.push(node.data);
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
return children.length === 0
|
|
? undefined
|
|
: children.length === 1
|
|
? children[0]
|
|
: children;
|
|
}
|
|
|
|
function resolveText(text) {
|
|
const record = textCache.get(text);
|
|
if (record === undefined) {
|
|
const newRecord = {
|
|
status: 'resolved',
|
|
value: text,
|
|
};
|
|
textCache.set(text, newRecord);
|
|
} else if (record.status === 'pending') {
|
|
const thenable = record.value;
|
|
record.status = 'resolved';
|
|
record.value = text;
|
|
thenable.pings.forEach(t => t());
|
|
}
|
|
}
|
|
|
|
function rejectText(text, error) {
|
|
const record = textCache.get(text);
|
|
if (record === undefined) {
|
|
const newRecord = {
|
|
status: 'rejected',
|
|
value: error,
|
|
};
|
|
textCache.set(text, newRecord);
|
|
} else if (record.status === 'pending') {
|
|
const thenable = record.value;
|
|
record.status = 'rejected';
|
|
record.value = error;
|
|
thenable.pings.forEach(t => t());
|
|
}
|
|
}
|
|
|
|
function readText(text) {
|
|
const record = textCache.get(text);
|
|
if (record !== undefined) {
|
|
switch (record.status) {
|
|
case 'pending':
|
|
throw record.value;
|
|
case 'rejected':
|
|
throw record.value;
|
|
case 'resolved':
|
|
return record.value;
|
|
}
|
|
} else {
|
|
const thenable = {
|
|
pings: [],
|
|
then(resolve) {
|
|
if (newRecord.status === 'pending') {
|
|
thenable.pings.push(resolve);
|
|
} else {
|
|
Promise.resolve().then(() => resolve(newRecord.value));
|
|
}
|
|
},
|
|
};
|
|
|
|
const newRecord = {
|
|
status: 'pending',
|
|
value: thenable,
|
|
};
|
|
textCache.set(text, newRecord);
|
|
|
|
throw thenable;
|
|
}
|
|
}
|
|
|
|
function Text({text}) {
|
|
return text;
|
|
}
|
|
|
|
function AsyncText({text}) {
|
|
return readText(text);
|
|
}
|
|
|
|
function AsyncTextWrapped({as, text}) {
|
|
const As = as;
|
|
return <As>{readText(text)}</As>;
|
|
}
|
|
|
|
// @gate experimental
|
|
it('should asynchronously load a lazy component', async () => {
|
|
let resolveA;
|
|
const LazyA = React.lazy(() => {
|
|
return new Promise(r => {
|
|
resolveA = r;
|
|
});
|
|
});
|
|
|
|
let resolveB;
|
|
const LazyB = React.lazy(() => {
|
|
return new Promise(r => {
|
|
resolveB = r;
|
|
});
|
|
});
|
|
|
|
function TextWithPunctuation({text, punctuation}) {
|
|
return <Text text={text + punctuation} />;
|
|
}
|
|
// This tests that default props of the inner element is resolved.
|
|
TextWithPunctuation.defaultProps = {
|
|
punctuation: '!',
|
|
};
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<LazyA text="Hello" />
|
|
</Suspense>
|
|
</div>
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<LazyB text="world" />
|
|
</Suspense>
|
|
</div>
|
|
</div>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<div>Loading...</div>
|
|
<div>Loading...</div>
|
|
</div>,
|
|
);
|
|
await act(async () => {
|
|
resolveA({default: Text});
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<div>Hello</div>
|
|
<div>Loading...</div>
|
|
</div>,
|
|
);
|
|
await act(async () => {
|
|
resolveB({default: TextWithPunctuation});
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<div>Hello</div>
|
|
<div>world!</div>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => {
|
|
const makeApp = () => {
|
|
let resolve;
|
|
const imports = new Promise(r => {
|
|
resolve = () => r({default: () => <span id="async">async</span>});
|
|
});
|
|
const Lazy = React.lazy(() => imports);
|
|
|
|
const App = () => (
|
|
<div>
|
|
<Suspense fallback={<span>Loading...</span>}>
|
|
<Lazy />
|
|
<span id="after">after</span>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
|
|
return [App, resolve];
|
|
};
|
|
|
|
// Server-side
|
|
const [App, resolve] = makeApp();
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span>Loading...</span>
|
|
</div>,
|
|
);
|
|
await act(async () => {
|
|
resolve();
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span id="async">async</span>
|
|
<span id="after">after</span>
|
|
</div>,
|
|
);
|
|
|
|
// Client-side
|
|
const [HydrateApp, hydrateResolve] = makeApp();
|
|
await act(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <HydrateApp />);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span id="async">async</span>
|
|
<span id="after">after</span>
|
|
</div>,
|
|
);
|
|
|
|
await act(async () => {
|
|
hydrateResolve();
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span id="async">async</span>
|
|
<span id="after">after</span>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should support nonce scripts', async () => {
|
|
CSPnonce = 'R4nd0m';
|
|
try {
|
|
let resolve;
|
|
const Lazy = React.lazy(() => {
|
|
return new Promise(r => {
|
|
resolve = r;
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<Lazy text="Hello" />
|
|
</Suspense>
|
|
</div>,
|
|
{nonce: 'R4nd0m'},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
await act(async () => {
|
|
resolve({default: Text});
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
} finally {
|
|
CSPnonce = null;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should client render a boundary if a lazy component rejects', async () => {
|
|
let rejectComponent;
|
|
const LazyComponent = React.lazy(() => {
|
|
return new Promise((resolve, reject) => {
|
|
rejectComponent = reject;
|
|
});
|
|
});
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
{isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let bootstrapped = false;
|
|
const errors = [];
|
|
window.__INIT__ = function() {
|
|
bootstrapped = true;
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
};
|
|
|
|
const theError = new Error('Test');
|
|
const loggedErrors = [];
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return 'Hash of (' + x.message + ')';
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App isClient={false} />,
|
|
{
|
|
bootstrapScriptContent: '__INIT__();',
|
|
onError,
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(loggedErrors).toEqual([]);
|
|
expect(bootstrapped).toBe(true);
|
|
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
await act(async () => {
|
|
rejectComponent(theError);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
// We haven't ran the client hydration yet.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// Now we can client render it instead.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack(['Lazy', 'Suspense', 'div', 'App']),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
|
|
// The client rendered HTML is now in place.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should asynchronously load a lazy element', async () => {
|
|
let resolveElement;
|
|
const lazyElement = React.lazy(() => {
|
|
return new Promise(r => {
|
|
resolveElement = r;
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
{lazyElement}
|
|
</Suspense>
|
|
</div>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
// Because there is no content inside the Suspense boundary that could've
|
|
// been written, we expect to not see any additional partial data flushed
|
|
// yet.
|
|
expect(container.firstChild.nextSibling).toBe(null);
|
|
await act(async () => {
|
|
resolveElement({default: <Text text="Hello" />});
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should client render a boundary if a lazy element rejects', async () => {
|
|
let rejectElement;
|
|
const element = <Text text="Hello" />;
|
|
const lazyElement = React.lazy(() => {
|
|
return new Promise((resolve, reject) => {
|
|
rejectElement = reject;
|
|
});
|
|
});
|
|
|
|
const theError = new Error('Test');
|
|
const loggedErrors = [];
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return 'hash of (' + x.message + ')';
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
{isClient ? element : lazyElement}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App isClient={false} />,
|
|
|
|
{
|
|
onError,
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
const errors = [];
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
await act(async () => {
|
|
rejectElement(theError);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
// We haven't ran the client hydration yet.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// Now we can client render it instead.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack(['Suspense', 'div', 'App']),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
|
|
// The client rendered HTML is now in place.
|
|
// expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('Errors in boundaries should be sent to the client and reported on client render - Error before flushing', async () => {
|
|
function Indirection({level, children}) {
|
|
if (level > 0) {
|
|
return <Indirection level={level - 1}>{children}</Indirection>;
|
|
}
|
|
return children;
|
|
}
|
|
|
|
const theError = new Error('uh oh');
|
|
|
|
function Erroring({isClient}) {
|
|
if (isClient) {
|
|
return 'Hello World';
|
|
}
|
|
throw theError;
|
|
}
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<span>loading...</span>}>
|
|
<Erroring isClient={isClient} />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return 'hash(' + x.message + ')';
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App />,
|
|
|
|
{
|
|
onError,
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
const errors = [];
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
|
|
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack(['Erroring', 'Suspense', 'div', 'App']),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('Errors in boundaries should be sent to the client and reported on client render - Error after flushing', async () => {
|
|
let rejectComponent;
|
|
const LazyComponent = React.lazy(() => {
|
|
return new Promise((resolve, reject) => {
|
|
rejectComponent = reject;
|
|
});
|
|
});
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
{isClient ? <Text text="Hello" /> : <LazyComponent text="Hello" />}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
const theError = new Error('uh oh');
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return 'hash(' + x.message + ')';
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App />,
|
|
|
|
{
|
|
onError,
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
const errors = [];
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
await act(async () => {
|
|
rejectComponent(theError);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// Now we can client render it instead.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack(['Lazy', 'Suspense', 'div', 'App']),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
|
|
// The client rendered HTML is now in place.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
expect(loggedErrors).toEqual([theError]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should asynchronously load the suspense boundary', async () => {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<AsyncText text="Hello World" />
|
|
</Suspense>
|
|
</div>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
await act(async () => {
|
|
resolveText('Hello World');
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello World</div>);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('waits for pending content to come in from the server and then hydrates it', async () => {
|
|
const ref = React.createRef();
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback="Loading...">
|
|
<h1 ref={ref}>
|
|
<AsyncText text="Hello" />
|
|
</h1>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
let bootstrapped = false;
|
|
window.__INIT__ = function() {
|
|
bootstrapped = true;
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
};
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
bootstrapScriptContent: '__INIT__();',
|
|
});
|
|
pipe(writable);
|
|
});
|
|
|
|
// We're still showing a fallback.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// We already bootstrapped.
|
|
expect(bootstrapped).toBe(true);
|
|
|
|
// Attempt to hydrate the content.
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// The server now updates the content in place in the fallback.
|
|
await act(async () => {
|
|
resolveText('Hello');
|
|
});
|
|
|
|
// The final HTML is now in place.
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>Hello</h1>
|
|
</div>,
|
|
);
|
|
const h1 = container.getElementsByTagName('h1')[0];
|
|
|
|
// But it is not yet hydrated.
|
|
expect(ref.current).toBe(null);
|
|
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// Now it's hydrated.
|
|
expect(ref.current).toBe(h1);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('handles an error on the client if the server ends up erroring', async () => {
|
|
const ref = React.createRef();
|
|
|
|
class ErrorBoundary extends React.Component {
|
|
state = {error: null};
|
|
static getDerivedStateFromError(error) {
|
|
return {error};
|
|
}
|
|
render() {
|
|
if (this.state.error) {
|
|
return <b ref={ref}>{this.state.error.message}</b>;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<ErrorBoundary>
|
|
<div>
|
|
<Suspense fallback="Loading...">
|
|
<span ref={ref}>
|
|
<AsyncText text="This Errors" />
|
|
</span>
|
|
</Suspense>
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
|
|
// We originally suspend the boundary and start streaming the loading state.
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App />,
|
|
|
|
{
|
|
onError(x) {
|
|
loggedErrors.push(x);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
// We're still showing a fallback.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
const theError = new Error('Error Message');
|
|
await act(async () => {
|
|
rejectText('This Errors', theError);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
// The server errored, but we still haven't hydrated. We don't know if the
|
|
// client will succeed yet, so we still show the loading state.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
expect(ref.current).toBe(null);
|
|
|
|
// Flush the hydration.
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// Hydrating should've generated an error and replaced the suspense boundary.
|
|
expect(getVisibleChildren(container)).toEqual(<b>Error Message</b>);
|
|
|
|
const b = container.getElementsByTagName('b')[0];
|
|
expect(ref.current).toBe(b);
|
|
});
|
|
|
|
// @gate enableSuspenseList
|
|
// @gate experimental
|
|
it('shows inserted items before pending in a SuspenseList as fallbacks while hydrating', async () => {
|
|
const ref = React.createRef();
|
|
|
|
// These are hoisted to avoid them from rerendering.
|
|
const a = (
|
|
<Suspense fallback="Loading A">
|
|
<span ref={ref}>
|
|
<AsyncText text="A" />
|
|
</span>
|
|
</Suspense>
|
|
);
|
|
const b = (
|
|
<Suspense fallback="Loading B">
|
|
<span>
|
|
<Text text="B" />
|
|
</span>
|
|
</Suspense>
|
|
);
|
|
|
|
function App({showMore}) {
|
|
return (
|
|
<SuspenseList revealOrder="forwards">
|
|
{a}
|
|
{b}
|
|
{showMore ? (
|
|
<Suspense fallback="Loading C">
|
|
<span>C</span>
|
|
</Suspense>
|
|
) : null}
|
|
</SuspenseList>
|
|
);
|
|
}
|
|
|
|
// We originally suspend the boundary and start streaming the loading state.
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App showMore={false} />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
container,
|
|
<App showMore={false} />,
|
|
);
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're not hydrated yet.
|
|
expect(ref.current).toBe(null);
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
'Loading A',
|
|
// TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
|
|
// isn't implemented fully yet.
|
|
<span>B</span>,
|
|
]);
|
|
|
|
// Add more rows before we've hydrated the first two.
|
|
root.render(<App showMore={true} />);
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're not hydrated yet.
|
|
expect(ref.current).toBe(null);
|
|
|
|
// We haven't resolved yet.
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
'Loading A',
|
|
// TODO: This is incorrect. It should be "Loading B" but Fizz SuspenseList
|
|
// isn't implemented fully yet.
|
|
<span>B</span>,
|
|
'Loading C',
|
|
]);
|
|
|
|
await act(async () => {
|
|
await resolveText('A');
|
|
});
|
|
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
<span>A</span>,
|
|
<span>B</span>,
|
|
<span>C</span>,
|
|
]);
|
|
|
|
const span = container.getElementsByTagName('span')[0];
|
|
expect(ref.current).toBe(span);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('client renders a boundary if it does not resolve before aborting', async () => {
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback="Loading...">
|
|
<h1>
|
|
<AsyncText text="Hello" />
|
|
</h1>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
const expectedDigest = 'Hash for Abort';
|
|
function onError(error) {
|
|
loggedErrors.push(error);
|
|
return expectedDigest;
|
|
}
|
|
|
|
let controls;
|
|
await act(async () => {
|
|
controls = ReactDOMFizzServer.renderToPipeableStream(<App />, {onError});
|
|
controls.pipe(writable);
|
|
});
|
|
|
|
// We're still showing a fallback.
|
|
|
|
const errors = [];
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// We abort the server response.
|
|
await act(async () => {
|
|
controls.abort();
|
|
});
|
|
|
|
// We still can't render it on the client.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expectErrors(
|
|
errors,
|
|
[['This Suspense boundary was aborted by the server.', expectedDigest]],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// We now resolve it on the client.
|
|
resolveText('Hello');
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// The client rendered HTML is now in place.
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>Hello</h1>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should allow for two containers to be written to the same document', async () => {
|
|
// We create two passthrough streams for each container to write into.
|
|
// Notably we don't implement a end() call for these. Because we don't want to
|
|
// close the underlying stream just because one of the streams is done. Instead
|
|
// we manually close when both are done.
|
|
const writableA = new Stream.Writable();
|
|
writableA._write = (chunk, encoding, next) => {
|
|
writable.write(chunk, encoding, next);
|
|
};
|
|
const writableB = new Stream.Writable();
|
|
writableB._write = (chunk, encoding, next) => {
|
|
writable.write(chunk, encoding, next);
|
|
};
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
// We use two nested boundaries to flush out coverage of an old reentrancy bug.
|
|
<Suspense fallback="Loading...">
|
|
<Suspense fallback={<Text text="Loading A..." />}>
|
|
<>
|
|
<Text text="This will show A: " />
|
|
<div>
|
|
<AsyncText text="A" />
|
|
</div>
|
|
</>
|
|
</Suspense>
|
|
</Suspense>,
|
|
{
|
|
identifierPrefix: 'A_',
|
|
onShellReady() {
|
|
writableA.write('<div id="container-A">');
|
|
pipe(writableA);
|
|
writableA.write('</div>');
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<Suspense fallback={<Text text="Loading B..." />}>
|
|
<Text text="This will show B: " />
|
|
<div>
|
|
<AsyncText text="B" />
|
|
</div>
|
|
</Suspense>,
|
|
{
|
|
identifierPrefix: 'B_',
|
|
onShellReady() {
|
|
writableB.write('<div id="container-B">');
|
|
pipe(writableB);
|
|
writableB.write('</div>');
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
<div id="container-A">Loading A...</div>,
|
|
<div id="container-B">Loading B...</div>,
|
|
]);
|
|
|
|
await act(async () => {
|
|
resolveText('B');
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
<div id="container-A">Loading A...</div>,
|
|
<div id="container-B">
|
|
This will show B: <div>B</div>
|
|
</div>,
|
|
]);
|
|
|
|
await act(async () => {
|
|
resolveText('A');
|
|
});
|
|
|
|
// We're done writing both streams now.
|
|
writable.end();
|
|
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
<div id="container-A">
|
|
This will show A: <div>A</div>
|
|
</div>,
|
|
<div id="container-B">
|
|
This will show B: <div>B</div>
|
|
</div>,
|
|
]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('can resolve async content in esoteric parents', async () => {
|
|
function AsyncOption({text}) {
|
|
return <option>{readText(text)}</option>;
|
|
}
|
|
|
|
function AsyncCol({className}) {
|
|
return <col className={readText(className)} />;
|
|
}
|
|
|
|
function AsyncPath({id}) {
|
|
return <path id={readText(id)} />;
|
|
}
|
|
|
|
function AsyncMi({id}) {
|
|
return <mi id={readText(id)} />;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<select>
|
|
<Suspense fallback="Loading...">
|
|
<AsyncOption text="Hello" />
|
|
</Suspense>
|
|
</select>
|
|
<Suspense fallback="Loading...">
|
|
<table>
|
|
<colgroup>
|
|
<AsyncCol className="World" />
|
|
</colgroup>
|
|
</table>
|
|
<svg>
|
|
<g>
|
|
<AsyncPath id="my-path" />
|
|
</g>
|
|
</svg>
|
|
<math>
|
|
<AsyncMi id="my-mi" />
|
|
</math>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<select>Loading...</select>Loading...
|
|
</div>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('Hello');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveText('World');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveText('my-path');
|
|
resolveText('my-mi');
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<select>
|
|
<option>Hello</option>
|
|
</select>
|
|
<table>
|
|
<colgroup>
|
|
<col class="World" />
|
|
</colgroup>
|
|
</table>
|
|
<svg>
|
|
<g>
|
|
<path id="my-path" />
|
|
</g>
|
|
</svg>
|
|
<math>
|
|
<mi id="my-mi" />
|
|
</math>
|
|
</div>,
|
|
);
|
|
|
|
expect(container.querySelector('#my-path').namespaceURI).toBe(
|
|
'http://www.w3.org/2000/svg',
|
|
);
|
|
expect(container.querySelector('#my-mi').namespaceURI).toBe(
|
|
'http://www.w3.org/1998/Math/MathML',
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('can resolve async content in table parents', async () => {
|
|
function AsyncTableBody({className, children}) {
|
|
return <tbody className={readText(className)}>{children}</tbody>;
|
|
}
|
|
|
|
function AsyncTableRow({className, children}) {
|
|
return <tr className={readText(className)}>{children}</tr>;
|
|
}
|
|
|
|
function AsyncTableCell({text}) {
|
|
return <td>{readText(text)}</td>;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<table>
|
|
<Suspense
|
|
fallback={
|
|
<tbody>
|
|
<tr>
|
|
<td>Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
}>
|
|
<AsyncTableBody className="A">
|
|
<AsyncTableRow className="B">
|
|
<AsyncTableCell text="C" />
|
|
</AsyncTableRow>
|
|
</AsyncTableBody>
|
|
</Suspense>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<table>
|
|
<tbody>
|
|
<tr>
|
|
<td>Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('A');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveText('B');
|
|
});
|
|
|
|
await act(async () => {
|
|
resolveText('C');
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<table>
|
|
<tbody class="A">
|
|
<tr class="B">
|
|
<td>C</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('can stream into an SVG container', async () => {
|
|
function AsyncPath({id}) {
|
|
return <path id={readText(id)} />;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<g>
|
|
<Suspense fallback={<text>Loading...</text>}>
|
|
<AsyncPath id="my-path" />
|
|
</Suspense>
|
|
</g>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App />,
|
|
|
|
{
|
|
namespaceURI: 'http://www.w3.org/2000/svg',
|
|
onShellReady() {
|
|
writable.write('<svg>');
|
|
pipe(writable);
|
|
writable.write('</svg>');
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<svg>
|
|
<g>
|
|
<text>Loading...</text>
|
|
</g>
|
|
</svg>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('my-path');
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<svg>
|
|
<g>
|
|
<path id="my-path" />
|
|
</g>
|
|
</svg>,
|
|
);
|
|
|
|
expect(container.querySelector('#my-path').namespaceURI).toBe(
|
|
'http://www.w3.org/2000/svg',
|
|
);
|
|
});
|
|
|
|
function normalizeCodeLocInfo(str) {
|
|
return (
|
|
str &&
|
|
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
|
|
return '\n in ' + name + ' (at **)';
|
|
})
|
|
);
|
|
}
|
|
|
|
// @gate experimental
|
|
it('should include a component stack across suspended boundaries', async () => {
|
|
function B() {
|
|
const children = [readText('Hello'), readText('World')];
|
|
// Intentionally trigger a key warning here.
|
|
return (
|
|
<div>
|
|
{children.map(t => (
|
|
<span>{t}</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
function C() {
|
|
return (
|
|
<inCorrectTag>
|
|
<Text text="Loading" />
|
|
</inCorrectTag>
|
|
);
|
|
}
|
|
function A() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<C />}>
|
|
<B />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// We can't use the toErrorDev helper here because this is an async act.
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
|
|
try {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<A />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<incorrecttag>Loading</incorrecttag>
|
|
</div>,
|
|
);
|
|
|
|
if (__DEV__) {
|
|
expect(mockError).toHaveBeenCalledWith(
|
|
'Warning: <%s /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.%s',
|
|
'inCorrectTag',
|
|
'\n' +
|
|
' in inCorrectTag (at **)\n' +
|
|
' in C (at **)\n' +
|
|
' in Suspense (at **)\n' +
|
|
' in div (at **)\n' +
|
|
' in A (at **)',
|
|
);
|
|
mockError.mockClear();
|
|
} else {
|
|
expect(mockError).not.toHaveBeenCalled();
|
|
}
|
|
|
|
await act(async () => {
|
|
resolveText('Hello');
|
|
resolveText('World');
|
|
});
|
|
|
|
if (__DEV__) {
|
|
expect(mockError).toHaveBeenCalledWith(
|
|
'Warning: Each child in a list should have a unique "key" prop.%s%s' +
|
|
' See https://reactjs.org/link/warning-keys for more information.%s',
|
|
'\n\nCheck the top-level render call using <div>.',
|
|
'',
|
|
'\n' +
|
|
' in span (at **)\n' +
|
|
' in B (at **)\n' +
|
|
' in Suspense (at **)\n' +
|
|
' in div (at **)\n' +
|
|
' in A (at **)',
|
|
);
|
|
} else {
|
|
expect(mockError).not.toHaveBeenCalled();
|
|
}
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<div>
|
|
<span>Hello</span>
|
|
<span>World</span>
|
|
</div>
|
|
</div>,
|
|
);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should can suspend in a class component with legacy context', async () => {
|
|
class TestProvider extends React.Component {
|
|
static childContextTypes = {
|
|
test: PropTypes.string,
|
|
};
|
|
state = {ctxToSet: null};
|
|
static getDerivedStateFromProps(props, state) {
|
|
return {ctxToSet: props.ctx};
|
|
}
|
|
getChildContext() {
|
|
return {
|
|
test: this.state.ctxToSet,
|
|
};
|
|
}
|
|
render() {
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
class TestConsumer extends React.Component {
|
|
static contextTypes = {
|
|
test: PropTypes.string,
|
|
};
|
|
render() {
|
|
const child = (
|
|
<b>
|
|
<Text text={this.context.test} />
|
|
</b>
|
|
);
|
|
if (this.props.prefix) {
|
|
return [readText(this.props.prefix), child];
|
|
}
|
|
return child;
|
|
}
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<TestProvider ctx="A">
|
|
<div>
|
|
<Suspense fallback={[<Text text="Loading: " />, <TestConsumer />]}>
|
|
<TestProvider ctx="B">
|
|
<TestConsumer prefix="Hello: " />
|
|
</TestProvider>
|
|
<TestConsumer />
|
|
</Suspense>
|
|
</div>
|
|
</TestProvider>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Loading: <b>A</b>
|
|
</div>,
|
|
);
|
|
await act(async () => {
|
|
resolveText('Hello: ');
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Hello: <b>B</b>
|
|
<b>A</b>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should resume the context from where it left off', async () => {
|
|
const ContextA = React.createContext('A0');
|
|
const ContextB = React.createContext('B0');
|
|
|
|
function PrintA() {
|
|
return (
|
|
<ContextA.Consumer>{value => <Text text={value} />}</ContextA.Consumer>
|
|
);
|
|
}
|
|
|
|
class PrintB extends React.Component {
|
|
static contextType = ContextB;
|
|
render() {
|
|
return <Text text={this.context} />;
|
|
}
|
|
}
|
|
|
|
function AsyncParent({text, children}) {
|
|
return (
|
|
<>
|
|
<AsyncText text={text} />
|
|
<b>{children}</b>
|
|
</>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<PrintA />
|
|
<div>
|
|
<ContextA.Provider value="A0.1">
|
|
<Suspense fallback={<Text text="Loading..." />}>
|
|
<AsyncParent text="Child:">
|
|
<PrintA />
|
|
</AsyncParent>
|
|
<PrintB />
|
|
</Suspense>
|
|
</ContextA.Provider>
|
|
</div>
|
|
<PrintA />
|
|
</div>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
A0<div>Loading...</div>A0
|
|
</div>,
|
|
);
|
|
await act(async () => {
|
|
resolveText('Child:');
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
A0
|
|
<div>
|
|
Child:<b>A0.1</b>B0
|
|
</div>
|
|
A0
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should recover the outer context when an error happens inside a provider', async () => {
|
|
const ContextA = React.createContext('A0');
|
|
const ContextB = React.createContext('B0');
|
|
|
|
function PrintA() {
|
|
return (
|
|
<ContextA.Consumer>{value => <Text text={value} />}</ContextA.Consumer>
|
|
);
|
|
}
|
|
|
|
class PrintB extends React.Component {
|
|
static contextType = ContextB;
|
|
render() {
|
|
return <Text text={this.context} />;
|
|
}
|
|
}
|
|
|
|
function Throws() {
|
|
const value = React.useContext(ContextA);
|
|
throw new Error(value);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<div>
|
|
<PrintA />
|
|
<div>
|
|
<ContextA.Provider value="A0.1">
|
|
<Suspense
|
|
fallback={
|
|
<b>
|
|
<Text text="Loading..." />
|
|
</b>
|
|
}>
|
|
<ContextA.Provider value="A0.1.1">
|
|
<Throws />
|
|
</ContextA.Provider>
|
|
</Suspense>
|
|
<PrintB />
|
|
</ContextA.Provider>
|
|
</div>
|
|
<PrintA />
|
|
</div>,
|
|
|
|
{
|
|
onError(x) {
|
|
loggedErrors.push(x);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(loggedErrors.length).toBe(1);
|
|
expect(loggedErrors[0].message).toEqual('A0.1.1');
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
A0
|
|
<div>
|
|
<b>Loading...</b>B0
|
|
</div>
|
|
A0
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('client renders a boundary if it errors before finishing the fallback', async () => {
|
|
function App({isClient}) {
|
|
return (
|
|
<Suspense fallback="Loading root...">
|
|
<div>
|
|
<Suspense fallback={<AsyncText text="Loading..." />}>
|
|
<h1>
|
|
{isClient ? <Text text="Hello" /> : <AsyncText text="Hello" />}
|
|
</h1>
|
|
</Suspense>
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
const theError = new Error('Test');
|
|
const loggedErrors = [];
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return `hash of (${x.message})`;
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
let controls;
|
|
await act(async () => {
|
|
controls = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App isClient={false} />,
|
|
|
|
{
|
|
onError,
|
|
},
|
|
);
|
|
controls.pipe(writable);
|
|
});
|
|
|
|
// We're still showing a fallback.
|
|
|
|
const errors = [];
|
|
// Attempt to hydrate the content.
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
// We're still loading because we're waiting for the server to stream more content.
|
|
expect(getVisibleChildren(container)).toEqual('Loading root...');
|
|
|
|
expect(loggedErrors).toEqual([]);
|
|
|
|
// Error the content, but we don't have a fallback yet.
|
|
await act(async () => {
|
|
rejectText('Hello', theError);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
// We still can't render it on the client because we haven't unblocked the parent.
|
|
Scheduler.unstable_flushAll();
|
|
expect(getVisibleChildren(container)).toEqual('Loading root...');
|
|
|
|
// Unblock the loading state
|
|
await act(async () => {
|
|
resolveText('Loading...');
|
|
});
|
|
|
|
// Now we're able to show the inner boundary.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
|
|
|
|
// That will let us client render it instead.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack([
|
|
'AsyncText',
|
|
'h1',
|
|
'Suspense',
|
|
'div',
|
|
'Suspense',
|
|
'App',
|
|
]),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
|
|
// The client rendered HTML is now in place.
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>Hello</h1>
|
|
</div>,
|
|
);
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should be able to abort the fallback if the main content finishes first', async () => {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<Suspense fallback={<Text text="Loading Outer" />}>
|
|
<div>
|
|
<Suspense
|
|
fallback={
|
|
<div>
|
|
<AsyncText text="Loading" />
|
|
Inner
|
|
</div>
|
|
}>
|
|
<AsyncText text="Hello" />
|
|
</Suspense>
|
|
</div>
|
|
</Suspense>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual('Loading Outer');
|
|
// We should have received a partial segment containing the a partial of the fallback.
|
|
expect(container.innerHTML).toContain('Inner');
|
|
await act(async () => {
|
|
resolveText('Hello');
|
|
});
|
|
// We should've been able to display the content without waiting for the rest of the fallback.
|
|
expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
|
|
});
|
|
|
|
// @gate experimental && enableSuspenseAvoidThisFallbackFizz
|
|
it('should respect unstable_avoidThisFallback', async () => {
|
|
const resolved = {
|
|
0: false,
|
|
1: false,
|
|
};
|
|
const promiseRes = {};
|
|
const promises = {
|
|
0: new Promise(res => {
|
|
promiseRes[0] = () => {
|
|
resolved[0] = true;
|
|
res();
|
|
};
|
|
}),
|
|
1: new Promise(res => {
|
|
promiseRes[1] = () => {
|
|
resolved[1] = true;
|
|
res();
|
|
};
|
|
}),
|
|
};
|
|
|
|
const InnerComponent = ({isClient, depth}) => {
|
|
if (isClient) {
|
|
// Resuspend after re-rendering on client to check that fallback shows on client
|
|
throw new Promise(() => {});
|
|
}
|
|
if (!resolved[depth]) {
|
|
throw promises[depth];
|
|
}
|
|
return (
|
|
<div>
|
|
<Text text={`resolved ${depth}`} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Text text="Non Suspense Content" />
|
|
<Suspense
|
|
fallback={
|
|
<span>
|
|
<Text text="Avoided Fallback" />
|
|
</span>
|
|
}
|
|
unstable_avoidThisFallback={true}>
|
|
<InnerComponent isClient={isClient} depth={0} />
|
|
<div>
|
|
<Suspense fallback={<Text text="Fallback" />}>
|
|
<Suspense
|
|
fallback={
|
|
<span>
|
|
<Text text="Avoided Fallback2" />
|
|
</span>
|
|
}
|
|
unstable_avoidThisFallback={true}>
|
|
<InnerComponent isClient={isClient} depth={1} />
|
|
</Suspense>
|
|
</Suspense>
|
|
</div>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await jest.runAllTimers();
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App isClient={false} />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
// Nothing is output since root has a suspense with avoidedThisFallback that hasn't resolved
|
|
expect(getVisibleChildren(container)).toEqual(undefined);
|
|
expect(container.innerHTML).not.toContain('Avoided Fallback');
|
|
|
|
// resolve first suspense component with avoidThisFallback
|
|
await act(async () => {
|
|
promiseRes[0]();
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Non Suspense Content
|
|
<div>resolved 0</div>
|
|
<div>Fallback</div>
|
|
</div>,
|
|
);
|
|
|
|
expect(container.innerHTML).not.toContain('Avoided Fallback2');
|
|
|
|
await act(async () => {
|
|
promiseRes[1]();
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Non Suspense Content
|
|
<div>resolved 0</div>
|
|
<div>
|
|
<div>resolved 1</div>
|
|
</div>
|
|
</div>,
|
|
);
|
|
|
|
let root;
|
|
await act(async () => {
|
|
root = ReactDOMClient.hydrateRoot(container, <App isClient={false} />);
|
|
Scheduler.unstable_flushAll();
|
|
await jest.runAllTimers();
|
|
});
|
|
|
|
// No change after hydration
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Non Suspense Content
|
|
<div>resolved 0</div>
|
|
<div>
|
|
<div>resolved 1</div>
|
|
</div>
|
|
</div>,
|
|
);
|
|
|
|
await act(async () => {
|
|
// Trigger update by changing isClient to true
|
|
root.render(<App isClient={true} />);
|
|
Scheduler.unstable_flushAll();
|
|
await jest.runAllTimers();
|
|
});
|
|
|
|
// Now that we've resuspended at the root we show the root fallback
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
Non Suspense Content
|
|
<div style="display: none;">resolved 0</div>
|
|
<div style="display: none;">
|
|
<div>resolved 1</div>
|
|
</div>
|
|
<span>Avoided Fallback</span>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('calls getServerSnapshot instead of getSnapshot', async () => {
|
|
const ref = React.createRef();
|
|
|
|
function getServerSnapshot() {
|
|
return 'server';
|
|
}
|
|
|
|
function getClientSnapshot() {
|
|
return 'client';
|
|
}
|
|
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
|
|
function Child({text}) {
|
|
Scheduler.unstable_yieldValue(text);
|
|
return text;
|
|
}
|
|
|
|
function App() {
|
|
const value = useSyncExternalStore(
|
|
subscribe,
|
|
getClientSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
return (
|
|
<div ref={ref}>
|
|
<Child text={value} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<Suspense fallback="Loading...">
|
|
<App />
|
|
</Suspense>,
|
|
{
|
|
onError(x) {
|
|
loggedErrors.push(x);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['server']);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Log recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
|
|
expect(() => {
|
|
// The first paint switches to client rendering due to mismatch
|
|
expect(Scheduler).toFlushUntilNextPaint([
|
|
'client',
|
|
'Log recoverable error: Hydration failed because the initial ' +
|
|
'UI does not match what was rendered on the server.',
|
|
'Log recoverable error: There was an error while hydrating. ' +
|
|
'Because the error happened outside of a Suspense boundary, the ' +
|
|
'entire root will switch to client rendering.',
|
|
]);
|
|
}).toErrorDev(
|
|
[
|
|
'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
|
|
'Warning: Expected server HTML to contain a matching <div> in <div>.\n' +
|
|
' in div (at **)\n' +
|
|
' in App (at **)',
|
|
],
|
|
{withoutStack: 1},
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
|
|
});
|
|
|
|
// The selector implementation uses the lazy ref initialization pattern
|
|
// @gate experimental
|
|
it('calls getServerSnapshot instead of getSnapshot (with selector and isEqual)', async () => {
|
|
// Same as previous test, but with a selector that returns a complex object
|
|
// that is memoized with a custom `isEqual` function.
|
|
const ref = React.createRef();
|
|
function getServerSnapshot() {
|
|
return {env: 'server', other: 'unrelated'};
|
|
}
|
|
function getClientSnapshot() {
|
|
return {env: 'client', other: 'unrelated'};
|
|
}
|
|
function selector({env}) {
|
|
return {env};
|
|
}
|
|
function isEqual(a, b) {
|
|
return a.env === b.env;
|
|
}
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
function Child({text}) {
|
|
Scheduler.unstable_yieldValue(text);
|
|
return text;
|
|
}
|
|
function App() {
|
|
const {env} = useSyncExternalStoreWithSelector(
|
|
subscribe,
|
|
getClientSnapshot,
|
|
getServerSnapshot,
|
|
selector,
|
|
isEqual,
|
|
);
|
|
return (
|
|
<div ref={ref}>
|
|
<Child text={env} />
|
|
</div>
|
|
);
|
|
}
|
|
const loggedErrors = [];
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<Suspense fallback="Loading...">
|
|
<App />
|
|
</Suspense>,
|
|
{
|
|
onError(x) {
|
|
loggedErrors.push(x);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['server']);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Log recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
|
|
// The first paint uses the client due to mismatch forcing client render
|
|
expect(() => {
|
|
// The first paint switches to client rendering due to mismatch
|
|
expect(Scheduler).toFlushUntilNextPaint([
|
|
'client',
|
|
'Log recoverable error: Hydration failed because the initial ' +
|
|
'UI does not match what was rendered on the server.',
|
|
'Log recoverable error: There was an error while hydrating. ' +
|
|
'Because the error happened outside of a Suspense boundary, the ' +
|
|
'entire root will switch to client rendering.',
|
|
]);
|
|
}).toErrorDev(
|
|
[
|
|
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
|
|
'Warning: Expected server HTML to contain a matching <div> in <div>.\n' +
|
|
' in div (at **)\n' +
|
|
' in App (at **)',
|
|
],
|
|
{withoutStack: 1},
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
|
|
});
|
|
|
|
// @gate experimental
|
|
it(
|
|
'errors during hydration in the shell force a client render at the ' +
|
|
'root, and during the client render it recovers',
|
|
async () => {
|
|
let isClient = false;
|
|
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
function getClientSnapshot() {
|
|
return 'Yay!';
|
|
}
|
|
|
|
// At the time of writing, the only API that exposes whether it's currently
|
|
// hydrating is the `getServerSnapshot` API, so I'm using that here to
|
|
// simulate an error during hydration.
|
|
function getServerSnapshot() {
|
|
if (isClient) {
|
|
throw new Error('Hydration error');
|
|
}
|
|
return 'Yay!';
|
|
}
|
|
|
|
function Child() {
|
|
const value = useSyncExternalStore(
|
|
subscribe,
|
|
getClientSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
Scheduler.unstable_yieldValue(value);
|
|
return value;
|
|
}
|
|
|
|
const spanRef = React.createRef();
|
|
|
|
function App() {
|
|
return (
|
|
<span ref={spanRef}>
|
|
<Child />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['Yay!']);
|
|
|
|
const span = container.getElementsByTagName('span')[0];
|
|
|
|
// Hydrate the tree. Child will throw during hydration, but not when it
|
|
// falls back to client rendering.
|
|
isClient = true;
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(error.message);
|
|
},
|
|
});
|
|
|
|
// An error logged but instead of surfacing it to the UI, we switched
|
|
// to client rendering.
|
|
expect(() => {
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Yay!',
|
|
'Hydration error',
|
|
'There was an error while hydrating. Because the error happened ' +
|
|
'outside of a Suspense boundary, the entire root will switch ' +
|
|
'to client rendering.',
|
|
]);
|
|
}).toErrorDev(
|
|
'An error occurred during hydration. The server HTML was replaced',
|
|
{withoutStack: true},
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(<span>Yay!</span>);
|
|
|
|
// The node that's inside the boundary that errored during hydration was
|
|
// not hydrated.
|
|
expect(spanRef.current).not.toBe(span);
|
|
},
|
|
);
|
|
|
|
// @gate experimental
|
|
it(
|
|
'errors during hydration force a client render at the nearest Suspense ' +
|
|
'boundary, and during the client render it recovers',
|
|
async () => {
|
|
let isClient = false;
|
|
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
function getClientSnapshot() {
|
|
return 'Yay!';
|
|
}
|
|
|
|
// At the time of writing, the only API that exposes whether it's currently
|
|
// hydrating is the `getServerSnapshot` API, so I'm using that here to
|
|
// simulate an error during hydration.
|
|
function getServerSnapshot() {
|
|
if (isClient) {
|
|
throw new Error('Hydration error');
|
|
}
|
|
return 'Yay!';
|
|
}
|
|
|
|
function Child() {
|
|
const value = useSyncExternalStore(
|
|
subscribe,
|
|
getClientSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
Scheduler.unstable_yieldValue(value);
|
|
return value;
|
|
}
|
|
|
|
const span1Ref = React.createRef();
|
|
const span2Ref = React.createRef();
|
|
const span3Ref = React.createRef();
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<span ref={span1Ref} />
|
|
<Suspense fallback="Loading...">
|
|
<span ref={span2Ref}>
|
|
<Child />
|
|
</span>
|
|
</Suspense>
|
|
<span ref={span3Ref} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['Yay!']);
|
|
|
|
const [span1, span2, span3] = container.getElementsByTagName('span');
|
|
|
|
// Hydrate the tree. Child will throw during hydration, but not when it
|
|
// falls back to client rendering.
|
|
isClient = true;
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(error.message);
|
|
},
|
|
});
|
|
|
|
// An error logged but instead of surfacing it to the UI, we switched
|
|
// to client rendering.
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Yay!',
|
|
'Hydration error',
|
|
'There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span />
|
|
<span>Yay!</span>
|
|
<span />
|
|
</div>,
|
|
);
|
|
|
|
// The node that's inside the boundary that errored during hydration was
|
|
// not hydrated.
|
|
expect(span2Ref.current).not.toBe(span2);
|
|
|
|
// But the nodes outside the boundary were.
|
|
expect(span1Ref.current).toBe(span1);
|
|
expect(span3Ref.current).toBe(span3);
|
|
},
|
|
);
|
|
|
|
// @gate experimental
|
|
it(
|
|
'errors during hydration force a client render at the nearest Suspense ' +
|
|
'boundary, and during the client render it fails again',
|
|
async () => {
|
|
// Similar to previous test, but the client render errors, too. We should
|
|
// be able to capture it with an error boundary.
|
|
|
|
let isClient = false;
|
|
|
|
class ErrorBoundary extends React.Component {
|
|
state = {error: null};
|
|
static getDerivedStateFromError(error) {
|
|
return {error};
|
|
}
|
|
render() {
|
|
if (this.state.error !== null) {
|
|
return this.state.error.message;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function Child() {
|
|
if (isClient) {
|
|
throw new Error('Oops!');
|
|
}
|
|
Scheduler.unstable_yieldValue('Yay!');
|
|
return 'Yay!';
|
|
}
|
|
|
|
const span1Ref = React.createRef();
|
|
const span2Ref = React.createRef();
|
|
const span3Ref = React.createRef();
|
|
|
|
function App() {
|
|
return (
|
|
<ErrorBoundary>
|
|
<span ref={span1Ref} />
|
|
<Suspense fallback="Loading...">
|
|
<span ref={span2Ref}>
|
|
<Child />
|
|
</span>
|
|
</Suspense>
|
|
<span ref={span3Ref} />
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['Yay!']);
|
|
|
|
// Hydrate the tree. Child will throw during render.
|
|
isClient = true;
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
|
|
// Because we failed to recover from the error, onRecoverableError
|
|
// shouldn't be called.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(getVisibleChildren(container)).toEqual('Oops!');
|
|
|
|
expectErrors(errors, [], []);
|
|
},
|
|
);
|
|
|
|
// Disabled because of a WWW late mutations regression.
|
|
// We may want to re-enable this if we figure out why.
|
|
// @gate experimental
|
|
// @gate FIXME
|
|
it('does not recreate the fallback if server errors and hydration suspends', async () => {
|
|
let isClient = false;
|
|
|
|
function Child() {
|
|
if (isClient) {
|
|
readText('Yay!');
|
|
} else {
|
|
throw Error('Oops.');
|
|
}
|
|
Scheduler.unstable_yieldValue('Yay!');
|
|
return 'Yay!';
|
|
}
|
|
|
|
const fallbackRef = React.createRef();
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
|
|
<span>
|
|
<Child />
|
|
</span>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
onError(error) {
|
|
Scheduler.unstable_yieldValue('[s!] ' + error.message);
|
|
},
|
|
});
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['[s!] Oops.']);
|
|
|
|
// The server could not complete this boundary, so we'll retry on the client.
|
|
const serverFallback = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback.innerHTML).toBe('Loading...');
|
|
|
|
// Hydrate the tree. This will suspend.
|
|
isClient = true;
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue('[c!] ' + error.message);
|
|
},
|
|
});
|
|
// This should not report any errors yet.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>Loading...</p>
|
|
</div>,
|
|
);
|
|
|
|
// Normally, hydrating after server error would force a clean client render.
|
|
// However, it suspended so at best we'd only get the same fallback anyway.
|
|
// We don't want to recreate the same fallback in the DOM again because
|
|
// that's extra work and would restart animations etc. Check we don't do that.
|
|
const clientFallback = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback).toBe(clientFallback);
|
|
|
|
// When we're able to fully hydrate, we expect a clean client render.
|
|
await act(async () => {
|
|
resolveText('Yay!');
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Yay!',
|
|
'[c!] The server could not finish this Suspense boundary, ' +
|
|
'likely due to an error during server rendering. ' +
|
|
'Switched to client rendering.',
|
|
]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span>Yay!</span>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// Disabled because of a WWW late mutations regression.
|
|
// We may want to re-enable this if we figure out why.
|
|
// @gate experimental
|
|
// @gate FIXME
|
|
it(
|
|
'does not recreate the fallback if server errors and hydration suspends ' +
|
|
'and root receives a transition',
|
|
async () => {
|
|
let isClient = false;
|
|
|
|
function Child({color}) {
|
|
if (isClient) {
|
|
readText('Yay!');
|
|
} else {
|
|
throw Error('Oops.');
|
|
}
|
|
Scheduler.unstable_yieldValue('Yay! (' + color + ')');
|
|
return 'Yay! (' + color + ')';
|
|
}
|
|
|
|
const fallbackRef = React.createRef();
|
|
function App({color}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<p ref={fallbackRef}>Loading...</p>}>
|
|
<span>
|
|
<Child color={color} />
|
|
</span>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App color="red" />,
|
|
{
|
|
onError(error) {
|
|
Scheduler.unstable_yieldValue('[s!] ' + error.message);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['[s!] Oops.']);
|
|
|
|
// The server could not complete this boundary, so we'll retry on the client.
|
|
const serverFallback = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback.innerHTML).toBe('Loading...');
|
|
|
|
// Hydrate the tree. This will suspend.
|
|
isClient = true;
|
|
const root = ReactDOMClient.hydrateRoot(container, <App color="red" />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue('[c!] ' + error.message);
|
|
},
|
|
});
|
|
// This should not report any errors yet.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>Loading...</p>
|
|
</div>,
|
|
);
|
|
|
|
// Normally, hydrating after server error would force a clean client render.
|
|
// However, it suspended so at best we'd only get the same fallback anyway.
|
|
// We don't want to recreate the same fallback in the DOM again because
|
|
// that's extra work and would restart animations etc. Check we don't do that.
|
|
const clientFallback = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback).toBe(clientFallback);
|
|
|
|
// Transition updates shouldn't recreate the fallback either.
|
|
React.startTransition(() => {
|
|
root.render(<App color="blue" />);
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
jest.runAllTimers();
|
|
const clientFallback2 = container.getElementsByTagName('p')[0];
|
|
expect(clientFallback2).toBe(serverFallback);
|
|
|
|
// When we're able to fully hydrate, we expect a clean client render.
|
|
await act(async () => {
|
|
resolveText('Yay!');
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Yay! (red)',
|
|
'[c!] The server could not finish this Suspense boundary, ' +
|
|
'likely due to an error during server rendering. ' +
|
|
'Switched to client rendering.',
|
|
'Yay! (blue)',
|
|
]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span>Yay! (blue)</span>
|
|
</div>,
|
|
);
|
|
},
|
|
);
|
|
|
|
// Disabled because of a WWW late mutations regression.
|
|
// We may want to re-enable this if we figure out why.
|
|
// @gate experimental
|
|
// @gate FIXME
|
|
it(
|
|
'recreates the fallback if server errors and hydration suspends but ' +
|
|
'client receives new props',
|
|
async () => {
|
|
let isClient = false;
|
|
|
|
function Child() {
|
|
const value = 'Yay!';
|
|
if (isClient) {
|
|
readText(value);
|
|
} else {
|
|
throw Error('Oops.');
|
|
}
|
|
Scheduler.unstable_yieldValue(value);
|
|
return value;
|
|
}
|
|
|
|
const fallbackRef = React.createRef();
|
|
function App({fallbackText}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<p ref={fallbackRef}>{fallbackText}</p>}>
|
|
<span>
|
|
<Child />
|
|
</span>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App fallbackText="Loading..." />,
|
|
{
|
|
onError(error) {
|
|
Scheduler.unstable_yieldValue('[s!] ' + error.message);
|
|
},
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['[s!] Oops.']);
|
|
|
|
const serverFallback = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback.innerHTML).toBe('Loading...');
|
|
|
|
// Hydrate the tree. This will suspend.
|
|
isClient = true;
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
container,
|
|
<App fallbackText="Loading..." />,
|
|
{
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue('[c!] ' + error.message);
|
|
},
|
|
},
|
|
);
|
|
// This should not report any errors yet.
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>Loading...</p>
|
|
</div>,
|
|
);
|
|
|
|
// Normally, hydration after server error would force a clean client render.
|
|
// However, that suspended so at best we'd only get a fallback anyway.
|
|
// We don't want to replace a fallback with the same fallback because
|
|
// that's extra work and would restart animations etc. Verify we don't do that.
|
|
const clientFallback1 = container.getElementsByTagName('p')[0];
|
|
expect(serverFallback).toBe(clientFallback1);
|
|
|
|
// However, an update may have changed the fallback props. In that case we have to
|
|
// actually force it to re-render on the client and throw away the server one.
|
|
root.render(<App fallbackText="More loading..." />);
|
|
Scheduler.unstable_flushAll();
|
|
jest.runAllTimers();
|
|
expect(Scheduler).toHaveYielded([
|
|
'[c!] The server could not finish this Suspense boundary, ' +
|
|
'likely due to an error during server rendering. ' +
|
|
'Switched to client rendering.',
|
|
]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>More loading...</p>
|
|
</div>,
|
|
);
|
|
// This should be a clean render without reusing DOM.
|
|
const clientFallback2 = container.getElementsByTagName('p')[0];
|
|
expect(clientFallback2).not.toBe(clientFallback1);
|
|
|
|
// Verify we can still do a clean content render after.
|
|
await act(async () => {
|
|
resolveText('Yay!');
|
|
});
|
|
expect(Scheduler).toFlushAndYield(['Yay!']);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span>Yay!</span>
|
|
</div>,
|
|
);
|
|
},
|
|
);
|
|
|
|
// @gate experimental
|
|
it(
|
|
'errors during hydration force a client render at the nearest Suspense ' +
|
|
'boundary, and during the client render it recovers, then a deeper ' +
|
|
'child suspends',
|
|
async () => {
|
|
let isClient = false;
|
|
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
function getClientSnapshot() {
|
|
return 'Yay!';
|
|
}
|
|
|
|
// At the time of writing, the only API that exposes whether it's currently
|
|
// hydrating is the `getServerSnapshot` API, so I'm using that here to
|
|
// simulate an error during hydration.
|
|
function getServerSnapshot() {
|
|
if (isClient) {
|
|
throw new Error('Hydration error');
|
|
}
|
|
return 'Yay!';
|
|
}
|
|
|
|
function Child() {
|
|
const value = useSyncExternalStore(
|
|
subscribe,
|
|
getClientSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
if (isClient) {
|
|
readText(value);
|
|
}
|
|
Scheduler.unstable_yieldValue(value);
|
|
return value;
|
|
}
|
|
|
|
const span1Ref = React.createRef();
|
|
const span2Ref = React.createRef();
|
|
const span3Ref = React.createRef();
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<span ref={span1Ref} />
|
|
<Suspense fallback="Loading...">
|
|
<span ref={span2Ref}>
|
|
<Child />
|
|
</span>
|
|
</Suspense>
|
|
<span ref={span3Ref} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['Yay!']);
|
|
|
|
const [span1, span2, span3] = container.getElementsByTagName('span');
|
|
|
|
// Hydrate the tree. Child will throw during hydration, but not when it
|
|
// falls back to client rendering.
|
|
isClient = true;
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(error.message);
|
|
},
|
|
});
|
|
|
|
// An error logged but instead of surfacing it to the UI, we switched
|
|
// to client rendering.
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Hydration error',
|
|
'There was an error while hydrating this Suspense boundary. Switched ' +
|
|
'to client rendering.',
|
|
]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span />
|
|
Loading...
|
|
<span />
|
|
</div>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('Yay!');
|
|
});
|
|
expect(Scheduler).toFlushAndYield(['Yay!']);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<span />
|
|
<span>Yay!</span>
|
|
<span />
|
|
</div>,
|
|
);
|
|
|
|
// The node that's inside the boundary that errored during hydration was
|
|
// not hydrated.
|
|
expect(span2Ref.current).not.toBe(span2);
|
|
|
|
// But the nodes outside the boundary were.
|
|
expect(span1Ref.current).toBe(span1);
|
|
expect(span3Ref.current).toBe(span3);
|
|
},
|
|
);
|
|
|
|
it('logs regular (non-hydration) errors when the UI recovers', async () => {
|
|
let shouldThrow = true;
|
|
|
|
function A() {
|
|
if (shouldThrow) {
|
|
Scheduler.unstable_yieldValue('Oops!');
|
|
throw new Error('Oops!');
|
|
}
|
|
Scheduler.unstable_yieldValue('A');
|
|
return 'A';
|
|
}
|
|
|
|
function B() {
|
|
Scheduler.unstable_yieldValue('B');
|
|
return 'B';
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<>
|
|
<A />
|
|
<B />
|
|
</>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged a recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
React.startTransition(() => {
|
|
root.render(<App />);
|
|
});
|
|
|
|
// Partially render A, but yield before the render has finished
|
|
expect(Scheduler).toFlushAndYieldThrough(['Oops!', 'Oops!']);
|
|
|
|
// React will try rendering again synchronously. During the retry, A will
|
|
// not throw. This simulates a concurrent data race that is fixed by
|
|
// blocking the main thread.
|
|
shouldThrow = false;
|
|
expect(Scheduler).toFlushAndYield([
|
|
// Finish initial render attempt
|
|
'B',
|
|
|
|
// Render again, synchronously
|
|
'A',
|
|
'B',
|
|
|
|
// Log the error
|
|
'Logged a recoverable error: Oops!',
|
|
]);
|
|
|
|
// UI looks normal
|
|
expect(container.textContent).toEqual('AB');
|
|
});
|
|
|
|
// @gate experimental
|
|
it('logs multiple hydration errors in the same render', async () => {
|
|
let isClient = false;
|
|
|
|
function subscribe() {
|
|
return () => {};
|
|
}
|
|
function getClientSnapshot() {
|
|
return 'Yay!';
|
|
}
|
|
function getServerSnapshot() {
|
|
if (isClient) {
|
|
throw new Error('Hydration error');
|
|
}
|
|
return 'Yay!';
|
|
}
|
|
|
|
function Child({label}) {
|
|
// This will throw during client hydration. Only reason to use
|
|
// useSyncExternalStore in this test is because getServerSnapshot has the
|
|
// ability to observe whether we're hydrating.
|
|
useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
|
|
Scheduler.unstable_yieldValue(label);
|
|
return label;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<>
|
|
<Suspense fallback="Loading...">
|
|
<Child label="A" />
|
|
</Suspense>
|
|
<Suspense fallback="Loading...">
|
|
<Child label="B" />
|
|
</Suspense>
|
|
</>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(Scheduler).toHaveYielded(['A', 'B']);
|
|
|
|
// Hydrate the tree. Child will throw during hydration, but not when it
|
|
// falls back to client rendering.
|
|
isClient = true;
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
|
|
expect(Scheduler).toFlushAndYield([
|
|
'A',
|
|
'B',
|
|
|
|
'Logged recoverable error: Hydration error',
|
|
'Logged recoverable error: There was an error while hydrating this ' +
|
|
'Suspense boundary. Switched to client rendering.',
|
|
|
|
'Logged recoverable error: Hydration error',
|
|
'Logged recoverable error: There was an error while hydrating this ' +
|
|
'Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
});
|
|
|
|
// @gate enableServerContext && experimental
|
|
it('supports ServerContext', async () => {
|
|
let ServerContext;
|
|
function inlineLazyServerContextInitialization() {
|
|
if (!ServerContext) {
|
|
ServerContext = React.createServerContext('ServerContext', 'default');
|
|
}
|
|
return ServerContext;
|
|
}
|
|
|
|
function Foo() {
|
|
inlineLazyServerContextInitialization();
|
|
return (
|
|
<>
|
|
<ServerContext.Provider value="hi this is server outer">
|
|
<ServerContext.Provider value="hi this is server">
|
|
<Bar />
|
|
</ServerContext.Provider>
|
|
<ServerContext.Provider value="hi this is server2">
|
|
<Bar />
|
|
</ServerContext.Provider>
|
|
<Bar />
|
|
</ServerContext.Provider>
|
|
<ServerContext.Provider value="hi this is server outer2">
|
|
<Bar />
|
|
</ServerContext.Provider>
|
|
<Bar />
|
|
</>
|
|
);
|
|
}
|
|
function Bar() {
|
|
const context = React.useContext(inlineLazyServerContextInitialization());
|
|
return <span>{context}</span>;
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual([
|
|
<span>hi this is server</span>,
|
|
<span>hi this is server2</span>,
|
|
<span>hi this is server outer</span>,
|
|
<span>hi this is server outer2</span>,
|
|
<span>default</span>,
|
|
]);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('Supports iterable', async () => {
|
|
const Immutable = require('immutable');
|
|
|
|
const mappedJSX = Immutable.fromJS([
|
|
{name: 'a', value: 'a'},
|
|
{name: 'b', value: 'b'},
|
|
]).map(item => <li key={item.get('value')}>{item.get('name')}</li>);
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<ul>{mappedJSX}</ul>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<ul>
|
|
<li>a</li>
|
|
<li>b</li>
|
|
</ul>,
|
|
);
|
|
});
|
|
|
|
describe('error escaping', () => {
|
|
//@gate experimental
|
|
it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => {
|
|
window.__outlet = {};
|
|
|
|
const dangerousErrorString =
|
|
'"></template></div><script>window.__outlet.message="from error"</script><div><template data-foo="';
|
|
|
|
function Erroring() {
|
|
throw new Error(dangerousErrorString);
|
|
}
|
|
|
|
// We can't test newline in component stacks because the stack always takes just one line and we end up
|
|
// dropping the first part including the \n character
|
|
Erroring.displayName =
|
|
'DangerousName' +
|
|
dangerousErrorString.replace(
|
|
'message="from error"',
|
|
'stack="from_stack"',
|
|
);
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<Erroring />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function onError(x) {
|
|
return `dangerous hash ${x.message.replace(
|
|
'message="from error"',
|
|
'hash="from hash"',
|
|
)}`;
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
onError,
|
|
});
|
|
pipe(writable);
|
|
});
|
|
expect(window.__outlet).toEqual({});
|
|
});
|
|
//@gate experimental
|
|
it('escapes error hash, message, and component stack values in clientRenderInstruction (javascript escaping)', async () => {
|
|
window.__outlet = {};
|
|
|
|
const dangerousErrorString =
|
|
'");window.__outlet.message="from error";</script><script>(() => {})("';
|
|
|
|
let rejectComponent;
|
|
const SuspensyErroring = React.lazy(() => {
|
|
return new Promise((resolve, reject) => {
|
|
rejectComponent = reject;
|
|
});
|
|
});
|
|
|
|
// We can't test newline in component stacks because the stack always takes just one line and we end up
|
|
// dropping the first part including the \n character
|
|
SuspensyErroring.displayName =
|
|
'DangerousName' +
|
|
dangerousErrorString.replace(
|
|
'message="from error"',
|
|
'stack="from_stack"',
|
|
);
|
|
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<SuspensyErroring />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function onError(x) {
|
|
return `dangerous hash ${x.message.replace(
|
|
'message="from error"',
|
|
'hash="from hash"',
|
|
)}`;
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
onError,
|
|
});
|
|
pipe(writable);
|
|
});
|
|
|
|
await act(() => {
|
|
rejectComponent(new Error(dangerousErrorString));
|
|
});
|
|
expect(window.__outlet).toEqual({});
|
|
});
|
|
//@gate experimental
|
|
it('escapes such that attributes cannot be masked', async () => {
|
|
const dangerousErrorString = '" data-msg="bad message" data-foo="';
|
|
const theError = new Error(dangerousErrorString);
|
|
|
|
function Erroring({isClient}) {
|
|
if (isClient) return 'Hello';
|
|
throw theError;
|
|
}
|
|
|
|
function App({isClient}) {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<Erroring isClient={isClient} />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loggedErrors = [];
|
|
function onError(x) {
|
|
loggedErrors.push(x);
|
|
return x.message.replace('bad message', 'bad hash');
|
|
}
|
|
const expectedDigest = onError(theError);
|
|
loggedErrors.length = 0;
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
onError,
|
|
});
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(loggedErrors).toEqual([theError]);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error, errorInfo) {
|
|
errors.push({error, errorInfo});
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
|
|
// If escaping were not done we would get a message that says "bad hash"
|
|
expectErrors(
|
|
errors,
|
|
[
|
|
[
|
|
theError.message,
|
|
expectedDigest,
|
|
componentStack(['Erroring', 'Suspense', 'div', 'App']),
|
|
],
|
|
],
|
|
[
|
|
[
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
expectedDigest,
|
|
],
|
|
],
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bootstrapScriptContent escaping', () => {
|
|
// @gate experimental
|
|
it('the "S" in "</?[Ss]cript" strings are replaced with unicode escaped lowercase s or S depending on case, preserving case sensitivity of nearby characters', async () => {
|
|
window.__test_outlet = '';
|
|
const stringWithScriptsInIt =
|
|
'prescription pre<scription pre<Scription pre</scRipTion pre</ScripTion </script><script><!-- <script> -->';
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
|
|
bootstrapScriptContent:
|
|
'window.__test_outlet = "This should have been replaced";var x = "' +
|
|
stringWithScriptsInIt +
|
|
'";\nwindow.__test_outlet = x;',
|
|
});
|
|
pipe(writable);
|
|
});
|
|
expect(window.__test_outlet).toMatch(stringWithScriptsInIt);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('does not escape \\u2028, or \\u2029 characters', async () => {
|
|
// these characters are ignored in engines support https://github.com/tc39/proposal-json-superset
|
|
// in this test with JSDOM the characters are silently dropped and thus don't need to be encoded.
|
|
// if you send these characters to an older browser they could fail so it is a good idea to
|
|
// sanitize JSON input of these characters
|
|
window.__test_outlet = '';
|
|
const el = document.createElement('p');
|
|
el.textContent = '{"one":1,\u2028\u2029"two":2}';
|
|
const stringWithLSAndPSCharacters = el.textContent;
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
|
|
bootstrapScriptContent:
|
|
'let x = ' +
|
|
stringWithLSAndPSCharacters +
|
|
'; window.__test_outlet = x;',
|
|
});
|
|
pipe(writable);
|
|
});
|
|
const outletString = JSON.stringify(window.__test_outlet);
|
|
expect(outletString).toBe(
|
|
stringWithLSAndPSCharacters.replace(/[\u2028\u2029]/g, ''),
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('does not escape <, >, or & characters', async () => {
|
|
// these characters valid javascript and may be necessary in scripts and won't be interpretted properly
|
|
// escaped outside of a string context within javascript
|
|
window.__test_outlet = null;
|
|
// this boolean expression will be cast to a number due to the bitwise &. we will look for a truthy value (1) below
|
|
const booleanLogicString = '1 < 2 & 3 > 1';
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<div />, {
|
|
bootstrapScriptContent:
|
|
'let x = ' + booleanLogicString + '; window.__test_outlet = x;',
|
|
});
|
|
pipe(writable);
|
|
});
|
|
expect(window.__test_outlet).toBe(1);
|
|
});
|
|
});
|
|
|
|
// @gate experimental
|
|
it('#24384: Suspending should halt hydration warnings and not emit any if hydration completes successfully after unsuspending', async () => {
|
|
const makeApp = () => {
|
|
let resolve, resolved;
|
|
const promise = new Promise(r => {
|
|
resolve = () => {
|
|
resolved = true;
|
|
return r();
|
|
};
|
|
});
|
|
function ComponentThatSuspends() {
|
|
if (!resolved) {
|
|
throw promise;
|
|
}
|
|
return <p>A</p>;
|
|
}
|
|
|
|
const App = () => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ComponentThatSuspends />
|
|
<h2 name="hello">world</h2>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return [App, resolve];
|
|
};
|
|
|
|
const [ServerApp, serverResolve] = makeApp();
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ServerApp />);
|
|
pipe(writable);
|
|
});
|
|
await act(() => {
|
|
serverResolve();
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="hello">world</h2>
|
|
</div>,
|
|
);
|
|
|
|
const [ClientApp, clientResolve] = makeApp();
|
|
ReactDOMClient.hydrateRoot(container, <ClientApp />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="hello">world</h2>
|
|
</div>,
|
|
);
|
|
|
|
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
|
|
// client-side rendering.
|
|
await clientResolve();
|
|
expect(Scheduler).toFlushWithoutYielding();
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="hello">world</h2>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental && enableClientRenderFallbackOnTextMismatch
|
|
it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
|
|
const makeApp = () => {
|
|
let resolve, resolved;
|
|
const promise = new Promise(r => {
|
|
resolve = () => {
|
|
resolved = true;
|
|
return r();
|
|
};
|
|
});
|
|
function ComponentThatSuspends() {
|
|
if (!resolved) {
|
|
throw promise;
|
|
}
|
|
return <p>A</p>;
|
|
}
|
|
|
|
const App = ({text}) => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ComponentThatSuspends />
|
|
<h2 name={text}>{text}</h2>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return [App, resolve];
|
|
};
|
|
|
|
const [ServerApp, serverResolve] = makeApp();
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<ServerApp text="initial" />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
await act(() => {
|
|
serverResolve();
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="initial">initial</h2>
|
|
</div>,
|
|
);
|
|
|
|
// The client app is rendered with an intentionally incorrect text. The still Suspended component causes
|
|
// hydration to fail silently (allowing for cache warming but otherwise skipping this boundary) until it
|
|
// resolves.
|
|
const [ClientApp, clientResolve] = makeApp();
|
|
ReactDOMClient.hydrateRoot(container, <ClientApp text="replaced" />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
Scheduler.unstable_flushAll();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="initial">initial</h2>
|
|
</div>,
|
|
);
|
|
|
|
// Now that the boundary resolves to it's children the hydration completes and discovers that there is a mismatch requiring
|
|
// client-side rendering.
|
|
await clientResolve();
|
|
expect(() => {
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Logged recoverable error: Text content does not match server-rendered HTML.',
|
|
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
}).toErrorDev(
|
|
'Warning: Prop `name` did not match. Server: "initial" Client: "replaced"',
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<p>A</p>
|
|
<h2 name="replaced">replaced</h2>
|
|
</div>,
|
|
);
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
});
|
|
|
|
// @gate experimental && enableClientRenderFallbackOnTextMismatch
|
|
it('only warns once on hydration mismatch while within a suspense boundary', async () => {
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
|
|
const App = ({text}) => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<h2>{text}</h2>
|
|
<h2>{text}</h2>
|
|
<h2>{text}</h2>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
try {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App text="initial" />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h2>initial</h2>
|
|
<h2>initial</h2>
|
|
<h2>initial</h2>
|
|
</div>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App text="replaced" />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Logged recoverable error: Text content does not match server-rendered HTML.',
|
|
'Logged recoverable error: Text content does not match server-rendered HTML.',
|
|
'Logged recoverable error: Text content does not match server-rendered HTML.',
|
|
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h2>replaced</h2>
|
|
<h2>replaced</h2>
|
|
<h2>replaced</h2>
|
|
</div>,
|
|
);
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
if (__DEV__) {
|
|
expect(mockError.mock.calls.length).toBe(1);
|
|
expect(mockError.mock.calls[0]).toEqual([
|
|
'Warning: Text content did not match. Server: "%s" Client: "%s"%s',
|
|
'initial',
|
|
'replaced',
|
|
'\n' +
|
|
' in h2 (at **)\n' +
|
|
' in Suspense (at **)\n' +
|
|
' in div (at **)\n' +
|
|
' in App (at **)',
|
|
]);
|
|
} else {
|
|
expect(mockError.mock.calls.length).toBe(0);
|
|
}
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('supresses hydration warnings when an error occurs within a Suspense boundary', async () => {
|
|
let isClient = false;
|
|
let shouldThrow = true;
|
|
|
|
function ThrowUntilOnClient({children}) {
|
|
if (isClient && shouldThrow) {
|
|
throw new Error('uh oh');
|
|
}
|
|
return children;
|
|
}
|
|
|
|
function StopThrowingOnClient() {
|
|
if (isClient) {
|
|
shouldThrow = false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const App = () => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ThrowUntilOnClient>
|
|
<h1>one</h1>
|
|
</ThrowUntilOnClient>
|
|
<h2>two</h2>
|
|
<h3>{isClient ? 'five' : 'three'}</h3>
|
|
<StopThrowingOnClient />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
|
|
isClient = true;
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'Logged recoverable error: uh oh',
|
|
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
|
|
'Logged recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
|
|
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>five</h3>
|
|
</div>,
|
|
);
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
});
|
|
|
|
// @gate experimental && __DEV__
|
|
it('does not invokeGuardedCallback for errors after the first hydration error', async () => {
|
|
// We can't use the toErrorDev helper here because this is async.
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
if (args.length > 1) {
|
|
if (typeof args[1] === 'object') {
|
|
mockError(args[0].split('\n')[0]);
|
|
return;
|
|
}
|
|
}
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
let isClient = false;
|
|
let shouldThrow = true;
|
|
|
|
function ThrowUntilOnClient({children, message}) {
|
|
if (isClient && shouldThrow) {
|
|
Scheduler.unstable_yieldValue('throwing: ' + message);
|
|
throw new Error(message);
|
|
}
|
|
return children;
|
|
}
|
|
|
|
function StopThrowingOnClient() {
|
|
if (isClient) {
|
|
shouldThrow = false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const App = () => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ThrowUntilOnClient message="first error">
|
|
<h1>one</h1>
|
|
</ThrowUntilOnClient>
|
|
<ThrowUntilOnClient message="second error">
|
|
<h2>two</h2>
|
|
</ThrowUntilOnClient>
|
|
<ThrowUntilOnClient message="third error">
|
|
<h3>three</h3>
|
|
</ThrowUntilOnClient>
|
|
<StopThrowingOnClient />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
try {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
|
|
isClient = true;
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'throwing: first error',
|
|
// this repeated first error is the invokeGuardedCallback throw
|
|
'throwing: first error',
|
|
// these are actually thrown during render but no iGC repeat and no queueing as hydration errors
|
|
'throwing: second error',
|
|
'throwing: third error',
|
|
// all hydration errors are still queued
|
|
'Logged recoverable error: first error',
|
|
'Logged recoverable error: second error',
|
|
'Logged recoverable error: third error',
|
|
// other recoverable errors are queued as hydration errors
|
|
'Logged recoverable error: There was an error while hydrating this Suspense boundary. Switched to client rendering.',
|
|
]);
|
|
// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
|
|
// when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
|
|
expect(mockError.mock.calls).toEqual([
|
|
// we only get one because we suppress invokeGuardedCallback after the first one when hydrating in a
|
|
// suspense boundary
|
|
['Error: Uncaught [Error: first error]'],
|
|
]);
|
|
mockError.mockClear();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(mockError.mock.calls).toEqual([]);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('does not invokeGuardedCallback for errors after a preceding fiber suspends', async () => {
|
|
// We can't use the toErrorDev helper here because this is async.
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
if (args.length > 1) {
|
|
if (typeof args[1] === 'object') {
|
|
mockError(args[0].split('\n')[0]);
|
|
return;
|
|
}
|
|
}
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
let isClient = false;
|
|
let shouldThrow = true;
|
|
let promise = null;
|
|
let unsuspend = null;
|
|
let isResolved = false;
|
|
|
|
function ComponentThatSuspendsOnClient() {
|
|
if (isClient && !isResolved) {
|
|
if (promise === null) {
|
|
promise = new Promise(resolve => {
|
|
unsuspend = () => {
|
|
isResolved = true;
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
Scheduler.unstable_yieldValue('suspending');
|
|
throw promise;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ThrowUntilOnClient({children, message}) {
|
|
if (isClient && shouldThrow) {
|
|
Scheduler.unstable_yieldValue('throwing: ' + message);
|
|
throw new Error(message);
|
|
}
|
|
return children;
|
|
}
|
|
|
|
function StopThrowingOnClient() {
|
|
if (isClient) {
|
|
shouldThrow = false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const App = () => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ComponentThatSuspendsOnClient />
|
|
<ThrowUntilOnClient message="first error">
|
|
<h1>one</h1>
|
|
</ThrowUntilOnClient>
|
|
<ThrowUntilOnClient message="second error">
|
|
<h2>two</h2>
|
|
</ThrowUntilOnClient>
|
|
<ThrowUntilOnClient message="third error">
|
|
<h3>three</h3>
|
|
</ThrowUntilOnClient>
|
|
<StopThrowingOnClient />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
try {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
|
|
isClient = true;
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'suspending',
|
|
'throwing: first error',
|
|
// There is no repeated first error because we already suspended and no
|
|
// invokeGuardedCallback is used if we are in dev
|
|
// or in prod there is just never an invokeGuardedCallback
|
|
'throwing: second error',
|
|
'throwing: third error',
|
|
]);
|
|
expect(mockError.mock.calls).toEqual([]);
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
await unsuspend();
|
|
// Since our client components only throw on the very first render there are no
|
|
// new throws in this pass
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
|
|
expect(mockError.mock.calls).toEqual([]);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental && __DEV__
|
|
it('suspending after erroring will cause errors previously queued to be silenced until the boundary resolves', async () => {
|
|
// We can't use the toErrorDev helper here because this is async.
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
if (args.length > 1) {
|
|
if (typeof args[1] === 'object') {
|
|
mockError(args[0].split('\n')[0]);
|
|
return;
|
|
}
|
|
}
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
let isClient = false;
|
|
let shouldThrow = true;
|
|
let promise = null;
|
|
let unsuspend = null;
|
|
let isResolved = false;
|
|
|
|
function ComponentThatSuspendsOnClient() {
|
|
if (isClient && !isResolved) {
|
|
if (promise === null) {
|
|
promise = new Promise(resolve => {
|
|
unsuspend = () => {
|
|
isResolved = true;
|
|
resolve();
|
|
};
|
|
});
|
|
}
|
|
Scheduler.unstable_yieldValue('suspending');
|
|
throw promise;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ThrowUntilOnClient({children, message}) {
|
|
if (isClient && shouldThrow) {
|
|
Scheduler.unstable_yieldValue('throwing: ' + message);
|
|
throw new Error(message);
|
|
}
|
|
return children;
|
|
}
|
|
|
|
function StopThrowingOnClient() {
|
|
if (isClient) {
|
|
shouldThrow = false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const App = () => {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<ThrowUntilOnClient message="first error">
|
|
<h1>one</h1>
|
|
</ThrowUntilOnClient>
|
|
<ThrowUntilOnClient message="second error">
|
|
<h2>two</h2>
|
|
</ThrowUntilOnClient>
|
|
<ComponentThatSuspendsOnClient />
|
|
<ThrowUntilOnClient message="third error">
|
|
<h3>three</h3>
|
|
</ThrowUntilOnClient>
|
|
<StopThrowingOnClient />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
try {
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
|
|
isClient = true;
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
Scheduler.unstable_yieldValue(
|
|
'Logged recoverable error: ' + error.message,
|
|
);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([
|
|
'throwing: first error',
|
|
// duplicate because first error is re-done in invokeGuardedCallback
|
|
'throwing: first error',
|
|
'throwing: second error',
|
|
'suspending',
|
|
'throwing: third error',
|
|
]);
|
|
// These Uncaught error calls are the error reported by the runtime (jsdom here, browser in actual use)
|
|
// when invokeGuardedCallback is used to replay an error in dev using event dispatching in the document
|
|
expect(mockError.mock.calls).toEqual([
|
|
// we only get one because we suppress invokeGuardedCallback after the first one when hydrating in a
|
|
// suspense boundary
|
|
['Error: Uncaught [Error: first error]'],
|
|
]);
|
|
mockError.mockClear();
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
<h1>one</h1>
|
|
<h2>two</h2>
|
|
<h3>three</h3>
|
|
</div>,
|
|
);
|
|
await unsuspend();
|
|
// Since our client components only throw on the very first render there are no
|
|
// new throws in this pass
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(mockError.mock.calls).toEqual([]);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('#24578 Hydration errors caused by a suspending component should not become recoverable when nested in an ancestor Suspense that is showing primary content', async () => {
|
|
// this test failed before because hydration errors on the inner boundary were upgraded to recoverable by
|
|
// a codepath of the outer boundary
|
|
function App({isClient}) {
|
|
return (
|
|
<Suspense fallback={'outer'}>
|
|
<Suspense fallback={'inner'}>
|
|
<div>
|
|
{isClient ? <AsyncText text="A" /> : <Text text="A" />}
|
|
<b>B</b>
|
|
</div>
|
|
</Suspense>
|
|
</Suspense>
|
|
);
|
|
}
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App isClient={true} />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
A<b>B</b>
|
|
</div>,
|
|
);
|
|
|
|
resolveText('A');
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
A<b>B</b>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
describe('text separators', () => {
|
|
// To force performWork to start before resolving AsyncText but before piping we need to wait until
|
|
// after scheduleWork which currently uses setImmediate to delay performWork
|
|
function afterImmediate() {
|
|
return new Promise(resolve => {
|
|
setImmediate(resolve);
|
|
});
|
|
}
|
|
|
|
// @gate experimental
|
|
it('it only includes separators between adjacent text nodes', async () => {
|
|
function App({name}) {
|
|
return (
|
|
<div>
|
|
hello<b>world, {name}</b>!
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App name="Foo" />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(container.innerHTML).toEqual(
|
|
'<div>hello<b>world, <!-- -->Foo</b>!</div>',
|
|
);
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
hello<b>world, {'Foo'}</b>!
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('it does not insert text separators even when adjacent text is in a delayed segment', async () => {
|
|
function App({name}) {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div id="app-div">
|
|
hello
|
|
<b>
|
|
world, <AsyncText text={name} />
|
|
</b>
|
|
!
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
|
<App name="Foo" />,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">hello<b>world, <template id="P:1"></template></b>!</div>',
|
|
);
|
|
|
|
await act(() => resolveText('Foo'));
|
|
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div id="app-div">hello<b>world, Foo</b>!</div>',
|
|
);
|
|
// there are extra script nodes at the end of container
|
|
expect(container.childNodes.length).toBe(5);
|
|
const div = container.childNodes[1];
|
|
expect(div.childNodes.length).toBe(3);
|
|
const b = div.childNodes[1];
|
|
expect(b.childNodes.length).toBe(2);
|
|
expect(b.childNodes[0]).toMatchInlineSnapshot('world, ');
|
|
expect(b.childNodes[1]).toMatchInlineSnapshot('Foo');
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div id="app-div">
|
|
hello<b>world, {'Foo'}</b>!
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('it works with multiple adjacent segments', async () => {
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div id="app-div">
|
|
h<AsyncText text={'ello'} />
|
|
w<AsyncText text={'orld'} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">h<template id="P:1"></template>w<template id="P:2"></template></div>',
|
|
);
|
|
|
|
await act(() => resolveText('orld'));
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">h<template id="P:1"></template>world</div>',
|
|
);
|
|
|
|
await act(() => resolveText('ello'));
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div id="app-div">helloworld</div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('it works when some segments are flushed and others are patched', async () => {
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div id="app-div">
|
|
h<AsyncText text={'ello'} />
|
|
w<AsyncText text={'orld'} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
await afterImmediate();
|
|
await act(() => resolveText('ello'));
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">h<!-- -->ello<!-- -->w<template id="P:1"></template></div>',
|
|
);
|
|
|
|
await act(() => resolveText('orld'));
|
|
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div id="app-div">h<!-- -->ello<!-- -->world</div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('it does not prepend a text separators if the segment follows a non-Text Node', async () => {
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div>
|
|
hello
|
|
<b>
|
|
<AsyncText text={'world'} />
|
|
</b>
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
await afterImmediate();
|
|
await act(() => resolveText('world'));
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div>hello<b>world<!-- --></b></div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
hello<b>world</b>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('it does not prepend a text separators if the segments first emission is a non-Text Node', async () => {
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div>
|
|
hello
|
|
<AsyncTextWrapped as={'b'} text={'world'} />
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
await afterImmediate();
|
|
await act(() => resolveText('world'));
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div>hello<b>world</b></div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
hello<b>world</b>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should not insert separators for text inside Suspense boundaries even if they would otherwise be considered text-embedded', async () => {
|
|
function App() {
|
|
return (
|
|
<Suspense fallback={'loading...'}>
|
|
<div id="app-div">
|
|
start
|
|
<Suspense fallback={'[loading first]'}>
|
|
firststart
|
|
<AsyncText text={'first suspended'} />
|
|
firstend
|
|
</Suspense>
|
|
<Suspense fallback={'[loading second]'}>
|
|
secondstart
|
|
<b>
|
|
<AsyncText text={'second suspended'} />
|
|
</b>
|
|
</Suspense>
|
|
end
|
|
</div>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
await afterImmediate();
|
|
await act(() => resolveText('world'));
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">start<!--$?--><template id="B:0"></template>[loading first]<!--/$--><!--$?--><template id="B:1"></template>[loading second]<!--/$-->end</div>',
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('first suspended');
|
|
});
|
|
|
|
expect(document.getElementById('app-div').outerHTML).toEqual(
|
|
'<div id="app-div">start<!--$-->firststartfirst suspendedfirstend<!--/$--><!--$?--><template id="B:1"></template>[loading second]<!--/$-->end</div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div id="app-div">
|
|
{'start'}
|
|
{'firststart'}
|
|
{'first suspended'}
|
|
{'firstend'}
|
|
{'[loading second]'}
|
|
{'end'}
|
|
</div>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('second suspended');
|
|
});
|
|
|
|
expect(container.firstElementChild.outerHTML).toEqual(
|
|
'<div id="app-div">start<!--$-->firststartfirst suspendedfirstend<!--/$--><!--$-->secondstart<b>second suspended</b><!--/$-->end</div>',
|
|
);
|
|
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div id="app-div">
|
|
{'start'}
|
|
{'firststart'}
|
|
{'first suspended'}
|
|
{'firstend'}
|
|
{'secondstart'}
|
|
<b>second suspended</b>
|
|
{'end'}
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('(only) includes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => {
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback={'text before, nothing after...'}>
|
|
hello
|
|
<AsyncText text="world" />
|
|
</Suspense>
|
|
<Suspense fallback={'nothing before or after...'}>
|
|
<AsyncText text="world" />
|
|
</Suspense>
|
|
<Suspense fallback={'text before, element after...'}>
|
|
hello
|
|
<AsyncText text="world" />
|
|
<br />
|
|
</Suspense>
|
|
<Suspense fallback={'nothing before, element after...'}>
|
|
<AsyncText text="world" />
|
|
<br />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
await afterImmediate();
|
|
await act(() => resolveText('world'));
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(container.innerHTML).toEqual(
|
|
'<div><!--$-->hello<!-- -->world<!-- --><!--/$--><!--$-->world<!-- --><!--/$--><!--$-->hello<!-- -->world<!-- --><br><!--/$--><!--$-->world<!-- --><br><!--/$--></div>',
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<div>
|
|
{/* first boundary */}
|
|
{'hello'}
|
|
{'world'}
|
|
{/* second boundary */}
|
|
{'world'}
|
|
{/* third boundary */}
|
|
{'hello'}
|
|
{'world'}
|
|
<br />
|
|
{/* fourth boundary */}
|
|
{'world'}
|
|
<br />
|
|
</div>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('title children', () => {
|
|
function prepareJSDOMForTitle() {
|
|
// Test Environment
|
|
const jsdom = new JSDOM('<!DOCTYPE html><html><head>\u0000', {
|
|
runScripts: 'dangerously',
|
|
});
|
|
window = jsdom.window;
|
|
document = jsdom.window.document;
|
|
container = document.getElementsByTagName('head')[0];
|
|
}
|
|
|
|
// @gate experimental
|
|
it('should accept a single string child', async () => {
|
|
// a Single string child
|
|
function App() {
|
|
return <title>hello</title>;
|
|
}
|
|
|
|
prepareJSDOMForTitle();
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should accept children array of length 1 containing a string', async () => {
|
|
// a Single string child
|
|
function App() {
|
|
return <title>{['hello']}</title>;
|
|
}
|
|
|
|
prepareJSDOMForTitle();
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should warn in dev when given an array of length 2 or more', async () => {
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
if (args.length > 1) {
|
|
if (typeof args[1] === 'object') {
|
|
mockError(args[0].split('\n')[0]);
|
|
return;
|
|
}
|
|
}
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
|
|
// a Single string child
|
|
function App() {
|
|
return <title>{['hello1', 'hello2']}</title>;
|
|
}
|
|
|
|
try {
|
|
prepareJSDOMForTitle();
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
if (__DEV__) {
|
|
expect(mockError).toHaveBeenCalledWith(
|
|
'Warning: A title element received an array with more than 1 element as children. ' +
|
|
'In browsers title Elements can only have Text Nodes as children. If ' +
|
|
'the children being rendered output more than a single text node in aggregate the browser ' +
|
|
'will display markup and comments as text in the title and hydration will likely fail and ' +
|
|
'fall back to client rendering%s',
|
|
'\n' + ' in title (at **)\n' + ' in App (at **)',
|
|
);
|
|
} else {
|
|
expect(mockError).not.toHaveBeenCalled();
|
|
}
|
|
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<title>{'hello1<!-- -->hello2'}</title>,
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual(
|
|
[
|
|
gate(flags => flags.enableClientRenderFallbackOnTextMismatch)
|
|
? 'Text content does not match server-rendered HTML.'
|
|
: null,
|
|
'Hydration failed because the initial UI does not match what was rendered on the server.',
|
|
'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
|
|
].filter(Boolean),
|
|
);
|
|
expect(getVisibleChildren(container)).toEqual(
|
|
<title>{['hello1', 'hello2']}</title>,
|
|
);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
|
|
// @gate experimental
|
|
it('should warn in dev if you pass a React Component as a child to <title>', async () => {
|
|
const originalConsoleError = console.error;
|
|
const mockError = jest.fn();
|
|
console.error = (...args) => {
|
|
if (args.length > 1) {
|
|
if (typeof args[1] === 'object') {
|
|
mockError(args[0].split('\n')[0]);
|
|
return;
|
|
}
|
|
}
|
|
mockError(...args.map(normalizeCodeLocInfo));
|
|
};
|
|
|
|
function IndirectTitle() {
|
|
return 'hello';
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<title>
|
|
<IndirectTitle />
|
|
</title>
|
|
);
|
|
}
|
|
|
|
try {
|
|
prepareJSDOMForTitle();
|
|
|
|
await act(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
if (__DEV__) {
|
|
expect(mockError).toHaveBeenCalledWith(
|
|
'Warning: A title element received a React element for children. ' +
|
|
'In the browser title Elements can only have Text Nodes as children. If ' +
|
|
'the children being rendered output more than a single text node in aggregate the browser ' +
|
|
'will display markup and comments as text in the title and hydration will likely fail and ' +
|
|
'fall back to client rendering%s',
|
|
'\n' + ' in title (at **)\n' + ' in App (at **)',
|
|
);
|
|
} else {
|
|
expect(mockError).not.toHaveBeenCalled();
|
|
}
|
|
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(container, <App />, {
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
});
|
|
expect(Scheduler).toFlushAndYield([]);
|
|
expect(errors).toEqual([]);
|
|
expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
|
|
} finally {
|
|
console.error = originalConsoleError;
|
|
}
|
|
});
|
|
});
|
|
});
|