mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Throw on hydration mismatch and force client rendering if boundary hasn't suspended within concurrent root (#22629)
* Throw on hydration mismatch * remove debugger * update error message * update error message part2... * fix test? * test? :( * tests 4real * remove useRefAccessWarning gating * split markSuspenseBoundary and getNearestBoundary * also assert html is correct * replace-fork * also remove client render flag on suspend * replace-fork * fix mismerge????
This commit is contained in:
+16
-43
@@ -1677,25 +1677,19 @@ describe('ReactDOMFizzServer', () => {
|
||||
|
||||
// @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,
|
||||
@@ -1703,19 +1697,17 @@ describe('ReactDOMFizzServer', () => {
|
||||
getServerSnapshot,
|
||||
);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div>
|
||||
<Child text={value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const loggedErrors = [];
|
||||
await act(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
||||
<Suspense fallback="Loading...">
|
||||
<App />
|
||||
</Suspense>,
|
||||
|
||||
{
|
||||
onError(x) {
|
||||
loggedErrors.push(x);
|
||||
@@ -1726,56 +1718,43 @@ describe('ReactDOMFizzServer', () => {
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['server']);
|
||||
|
||||
const serverRenderedDiv = container.getElementsByTagName('div')[0];
|
||||
|
||||
ReactDOM.hydrateRoot(container, <App />);
|
||||
|
||||
// The first paint uses the server snapshot
|
||||
expect(Scheduler).toFlushUntilNextPaint(['server']);
|
||||
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
|
||||
// Hydration succeeded
|
||||
expect(ref.current).toEqual(serverRenderedDiv);
|
||||
|
||||
// Asynchronously we detect that the store has changed on the client,
|
||||
// and patch up the inconsistency
|
||||
expect(Scheduler).toFlushUntilNextPaint(['client']);
|
||||
expect(() => {
|
||||
// The first paint switches to client rendering due to mismatch
|
||||
expect(Scheduler).toFlushUntilNextPaint(['client']);
|
||||
}).toErrorDev(
|
||||
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
|
||||
{withoutStack: true},
|
||||
);
|
||||
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
|
||||
expect(ref.current).toEqual(serverRenderedDiv);
|
||||
});
|
||||
|
||||
// The selector implementation uses the lazy ref initialization pattern
|
||||
// @gate !(enableUseRefAccessWarning && __DEV__)
|
||||
// @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,
|
||||
@@ -1790,14 +1769,12 @@ describe('ReactDOMFizzServer', () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const loggedErrors = [];
|
||||
await act(async () => {
|
||||
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
|
||||
<Suspense fallback="Loading...">
|
||||
<App />
|
||||
</Suspense>,
|
||||
|
||||
{
|
||||
onError(x) {
|
||||
loggedErrors.push(x);
|
||||
@@ -1808,21 +1785,17 @@ describe('ReactDOMFizzServer', () => {
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['server']);
|
||||
|
||||
const serverRenderedDiv = container.getElementsByTagName('div')[0];
|
||||
|
||||
ReactDOM.hydrateRoot(container, <App />);
|
||||
|
||||
// The first paint uses the server snapshot
|
||||
expect(Scheduler).toFlushUntilNextPaint(['server']);
|
||||
expect(getVisibleChildren(container)).toEqual(<div>server</div>);
|
||||
// Hydration succeeded
|
||||
expect(ref.current).toEqual(serverRenderedDiv);
|
||||
|
||||
// Asynchronously we detect that the store has changed on the client,
|
||||
// and patch up the inconsistency
|
||||
expect(Scheduler).toFlushUntilNextPaint(['client']);
|
||||
// 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']);
|
||||
}).toErrorDev(
|
||||
'Warning: An error occurred during hydration. The server HTML was replaced with client content',
|
||||
{withoutStack: true},
|
||||
);
|
||||
expect(getVisibleChildren(container)).toEqual(<div>client</div>);
|
||||
expect(ref.current).toEqual(serverRenderedDiv);
|
||||
});
|
||||
|
||||
// @gate experimental
|
||||
|
||||
@@ -197,12 +197,7 @@ describe('ReactDOMServerPartialHydration', () => {
|
||||
// hydrating anyway.
|
||||
suspend = true;
|
||||
ReactDOM.hydrateRoot(container, <App />);
|
||||
expect(() => {
|
||||
Scheduler.unstable_flushAll();
|
||||
}).toErrorDev(
|
||||
// TODO: This error should not be logged in this case. It's a false positive.
|
||||
'Did not expect server HTML to contain the text node "Hello" in <div>.',
|
||||
);
|
||||
Scheduler.unstable_flushAll();
|
||||
jest.runAllTimers();
|
||||
|
||||
// Expect the server-generated HTML to stay intact.
|
||||
@@ -218,6 +213,101 @@ describe('ReactDOMServerPartialHydration', () => {
|
||||
expect(container.textContent).toBe('HelloHello');
|
||||
});
|
||||
|
||||
it('falls back to client rendering boundary on mismatch', async () => {
|
||||
let client = false;
|
||||
let suspend = false;
|
||||
let resolve;
|
||||
const promise = new Promise(resolvePromise => {
|
||||
resolve = () => {
|
||||
suspend = false;
|
||||
resolvePromise();
|
||||
};
|
||||
});
|
||||
function Child() {
|
||||
if (suspend) {
|
||||
Scheduler.unstable_yieldValue('Suspend');
|
||||
throw promise;
|
||||
} else {
|
||||
Scheduler.unstable_yieldValue('Hello');
|
||||
return 'Hello';
|
||||
}
|
||||
}
|
||||
function Component({shouldMismatch}) {
|
||||
Scheduler.unstable_yieldValue('Component');
|
||||
if (shouldMismatch && client) {
|
||||
return <article>Mismatch</article>;
|
||||
}
|
||||
return <div>Component</div>;
|
||||
}
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback="Loading...">
|
||||
<Child />
|
||||
<Component />
|
||||
<Component />
|
||||
<Component />
|
||||
<Component shouldMismatch={true} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
const finalHTML = ReactDOMServer.renderToString(<App />);
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = finalHTML;
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Hello',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
]);
|
||||
|
||||
expect(container.innerHTML).toBe(
|
||||
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
|
||||
);
|
||||
|
||||
suspend = true;
|
||||
client = true;
|
||||
|
||||
ReactDOM.hydrateRoot(container, <App />);
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'Suspend',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
]);
|
||||
jest.runAllTimers();
|
||||
|
||||
// Unchanged
|
||||
expect(container.innerHTML).toBe(
|
||||
'<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
|
||||
);
|
||||
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
// first pass, mismatches at end
|
||||
'Hello',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
// second pass as client render
|
||||
'Hello',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
'Component',
|
||||
]);
|
||||
|
||||
// Client rendered - suspense comment nodes removed
|
||||
expect(container.innerHTML).toBe(
|
||||
'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls the hydration callbacks after hydration or deletion', async () => {
|
||||
let suspend = false;
|
||||
let resolve;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type {Fiber} from './ReactInternalTypes';
|
||||
import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
|
||||
import type {
|
||||
Instance,
|
||||
TextInstance,
|
||||
@@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnHydrationMismatchIfConcurrentMode(fiber) {
|
||||
if ((fiber.mode & ConcurrentMode) !== NoMode) {
|
||||
throw new Error(
|
||||
'An error occurred during hydration. The server HTML was replaced with client content',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
|
||||
if (!isHydrating) {
|
||||
return;
|
||||
}
|
||||
let nextInstance = nextHydratableInstance;
|
||||
if (!nextInstance) {
|
||||
throwOnHydrationMismatchIfConcurrentMode(fiber);
|
||||
// Nothing to hydrate. Make it an insertion.
|
||||
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
|
||||
isHydrating = false;
|
||||
@@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
|
||||
}
|
||||
const firstAttemptedInstance = nextInstance;
|
||||
if (!tryHydrate(fiber, nextInstance)) {
|
||||
throwOnHydrationMismatchIfConcurrentMode(fiber);
|
||||
// If we can't hydrate this instance let's try the next one.
|
||||
// We use this as a heuristic. It's based on intuition and not data so it
|
||||
// might be flawed or unnecessary.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type {Fiber} from './ReactInternalTypes';
|
||||
import {NoMode, ConcurrentMode} from './ReactTypeOfMode';
|
||||
import type {
|
||||
Instance,
|
||||
TextInstance,
|
||||
@@ -323,12 +324,21 @@ function tryHydrate(fiber, nextInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
function throwOnHydrationMismatchIfConcurrentMode(fiber) {
|
||||
if ((fiber.mode & ConcurrentMode) !== NoMode) {
|
||||
throw new Error(
|
||||
'An error occurred during hydration. The server HTML was replaced with client content',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
|
||||
if (!isHydrating) {
|
||||
return;
|
||||
}
|
||||
let nextInstance = nextHydratableInstance;
|
||||
if (!nextInstance) {
|
||||
throwOnHydrationMismatchIfConcurrentMode(fiber);
|
||||
// Nothing to hydrate. Make it an insertion.
|
||||
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
|
||||
isHydrating = false;
|
||||
@@ -337,6 +347,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
|
||||
}
|
||||
const firstAttemptedInstance = nextInstance;
|
||||
if (!tryHydrate(fiber, nextInstance)) {
|
||||
throwOnHydrationMismatchIfConcurrentMode(fiber);
|
||||
// If we can't hydrate this instance let's try the next one.
|
||||
// We use this as a heuristic. It's based on intuition and not data so it
|
||||
// might be flawed or unnecessary.
|
||||
|
||||
@@ -268,160 +268,159 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
|
||||
}
|
||||
}
|
||||
|
||||
function markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber: Fiber,
|
||||
sourceFiber: Fiber,
|
||||
root: FiberRoot,
|
||||
rootRenderLanes: Lanes,
|
||||
): Fiber | null {
|
||||
function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) {
|
||||
let node = returnFiber;
|
||||
const hasInvisibleParentBoundary = hasSuspenseContext(
|
||||
suspenseStackCursor.current,
|
||||
(InvisibleParentSuspenseContext: SuspenseContext),
|
||||
);
|
||||
let node = returnFiber;
|
||||
do {
|
||||
if (
|
||||
node.tag === SuspenseComponent &&
|
||||
shouldCaptureSuspense(node, hasInvisibleParentBoundary)
|
||||
) {
|
||||
// Found the nearest boundary.
|
||||
const suspenseBoundary = node;
|
||||
|
||||
// This marks a Suspense boundary so that when we're unwinding the stack,
|
||||
// it captures the suspended "exception" and does a second (fallback) pass.
|
||||
|
||||
if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
|
||||
// Legacy Mode Suspense
|
||||
//
|
||||
// If the boundary is in legacy mode, we should *not*
|
||||
// suspend the commit. Pretend as if the suspended component rendered
|
||||
// null and keep rendering. When the Suspense boundary completes,
|
||||
// we'll do a second pass to render the fallback.
|
||||
if (suspenseBoundary === returnFiber) {
|
||||
// Special case where we suspended while reconciling the children of
|
||||
// a Suspense boundary's inner Offscreen wrapper fiber. This happens
|
||||
// when a React.lazy component is a direct child of a
|
||||
// Suspense boundary.
|
||||
//
|
||||
// Suspense boundaries are implemented as multiple fibers, but they
|
||||
// are a single conceptual unit. The legacy mode behavior where we
|
||||
// pretend the suspended fiber committed as `null` won't work,
|
||||
// because in this case the "suspended" fiber is the inner
|
||||
// Offscreen wrapper.
|
||||
//
|
||||
// Because the contents of the boundary haven't started rendering
|
||||
// yet (i.e. nothing in the tree has partially rendered) we can
|
||||
// switch to the regular, concurrent mode behavior: mark the
|
||||
// boundary with ShouldCapture and enter the unwind phase.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
} else {
|
||||
suspenseBoundary.flags |= DidCapture;
|
||||
sourceFiber.flags |= ForceUpdateForLegacySuspense;
|
||||
|
||||
// We're going to commit this fiber even though it didn't complete.
|
||||
// But we shouldn't call any lifecycle methods or callbacks. Remove
|
||||
// all lifecycle effect tags.
|
||||
sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
|
||||
|
||||
if (supportsPersistence && enablePersistentOffscreenHostContainer) {
|
||||
// Another legacy Suspense quirk. In persistent mode, if this is the
|
||||
// initial mount, override the props of the host container to hide
|
||||
// its contents.
|
||||
const currentSuspenseBoundary = suspenseBoundary.alternate;
|
||||
if (currentSuspenseBoundary === null) {
|
||||
const offscreenFiber: Fiber = (suspenseBoundary.child: any);
|
||||
const offscreenContainer = offscreenFiber.child;
|
||||
if (offscreenContainer !== null) {
|
||||
const children = offscreenContainer.memoizedProps.children;
|
||||
const containerProps = getOffscreenContainerProps(
|
||||
'hidden',
|
||||
children,
|
||||
);
|
||||
offscreenContainer.pendingProps = containerProps;
|
||||
offscreenContainer.memoizedProps = containerProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceFiber.tag === ClassComponent) {
|
||||
const currentSourceFiber = sourceFiber.alternate;
|
||||
if (currentSourceFiber === null) {
|
||||
// This is a new mount. Change the tag so it's not mistaken for a
|
||||
// completed class component. For example, we should not call
|
||||
// componentWillUnmount if it is deleted.
|
||||
sourceFiber.tag = IncompleteClassComponent;
|
||||
} else {
|
||||
// When we try rendering again, we should not reuse the current fiber,
|
||||
// since it's known to be in an inconsistent state. Use a force update to
|
||||
// prevent a bail out.
|
||||
const update = createUpdate(NoTimestamp, SyncLane);
|
||||
update.tag = ForceUpdate;
|
||||
enqueueUpdate(sourceFiber, update, SyncLane);
|
||||
}
|
||||
}
|
||||
|
||||
// The source fiber did not complete. Mark it with Sync priority to
|
||||
// indicate that it still has pending work.
|
||||
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
|
||||
}
|
||||
return suspenseBoundary;
|
||||
}
|
||||
// Confirmed that the boundary is in a concurrent mode tree. Continue
|
||||
// with the normal suspend path.
|
||||
//
|
||||
// After this we'll use a set of heuristics to determine whether this
|
||||
// render pass will run to completion or restart or "suspend" the commit.
|
||||
// The actual logic for this is spread out in different places.
|
||||
//
|
||||
// This first principle is that if we're going to suspend when we complete
|
||||
// a root, then we should also restart if we get an update or ping that
|
||||
// might unsuspend it, and vice versa. The only reason to suspend is
|
||||
// because you think you might want to restart before committing. However,
|
||||
// it doesn't make sense to restart only while in the period we're suspended.
|
||||
//
|
||||
// Restarting too aggressively is also not good because it starves out any
|
||||
// intermediate loading state. So we use heuristics to determine when.
|
||||
|
||||
// Suspense Heuristics
|
||||
//
|
||||
// If nothing threw a Promise or all the same fallbacks are already showing,
|
||||
// then don't suspend/restart.
|
||||
//
|
||||
// If this is an initial render of a new tree of Suspense boundaries and
|
||||
// those trigger a fallback, then don't suspend/restart. We want to ensure
|
||||
// that we can show the initial loading state as quickly as possible.
|
||||
//
|
||||
// If we hit a "Delayed" case, such as when we'd switch from content back into
|
||||
// a fallback, then we should always suspend/restart. Transitions apply
|
||||
// to this case. If none is defined, JND is used instead.
|
||||
//
|
||||
// If we're already showing a fallback and it gets "retried", allowing us to show
|
||||
// another level, but there's still an inner boundary that would show a fallback,
|
||||
// then we suspend/restart for 500ms since the last time we showed a fallback
|
||||
// anywhere in the tree. This effectively throttles progressive loading into a
|
||||
// consistent train of commits. This also gives us an opportunity to restart to
|
||||
// get to the completed state slightly earlier.
|
||||
//
|
||||
// If there's ambiguity due to batching it's resolved in preference of:
|
||||
// 1) "delayed", 2) "initial render", 3) "retry".
|
||||
//
|
||||
// We want to ensure that a "busy" state doesn't get force committed. We want to
|
||||
// ensure that new initial loading states can commit as soon as possible.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
// TODO: I think we can remove this, since we now use `DidCapture` in
|
||||
// the begin phase to prevent an early bailout.
|
||||
suspenseBoundary.lanes = rootRenderLanes;
|
||||
return suspenseBoundary;
|
||||
return node;
|
||||
}
|
||||
// This boundary already captured during this render. Continue to the next
|
||||
// boundary.
|
||||
node = node.return;
|
||||
} while (node !== null);
|
||||
|
||||
// Could not find a Suspense boundary capable of capturing.
|
||||
return null;
|
||||
}
|
||||
|
||||
function markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary: Fiber,
|
||||
returnFiber: Fiber,
|
||||
sourceFiber: Fiber,
|
||||
root: FiberRoot,
|
||||
rootRenderLanes: Lanes,
|
||||
): Fiber | null {
|
||||
// This marks a Suspense boundary so that when we're unwinding the stack,
|
||||
// it captures the suspended "exception" and does a second (fallback) pass.
|
||||
if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
|
||||
// Legacy Mode Suspense
|
||||
//
|
||||
// If the boundary is in legacy mode, we should *not*
|
||||
// suspend the commit. Pretend as if the suspended component rendered
|
||||
// null and keep rendering. When the Suspense boundary completes,
|
||||
// we'll do a second pass to render the fallback.
|
||||
if (suspenseBoundary === returnFiber) {
|
||||
// Special case where we suspended while reconciling the children of
|
||||
// a Suspense boundary's inner Offscreen wrapper fiber. This happens
|
||||
// when a React.lazy component is a direct child of a
|
||||
// Suspense boundary.
|
||||
//
|
||||
// Suspense boundaries are implemented as multiple fibers, but they
|
||||
// are a single conceptual unit. The legacy mode behavior where we
|
||||
// pretend the suspended fiber committed as `null` won't work,
|
||||
// because in this case the "suspended" fiber is the inner
|
||||
// Offscreen wrapper.
|
||||
//
|
||||
// Because the contents of the boundary haven't started rendering
|
||||
// yet (i.e. nothing in the tree has partially rendered) we can
|
||||
// switch to the regular, concurrent mode behavior: mark the
|
||||
// boundary with ShouldCapture and enter the unwind phase.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
} else {
|
||||
suspenseBoundary.flags |= DidCapture;
|
||||
sourceFiber.flags |= ForceUpdateForLegacySuspense;
|
||||
|
||||
// We're going to commit this fiber even though it didn't complete.
|
||||
// But we shouldn't call any lifecycle methods or callbacks. Remove
|
||||
// all lifecycle effect tags.
|
||||
sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
|
||||
|
||||
if (supportsPersistence && enablePersistentOffscreenHostContainer) {
|
||||
// Another legacy Suspense quirk. In persistent mode, if this is the
|
||||
// initial mount, override the props of the host container to hide
|
||||
// its contents.
|
||||
const currentSuspenseBoundary = suspenseBoundary.alternate;
|
||||
if (currentSuspenseBoundary === null) {
|
||||
const offscreenFiber: Fiber = (suspenseBoundary.child: any);
|
||||
const offscreenContainer = offscreenFiber.child;
|
||||
if (offscreenContainer !== null) {
|
||||
const children = offscreenContainer.memoizedProps.children;
|
||||
const containerProps = getOffscreenContainerProps(
|
||||
'hidden',
|
||||
children,
|
||||
);
|
||||
offscreenContainer.pendingProps = containerProps;
|
||||
offscreenContainer.memoizedProps = containerProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceFiber.tag === ClassComponent) {
|
||||
const currentSourceFiber = sourceFiber.alternate;
|
||||
if (currentSourceFiber === null) {
|
||||
// This is a new mount. Change the tag so it's not mistaken for a
|
||||
// completed class component. For example, we should not call
|
||||
// componentWillUnmount if it is deleted.
|
||||
sourceFiber.tag = IncompleteClassComponent;
|
||||
} else {
|
||||
// When we try rendering again, we should not reuse the current fiber,
|
||||
// since it's known to be in an inconsistent state. Use a force update to
|
||||
// prevent a bail out.
|
||||
const update = createUpdate(NoTimestamp, SyncLane);
|
||||
update.tag = ForceUpdate;
|
||||
enqueueUpdate(sourceFiber, update, SyncLane);
|
||||
}
|
||||
}
|
||||
|
||||
// The source fiber did not complete. Mark it with Sync priority to
|
||||
// indicate that it still has pending work.
|
||||
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
|
||||
}
|
||||
return suspenseBoundary;
|
||||
}
|
||||
// Confirmed that the boundary is in a concurrent mode tree. Continue
|
||||
// with the normal suspend path.
|
||||
//
|
||||
// After this we'll use a set of heuristics to determine whether this
|
||||
// render pass will run to completion or restart or "suspend" the commit.
|
||||
// The actual logic for this is spread out in different places.
|
||||
//
|
||||
// This first principle is that if we're going to suspend when we complete
|
||||
// a root, then we should also restart if we get an update or ping that
|
||||
// might unsuspend it, and vice versa. The only reason to suspend is
|
||||
// because you think you might want to restart before committing. However,
|
||||
// it doesn't make sense to restart only while in the period we're suspended.
|
||||
//
|
||||
// Restarting too aggressively is also not good because it starves out any
|
||||
// intermediate loading state. So we use heuristics to determine when.
|
||||
|
||||
// Suspense Heuristics
|
||||
//
|
||||
// If nothing threw a Promise or all the same fallbacks are already showing,
|
||||
// then don't suspend/restart.
|
||||
//
|
||||
// If this is an initial render of a new tree of Suspense boundaries and
|
||||
// those trigger a fallback, then don't suspend/restart. We want to ensure
|
||||
// that we can show the initial loading state as quickly as possible.
|
||||
//
|
||||
// If we hit a "Delayed" case, such as when we'd switch from content back into
|
||||
// a fallback, then we should always suspend/restart. Transitions apply
|
||||
// to this case. If none is defined, JND is used instead.
|
||||
//
|
||||
// If we're already showing a fallback and it gets "retried", allowing us to show
|
||||
// another level, but there's still an inner boundary that would show a fallback,
|
||||
// then we suspend/restart for 500ms since the last time we showed a fallback
|
||||
// anywhere in the tree. This effectively throttles progressive loading into a
|
||||
// consistent train of commits. This also gives us an opportunity to restart to
|
||||
// get to the completed state slightly earlier.
|
||||
//
|
||||
// If there's ambiguity due to batching it's resolved in preference of:
|
||||
// 1) "delayed", 2) "initial render", 3) "retry".
|
||||
//
|
||||
// We want to ensure that a "busy" state doesn't get force committed. We want to
|
||||
// ensure that new initial loading states can commit as soon as possible.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
// TODO: I think we can remove this, since we now use `DidCapture` in
|
||||
// the begin phase to prevent an early bailout.
|
||||
suspenseBoundary.lanes = rootRenderLanes;
|
||||
return suspenseBoundary;
|
||||
}
|
||||
|
||||
function throwException(
|
||||
root: FiberRoot,
|
||||
returnFiber: Fiber,
|
||||
@@ -458,13 +457,16 @@ function throwException(
|
||||
}
|
||||
|
||||
// Schedule the nearest Suspense to re-render the timed out view.
|
||||
const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
|
||||
if (suspenseBoundary !== null) {
|
||||
suspenseBoundary.flags &= ~ForceClientRender;
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
attachWakeableListeners(
|
||||
suspenseBoundary,
|
||||
root,
|
||||
@@ -487,20 +489,24 @@ function throwException(
|
||||
} else {
|
||||
// This is a regular error, not a Suspense wakeable.
|
||||
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
|
||||
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
|
||||
// If the error was thrown during hydration, we may be able to recover by
|
||||
// discarding the dehydrated content and switching to a client render.
|
||||
// Instead of surfacing the error, find the nearest Suspense boundary
|
||||
// and render it again without hydration.
|
||||
const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
if (suspenseBoundary !== null) {
|
||||
// Set a flag to indicate that we should try rendering the normal
|
||||
// children again, not the fallback.
|
||||
suspenseBoundary.flags |= ForceClientRender;
|
||||
if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
|
||||
// Set a flag to indicate that we should try rendering the normal
|
||||
// children again, not the fallback.
|
||||
suspenseBoundary.flags |= ForceClientRender;
|
||||
}
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -268,160 +268,159 @@ function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
|
||||
}
|
||||
}
|
||||
|
||||
function markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber: Fiber,
|
||||
sourceFiber: Fiber,
|
||||
root: FiberRoot,
|
||||
rootRenderLanes: Lanes,
|
||||
): Fiber | null {
|
||||
function getNearestSuspenseBoundaryToCapture(returnFiber: Fiber) {
|
||||
let node = returnFiber;
|
||||
const hasInvisibleParentBoundary = hasSuspenseContext(
|
||||
suspenseStackCursor.current,
|
||||
(InvisibleParentSuspenseContext: SuspenseContext),
|
||||
);
|
||||
let node = returnFiber;
|
||||
do {
|
||||
if (
|
||||
node.tag === SuspenseComponent &&
|
||||
shouldCaptureSuspense(node, hasInvisibleParentBoundary)
|
||||
) {
|
||||
// Found the nearest boundary.
|
||||
const suspenseBoundary = node;
|
||||
|
||||
// This marks a Suspense boundary so that when we're unwinding the stack,
|
||||
// it captures the suspended "exception" and does a second (fallback) pass.
|
||||
|
||||
if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
|
||||
// Legacy Mode Suspense
|
||||
//
|
||||
// If the boundary is in legacy mode, we should *not*
|
||||
// suspend the commit. Pretend as if the suspended component rendered
|
||||
// null and keep rendering. When the Suspense boundary completes,
|
||||
// we'll do a second pass to render the fallback.
|
||||
if (suspenseBoundary === returnFiber) {
|
||||
// Special case where we suspended while reconciling the children of
|
||||
// a Suspense boundary's inner Offscreen wrapper fiber. This happens
|
||||
// when a React.lazy component is a direct child of a
|
||||
// Suspense boundary.
|
||||
//
|
||||
// Suspense boundaries are implemented as multiple fibers, but they
|
||||
// are a single conceptual unit. The legacy mode behavior where we
|
||||
// pretend the suspended fiber committed as `null` won't work,
|
||||
// because in this case the "suspended" fiber is the inner
|
||||
// Offscreen wrapper.
|
||||
//
|
||||
// Because the contents of the boundary haven't started rendering
|
||||
// yet (i.e. nothing in the tree has partially rendered) we can
|
||||
// switch to the regular, concurrent mode behavior: mark the
|
||||
// boundary with ShouldCapture and enter the unwind phase.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
} else {
|
||||
suspenseBoundary.flags |= DidCapture;
|
||||
sourceFiber.flags |= ForceUpdateForLegacySuspense;
|
||||
|
||||
// We're going to commit this fiber even though it didn't complete.
|
||||
// But we shouldn't call any lifecycle methods or callbacks. Remove
|
||||
// all lifecycle effect tags.
|
||||
sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
|
||||
|
||||
if (supportsPersistence && enablePersistentOffscreenHostContainer) {
|
||||
// Another legacy Suspense quirk. In persistent mode, if this is the
|
||||
// initial mount, override the props of the host container to hide
|
||||
// its contents.
|
||||
const currentSuspenseBoundary = suspenseBoundary.alternate;
|
||||
if (currentSuspenseBoundary === null) {
|
||||
const offscreenFiber: Fiber = (suspenseBoundary.child: any);
|
||||
const offscreenContainer = offscreenFiber.child;
|
||||
if (offscreenContainer !== null) {
|
||||
const children = offscreenContainer.memoizedProps.children;
|
||||
const containerProps = getOffscreenContainerProps(
|
||||
'hidden',
|
||||
children,
|
||||
);
|
||||
offscreenContainer.pendingProps = containerProps;
|
||||
offscreenContainer.memoizedProps = containerProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceFiber.tag === ClassComponent) {
|
||||
const currentSourceFiber = sourceFiber.alternate;
|
||||
if (currentSourceFiber === null) {
|
||||
// This is a new mount. Change the tag so it's not mistaken for a
|
||||
// completed class component. For example, we should not call
|
||||
// componentWillUnmount if it is deleted.
|
||||
sourceFiber.tag = IncompleteClassComponent;
|
||||
} else {
|
||||
// When we try rendering again, we should not reuse the current fiber,
|
||||
// since it's known to be in an inconsistent state. Use a force update to
|
||||
// prevent a bail out.
|
||||
const update = createUpdate(NoTimestamp, SyncLane);
|
||||
update.tag = ForceUpdate;
|
||||
enqueueUpdate(sourceFiber, update, SyncLane);
|
||||
}
|
||||
}
|
||||
|
||||
// The source fiber did not complete. Mark it with Sync priority to
|
||||
// indicate that it still has pending work.
|
||||
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
|
||||
}
|
||||
return suspenseBoundary;
|
||||
}
|
||||
// Confirmed that the boundary is in a concurrent mode tree. Continue
|
||||
// with the normal suspend path.
|
||||
//
|
||||
// After this we'll use a set of heuristics to determine whether this
|
||||
// render pass will run to completion or restart or "suspend" the commit.
|
||||
// The actual logic for this is spread out in different places.
|
||||
//
|
||||
// This first principle is that if we're going to suspend when we complete
|
||||
// a root, then we should also restart if we get an update or ping that
|
||||
// might unsuspend it, and vice versa. The only reason to suspend is
|
||||
// because you think you might want to restart before committing. However,
|
||||
// it doesn't make sense to restart only while in the period we're suspended.
|
||||
//
|
||||
// Restarting too aggressively is also not good because it starves out any
|
||||
// intermediate loading state. So we use heuristics to determine when.
|
||||
|
||||
// Suspense Heuristics
|
||||
//
|
||||
// If nothing threw a Promise or all the same fallbacks are already showing,
|
||||
// then don't suspend/restart.
|
||||
//
|
||||
// If this is an initial render of a new tree of Suspense boundaries and
|
||||
// those trigger a fallback, then don't suspend/restart. We want to ensure
|
||||
// that we can show the initial loading state as quickly as possible.
|
||||
//
|
||||
// If we hit a "Delayed" case, such as when we'd switch from content back into
|
||||
// a fallback, then we should always suspend/restart. Transitions apply
|
||||
// to this case. If none is defined, JND is used instead.
|
||||
//
|
||||
// If we're already showing a fallback and it gets "retried", allowing us to show
|
||||
// another level, but there's still an inner boundary that would show a fallback,
|
||||
// then we suspend/restart for 500ms since the last time we showed a fallback
|
||||
// anywhere in the tree. This effectively throttles progressive loading into a
|
||||
// consistent train of commits. This also gives us an opportunity to restart to
|
||||
// get to the completed state slightly earlier.
|
||||
//
|
||||
// If there's ambiguity due to batching it's resolved in preference of:
|
||||
// 1) "delayed", 2) "initial render", 3) "retry".
|
||||
//
|
||||
// We want to ensure that a "busy" state doesn't get force committed. We want to
|
||||
// ensure that new initial loading states can commit as soon as possible.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
// TODO: I think we can remove this, since we now use `DidCapture` in
|
||||
// the begin phase to prevent an early bailout.
|
||||
suspenseBoundary.lanes = rootRenderLanes;
|
||||
return suspenseBoundary;
|
||||
return node;
|
||||
}
|
||||
// This boundary already captured during this render. Continue to the next
|
||||
// boundary.
|
||||
node = node.return;
|
||||
} while (node !== null);
|
||||
|
||||
// Could not find a Suspense boundary capable of capturing.
|
||||
return null;
|
||||
}
|
||||
|
||||
function markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary: Fiber,
|
||||
returnFiber: Fiber,
|
||||
sourceFiber: Fiber,
|
||||
root: FiberRoot,
|
||||
rootRenderLanes: Lanes,
|
||||
): Fiber | null {
|
||||
// This marks a Suspense boundary so that when we're unwinding the stack,
|
||||
// it captures the suspended "exception" and does a second (fallback) pass.
|
||||
if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
|
||||
// Legacy Mode Suspense
|
||||
//
|
||||
// If the boundary is in legacy mode, we should *not*
|
||||
// suspend the commit. Pretend as if the suspended component rendered
|
||||
// null and keep rendering. When the Suspense boundary completes,
|
||||
// we'll do a second pass to render the fallback.
|
||||
if (suspenseBoundary === returnFiber) {
|
||||
// Special case where we suspended while reconciling the children of
|
||||
// a Suspense boundary's inner Offscreen wrapper fiber. This happens
|
||||
// when a React.lazy component is a direct child of a
|
||||
// Suspense boundary.
|
||||
//
|
||||
// Suspense boundaries are implemented as multiple fibers, but they
|
||||
// are a single conceptual unit. The legacy mode behavior where we
|
||||
// pretend the suspended fiber committed as `null` won't work,
|
||||
// because in this case the "suspended" fiber is the inner
|
||||
// Offscreen wrapper.
|
||||
//
|
||||
// Because the contents of the boundary haven't started rendering
|
||||
// yet (i.e. nothing in the tree has partially rendered) we can
|
||||
// switch to the regular, concurrent mode behavior: mark the
|
||||
// boundary with ShouldCapture and enter the unwind phase.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
} else {
|
||||
suspenseBoundary.flags |= DidCapture;
|
||||
sourceFiber.flags |= ForceUpdateForLegacySuspense;
|
||||
|
||||
// We're going to commit this fiber even though it didn't complete.
|
||||
// But we shouldn't call any lifecycle methods or callbacks. Remove
|
||||
// all lifecycle effect tags.
|
||||
sourceFiber.flags &= ~(LifecycleEffectMask | Incomplete);
|
||||
|
||||
if (supportsPersistence && enablePersistentOffscreenHostContainer) {
|
||||
// Another legacy Suspense quirk. In persistent mode, if this is the
|
||||
// initial mount, override the props of the host container to hide
|
||||
// its contents.
|
||||
const currentSuspenseBoundary = suspenseBoundary.alternate;
|
||||
if (currentSuspenseBoundary === null) {
|
||||
const offscreenFiber: Fiber = (suspenseBoundary.child: any);
|
||||
const offscreenContainer = offscreenFiber.child;
|
||||
if (offscreenContainer !== null) {
|
||||
const children = offscreenContainer.memoizedProps.children;
|
||||
const containerProps = getOffscreenContainerProps(
|
||||
'hidden',
|
||||
children,
|
||||
);
|
||||
offscreenContainer.pendingProps = containerProps;
|
||||
offscreenContainer.memoizedProps = containerProps;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceFiber.tag === ClassComponent) {
|
||||
const currentSourceFiber = sourceFiber.alternate;
|
||||
if (currentSourceFiber === null) {
|
||||
// This is a new mount. Change the tag so it's not mistaken for a
|
||||
// completed class component. For example, we should not call
|
||||
// componentWillUnmount if it is deleted.
|
||||
sourceFiber.tag = IncompleteClassComponent;
|
||||
} else {
|
||||
// When we try rendering again, we should not reuse the current fiber,
|
||||
// since it's known to be in an inconsistent state. Use a force update to
|
||||
// prevent a bail out.
|
||||
const update = createUpdate(NoTimestamp, SyncLane);
|
||||
update.tag = ForceUpdate;
|
||||
enqueueUpdate(sourceFiber, update, SyncLane);
|
||||
}
|
||||
}
|
||||
|
||||
// The source fiber did not complete. Mark it with Sync priority to
|
||||
// indicate that it still has pending work.
|
||||
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, SyncLane);
|
||||
}
|
||||
return suspenseBoundary;
|
||||
}
|
||||
// Confirmed that the boundary is in a concurrent mode tree. Continue
|
||||
// with the normal suspend path.
|
||||
//
|
||||
// After this we'll use a set of heuristics to determine whether this
|
||||
// render pass will run to completion or restart or "suspend" the commit.
|
||||
// The actual logic for this is spread out in different places.
|
||||
//
|
||||
// This first principle is that if we're going to suspend when we complete
|
||||
// a root, then we should also restart if we get an update or ping that
|
||||
// might unsuspend it, and vice versa. The only reason to suspend is
|
||||
// because you think you might want to restart before committing. However,
|
||||
// it doesn't make sense to restart only while in the period we're suspended.
|
||||
//
|
||||
// Restarting too aggressively is also not good because it starves out any
|
||||
// intermediate loading state. So we use heuristics to determine when.
|
||||
|
||||
// Suspense Heuristics
|
||||
//
|
||||
// If nothing threw a Promise or all the same fallbacks are already showing,
|
||||
// then don't suspend/restart.
|
||||
//
|
||||
// If this is an initial render of a new tree of Suspense boundaries and
|
||||
// those trigger a fallback, then don't suspend/restart. We want to ensure
|
||||
// that we can show the initial loading state as quickly as possible.
|
||||
//
|
||||
// If we hit a "Delayed" case, such as when we'd switch from content back into
|
||||
// a fallback, then we should always suspend/restart. Transitions apply
|
||||
// to this case. If none is defined, JND is used instead.
|
||||
//
|
||||
// If we're already showing a fallback and it gets "retried", allowing us to show
|
||||
// another level, but there's still an inner boundary that would show a fallback,
|
||||
// then we suspend/restart for 500ms since the last time we showed a fallback
|
||||
// anywhere in the tree. This effectively throttles progressive loading into a
|
||||
// consistent train of commits. This also gives us an opportunity to restart to
|
||||
// get to the completed state slightly earlier.
|
||||
//
|
||||
// If there's ambiguity due to batching it's resolved in preference of:
|
||||
// 1) "delayed", 2) "initial render", 3) "retry".
|
||||
//
|
||||
// We want to ensure that a "busy" state doesn't get force committed. We want to
|
||||
// ensure that new initial loading states can commit as soon as possible.
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
// TODO: I think we can remove this, since we now use `DidCapture` in
|
||||
// the begin phase to prevent an early bailout.
|
||||
suspenseBoundary.lanes = rootRenderLanes;
|
||||
return suspenseBoundary;
|
||||
}
|
||||
|
||||
function throwException(
|
||||
root: FiberRoot,
|
||||
returnFiber: Fiber,
|
||||
@@ -458,13 +457,16 @@ function throwException(
|
||||
}
|
||||
|
||||
// Schedule the nearest Suspense to re-render the timed out view.
|
||||
const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
|
||||
if (suspenseBoundary !== null) {
|
||||
suspenseBoundary.flags &= ~ForceClientRender;
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
attachWakeableListeners(
|
||||
suspenseBoundary,
|
||||
root,
|
||||
@@ -487,20 +489,24 @@ function throwException(
|
||||
} else {
|
||||
// This is a regular error, not a Suspense wakeable.
|
||||
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
|
||||
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
|
||||
// If the error was thrown during hydration, we may be able to recover by
|
||||
// discarding the dehydrated content and switching to a client render.
|
||||
// Instead of surfacing the error, find the nearest Suspense boundary
|
||||
// and render it again without hydration.
|
||||
const suspenseBoundary = markNearestSuspenseBoundaryShouldCapture(
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
if (suspenseBoundary !== null) {
|
||||
// Set a flag to indicate that we should try rendering the normal
|
||||
// children again, not the fallback.
|
||||
suspenseBoundary.flags |= ForceClientRender;
|
||||
if ((suspenseBoundary.flags & ShouldCapture) === NoFlags) {
|
||||
// Set a flag to indicate that we should try rendering the normal
|
||||
// children again, not the fallback.
|
||||
suspenseBoundary.flags |= ForceClientRender;
|
||||
}
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -402,5 +402,6 @@
|
||||
"414": "Did not expect this call in production. This is a bug in React. Please file an issue.",
|
||||
"415": "Error parsing the data. It's probably an error code or network corruption.",
|
||||
"416": "This environment don't support binary chunks.",
|
||||
"417": "React currently only supports piping to one writable stream."
|
||||
"417": "React currently only supports piping to one writable stream.",
|
||||
"418": "An error occurred during hydration. The server HTML was replaced with client content"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user