From bc3e2f97ea4522719eaa3d36371399c4e7871bff Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 18 Jan 2019 10:09:58 -0800 Subject: [PATCH 01/21] Basic partial hydration test --- .../ReactDOMServerPartialHydration-test.js | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js new file mode 100644 index 0000000000000..04f1f197c6c86 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -0,0 +1,73 @@ +/** + * 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 React; +let ReactDOM; +let ReactDOMServer; +let ReactFeatureFlags; +let Suspense; + +describe('ReactDOMServerPartialHydration', () => { + beforeEach(() => { + jest.resetModuleRegistry(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableSuspenseServerRenderer = true; + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Suspense = React.Suspense; + }); + + it('hydrates a parent even if a child Suspense boundary is blocked', () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + function App() { + return ( +
+ + + + + +
+ ); + } + + // First we render the final HTML. With the streaming renderer + // this may have suspense points on the server but here we want + // to test the completed HTML. Don't suspend on the server. + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + ReactDOM.hydrate(, container); + + // Resolving the promise should continue hydration + resolve(); + }); +}); From 433e6e5e44bde44f3ba96e8188429dbdaa6450c6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 18 Jan 2019 18:36:31 -0800 Subject: [PATCH 02/21] Render comments around Suspense components We need this to be able to identify how far to skip ahead if we're not going to hydrate this subtree yet. --- .../ReactDOMServerSuspense-test.internal.js | 43 +++++++++++-------- .../src/server/ReactPartialRenderer.js | 4 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js index e85e853c34593..06c8a9151c125 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js @@ -52,38 +52,47 @@ describe('ReactDOMServerSuspense', () => { } it('should render the children when no promise is thrown', async () => { - const e = await serverRender( - }> - - , + const c = await serverRender( +
+ }> + + +
, ); + const e = c.children[0]; expect(e.tagName).toBe('DIV'); expect(e.textContent).toBe('Children'); }); it('should render the fallback when a promise thrown', async () => { - const e = await serverRender( - }> - - , + const c = await serverRender( +
+ }> + + +
, ); + const e = c.children[0]; expect(e.tagName).toBe('DIV'); expect(e.textContent).toBe('Fallback'); }); it('should work with nested suspense components', async () => { - const e = await serverRender( - }> -
- - }> - - -
-
, + const c = await serverRender( +
+ }> +
+ + }> + + +
+
+
, ); + const e = c.children[0]; expect(e.innerHTML).toBe('
Children
Fallback
'); }); diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 82d56f3a49e02..629ca1131968d 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -978,7 +978,7 @@ class ReactDOMServerRenderer { children: nextChildren, childIndex: 0, context: context, - footer: '', + footer: '', }; if (__DEV__) { ((frame: any): FrameDev).debugElementStack = []; @@ -986,7 +986,7 @@ class ReactDOMServerRenderer { } this.stack.push(frame); this.suspenseDepth++; - return ''; + return ''; } else { invariant(false, 'ReactDOMServer does not yet support Suspense.'); } From 2d28a52f254caa993d51004010698144f53d2de6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 23 Jan 2019 22:54:24 -0800 Subject: [PATCH 03/21] Add DehydratedSuspenseComponent type of work Will be used for Suspense boundaries that are left with their server rendered content intact. --- .../src/ReactDebugFiberPerf.js | 4 +++- .../src/ReactFiberBeginWork.js | 18 +++++++++++++++++ .../src/ReactFiberCompleteWork.js | 12 +++++++++++ .../src/ReactFiberSuspenseComponent.js | 10 ++++++++++ .../src/ReactFiberUnwindWork.js | 20 ++++++++++++++++++- packages/shared/ReactWorkTags.js | 1 + 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactDebugFiberPerf.js b/packages/react-reconciler/src/ReactDebugFiberPerf.js index 818c821d2f262..96b71a33c2dae 100644 --- a/packages/react-reconciler/src/ReactDebugFiberPerf.js +++ b/packages/react-reconciler/src/ReactDebugFiberPerf.js @@ -21,6 +21,7 @@ import { ContextConsumer, Mode, SuspenseComponent, + DehydratedSuspenseComponent, } from 'shared/ReactWorkTags'; type MeasurementPhase = @@ -317,7 +318,8 @@ export function stopFailedWorkTimer(fiber: Fiber): void { } fiber._debugIsCurrentlyTiming = false; const warning = - fiber.tag === SuspenseComponent + fiber.tag === SuspenseComponent || + fiber.tag === DehydratedSuspenseComponent ? 'Rendering was suspended' : 'An error was thrown inside this error boundary'; endFiberMark(fiber, null, warning); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index e4420d7004e8e..84df11b422d81 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -30,6 +30,7 @@ import { ContextConsumer, Profiler, SuspenseComponent, + DehydratedSuspenseComponent, MemoComponent, SimpleMemoComponent, LazyComponent, @@ -1392,6 +1393,13 @@ function updateSuspenseComponent( // children -- we skip over the primary children entirely. let next; if (current === null) { + // If we're currently hydrating, try to hydrate this boundary. + tryToClaimNextHydratableInstance(workInProgress); + // This could've changed the tag if this was a dehydrated suspense component. + if (workInProgress.tag === DehydratedSuspenseComponent) { + return updateDehydratedSuspenseComponent(null, workInProgress); + } + // This is the initial mount. This branch is pretty simple because there's // no previous state that needs to be preserved. if (nextDidTimeout) { @@ -1598,6 +1606,13 @@ function updateSuspenseComponent( return next; } +function updateDehydratedSuspenseComponent( + current: Fiber | null, + workInProgress: Fiber, +) { + return null; +} + function updatePortalComponent( current: Fiber | null, workInProgress: Fiber, @@ -2051,6 +2066,9 @@ function beginWork( renderExpirationTime, ); } + case DehydratedSuspenseComponent: { + return updateDehydratedSuspenseComponent(current, workInProgress); + } default: invariant( false, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 01bca07a3ce3e..1d092ef29e64e 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -34,6 +34,7 @@ import { Mode, Profiler, SuspenseComponent, + DehydratedSuspenseComponent, MemoComponent, SimpleMemoComponent, LazyComponent, @@ -762,6 +763,17 @@ function completeWork( } break; } + case DehydratedSuspenseComponent: { + if (current === null) { + let wasHydrated = popHydrationState(workInProgress); + invariant( + wasHydrated, + 'A dehydrated suspense component was completed without a hydrated node. ' + + 'This is probably a bug in React.', + ); + } + break; + } default: invariant( false, diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 33c19e5ac1e5a..a7cd122c384f2 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -24,3 +24,13 @@ export function shouldCaptureSuspense(workInProgress: Fiber): boolean { const nextState: SuspenseState | null = workInProgress.memoizedState; return nextState === null; } + +export function shouldCaptureDehydratedSuspense( + workInProgress: Fiber, +): boolean { + // In order to capture, the Suspense component must have a fallback prop. + if (workInProgress.memoizedProps.fallback === undefined) { + return false; + } + return true; +} diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index df5efdf241f42..7d48e5bf54201 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -25,6 +25,7 @@ import { HostPortal, ContextProvider, SuspenseComponent, + DehydratedSuspenseComponent, IncompleteClassComponent, } from 'shared/ReactWorkTags'; import { @@ -36,7 +37,10 @@ import { } from 'shared/ReactSideEffectTags'; import {enableSchedulerTracing} from 'shared/ReactFeatureFlags'; import {ConcurrentMode} from './ReactTypeOfMode'; -import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent'; +import { + shouldCaptureSuspense, + shouldCaptureDehydratedSuspense, +} from './ReactFiberSuspenseComponent'; import {createCapturedValue} from './ReactCapturedValue'; import { @@ -197,6 +201,8 @@ function throwException( earliestTimeoutMs = timeoutPropMs; } } + } else if (workInProgress.tag === DehydratedSuspenseComponent) { + // TODO } workInProgress = workInProgress.return; } while (workInProgress !== null); @@ -334,6 +340,12 @@ function throwException( workInProgress.effectTag |= ShouldCapture; workInProgress.expirationTime = renderExpirationTime; return; + } else if ( + workInProgress.tag === DehydratedSuspenseComponent && + shouldCaptureDehydratedSuspense(workInProgress) + ) { + // TODO + return; } // This boundary already captured during this render. Continue to the next // boundary. @@ -432,6 +444,7 @@ function unwindWork( return workInProgress; } case HostComponent: { + // TODO: popHydrationState popHostContext(workInProgress); return null; } @@ -444,6 +457,11 @@ function unwindWork( } return null; } + case DehydratedSuspenseComponent: { + // TODO: popHydrationState + // TODO: Maybe re-render if it captured? + return null; + } case HostPortal: popHostContainer(workInProgress); return null; diff --git a/packages/shared/ReactWorkTags.js b/packages/shared/ReactWorkTags.js index aed8150217930..796113cb76e57 100644 --- a/packages/shared/ReactWorkTags.js +++ b/packages/shared/ReactWorkTags.js @@ -46,3 +46,4 @@ export const MemoComponent = 14; export const SimpleMemoComponent = 15; export const LazyComponent = 16; export const IncompleteClassComponent = 17; +export const DehydratedSuspenseComponent = 18; From f06a540cf044878cb2b3ffcc2836c7d66440e717 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 23 Jan 2019 23:51:47 -0800 Subject: [PATCH 04/21] Add comment node as hydratable instance type as placeholder for suspense --- .../src/client/ReactDOMHostConfig.js | 62 +++++++++++++++---- .../src/ReactFiberHydrationContext.js | 35 ++++++++++- .../src/forks/ReactFiberHostConfig.custom.js | 6 ++ packages/shared/HostConfigWithNoHydration.js | 3 + 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index bee91214ced16..a7fcdf27b1908 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -56,7 +56,8 @@ export type Props = { export type Container = Element | Document; export type Instance = Element; export type TextInstance = Text; -export type HydratableInstance = Element | Text; +export type SuspenseInstance = Comment; +export type HydratableInstance = Instance | TextInstance | SuspenseInstance; export type PublicInstance = Element | Text; type HostContextDev = { namespace: string, @@ -85,6 +86,8 @@ if (__DEV__) { SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; } +const SUSPENSE_DATA = '$'; + const STYLE = 'style'; let eventsEnabled: ?boolean = null; @@ -416,14 +419,14 @@ export function insertInContainerBefore( export function removeChild( parentInstance: Instance, - child: Instance | TextInstance, + child: Instance | TextInstance | SuspenseInstance, ): void { parentInstance.removeChild(child); } export function removeChildFromContainer( container: Container, - child: Instance | TextInstance, + child: Instance | TextInstance | SuspenseInstance, ): void { if (container.nodeType === COMMENT_NODE) { (container.parentNode: any).removeChild(child); @@ -469,7 +472,7 @@ export function unhideTextInstance( export const supportsHydration = true; export function canHydrateInstance( - instance: Instance | TextInstance, + instance: HydratableInstance, type: string, props: Props, ): null | Instance { @@ -484,7 +487,7 @@ export function canHydrateInstance( } export function canHydrateTextInstance( - instance: Instance | TextInstance, + instance: HydratableInstance, text: string, ): null | TextInstance { if (text === '' || instance.nodeType !== TEXT_NODE) { @@ -495,15 +498,27 @@ export function canHydrateTextInstance( return ((instance: any): TextInstance); } +export function canHydrateSuspenseInstance( + instance: HydratableInstance, +): null | SuspenseInstance { + if (instance.nodeType !== COMMENT_NODE) { + // Empty strings are not parsed by HTML so there won't be a correct match here. + return null; + } + // This has now been refined to a text node. + return ((instance: any): SuspenseInstance); +} + export function getNextHydratableSibling( - instance: Instance | TextInstance, -): null | Instance | TextInstance { + instance: HydratableInstance, +): null | HydratableInstance { let node = instance.nextSibling; // Skip non-hydratable nodes. while ( node && node.nodeType !== ELEMENT_NODE && - node.nodeType !== TEXT_NODE + node.nodeType !== TEXT_NODE && + (node.nodeType !== COMMENT_NODE || (node: any).data !== SUSPENSE_DATA) ) { node = node.nextSibling; } @@ -512,13 +527,14 @@ export function getNextHydratableSibling( export function getFirstHydratableChild( parentInstance: Container | Instance, -): null | Instance | TextInstance { +): null | HydratableInstance { let next = parentInstance.firstChild; // Skip non-hydratable nodes. while ( next && next.nodeType !== ELEMENT_NODE && - next.nodeType !== TEXT_NODE + next.nodeType !== TEXT_NODE && + (next.nodeType !== COMMENT_NODE || (next: any).data !== SUSPENSE_DATA) ) { next = next.nextSibling; } @@ -586,11 +602,13 @@ export function didNotMatchHydratedTextInstance( export function didNotHydrateContainerInstance( parentContainer: Container, - instance: Instance | TextInstance, + instance: HydratableInstance, ) { if (__DEV__) { if (instance.nodeType === ELEMENT_NODE) { warnForDeletedHydratableElement(parentContainer, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary } else { warnForDeletedHydratableText(parentContainer, (instance: any)); } @@ -601,11 +619,13 @@ export function didNotHydrateInstance( parentType: string, parentProps: Props, parentInstance: Instance, - instance: Instance | TextInstance, + instance: HydratableInstance, ) { if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { if (instance.nodeType === ELEMENT_NODE) { warnForDeletedHydratableElement(parentInstance, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary } else { warnForDeletedHydratableText(parentInstance, (instance: any)); } @@ -631,6 +651,14 @@ export function didNotFindHydratableContainerTextInstance( } } +export function didNotFindHydratableContainerSuspenseInstance( + parentContainer: Container, +) { + if (__DEV__) { + // TODO: warnForInsertedHydratedSupsense(parentContainer); + } +} + export function didNotFindHydratableInstance( parentType: string, parentProps: Props, @@ -653,3 +681,13 @@ export function didNotFindHydratableTextInstance( warnForInsertedHydratedText(parentInstance, text); } } + +export function didNotFindHydratableSuspenseInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, +) { + if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + // TODO: warnForInsertedHydratedSuspense(parentInstance); + } +} diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 573a95f5178e6..65d54b19a7867 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -12,11 +12,18 @@ import type { Instance, TextInstance, HydratableInstance, + SuspenseInstance, Container, HostContext, } from './ReactFiberHostConfig'; -import {HostComponent, HostText, HostRoot} from 'shared/ReactWorkTags'; +import { + HostComponent, + HostText, + HostRoot, + SuspenseComponent, + DehydratedSuspenseComponent, +} from 'shared/ReactWorkTags'; import {Deletion, Placement} from 'shared/ReactSideEffectTags'; import invariant from 'shared/invariant'; @@ -26,6 +33,7 @@ import { supportsHydration, canHydrateInstance, canHydrateTextInstance, + canHydrateSuspenseInstance, getNextHydratableSibling, getFirstHydratableChild, hydrateInstance, @@ -36,8 +44,10 @@ import { didNotHydrateInstance, didNotFindHydratableContainerInstance, didNotFindHydratableContainerTextInstance, + didNotFindHydratableContainerSuspenseInstance, didNotFindHydratableInstance, didNotFindHydratableTextInstance, + didNotFindHydratableSuspenseInstance, } from './ReactFiberHostConfig'; // The deepest Fiber on the stack involved in a hydration context. @@ -115,6 +125,9 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { const text = fiber.pendingProps; didNotFindHydratableContainerTextInstance(parentContainer, text); break; + case SuspenseComponent: + didNotFindHydratableContainerSuspenseInstance(parentContainer); + break; } break; } @@ -143,6 +156,13 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { text, ); break; + case SuspenseComponent: + didNotFindHydratableSuspenseInstance( + parentType, + parentProps, + parentInstance, + ); + break; } break; } @@ -173,6 +193,16 @@ function tryHydrate(fiber, nextInstance) { } return false; } + case SuspenseComponent: { + const suspenseInstance = canHydrateSuspenseInstance(nextInstance); + if (suspenseInstance !== null) { + // Downgrade the tag to a dehydrated component until we've hydrated it. + fiber.tag = DehydratedSuspenseComponent; + fiber.stateNode = (suspenseInstance: SuspenseInstance); + return true; + } + return false; + } default: return false; } @@ -301,7 +331,8 @@ function popToNextHostParent(fiber: Fiber): void { while ( parent !== null && parent.tag !== HostComponent && - parent.tag !== HostRoot + parent.tag !== HostRoot && + parent.tag !== DehydratedSuspenseComponent ) { parent = parent.return; } diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index c18c7eee6d464..7acf8944a7115 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -104,6 +104,8 @@ export const createHiddenTextInstance = $$$hostConfig.createHiddenTextInstance; // ------------------- export const canHydrateInstance = $$$hostConfig.canHydrateInstance; export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; +export const canHydrateSuspenseInstance = + $$$hostConfig.canHydrateSuspenseInstance; export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; export const hydrateInstance = $$$hostConfig.hydrateInstance; @@ -119,7 +121,11 @@ export const didNotFindHydratableContainerInstance = $$$hostConfig.didNotFindHydratableContainerInstance; export const didNotFindHydratableContainerTextInstance = $$$hostConfig.didNotFindHydratableContainerTextInstance; +export const didNotFindHydratableContainerSuspenseInstance = + $$$hostConfig.didNotFindHydratableContainerSuspenseInstance; export const didNotFindHydratableInstance = $$$hostConfig.didNotFindHydratableInstance; export const didNotFindHydratableTextInstance = $$$hostConfig.didNotFindHydratableTextInstance; +export const didNotFindHydratableSuspenseInstance = + $$$hostConfig.didNotFindHydratableSuspenseInstance; diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 36ac74184cae3..84731687096c7 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -25,6 +25,7 @@ function shim(...args: any) { export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; +export const canHydrateSuspenseInstance = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const hydrateInstance = shim; @@ -35,5 +36,7 @@ export const didNotHydrateContainerInstance = shim; export const didNotHydrateInstance = shim; export const didNotFindHydratableContainerInstance = shim; export const didNotFindHydratableContainerTextInstance = shim; +export const didNotFindHydratableContainerSuspenseInstance = shim; export const didNotFindHydratableInstance = shim; export const didNotFindHydratableTextInstance = shim; +export const didNotFindHydratableSuspenseInstance = shim; From 97a6664bdb3d5247d23c60dacf752ccf97fdb1d7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 27 Jan 2019 18:07:44 -0800 Subject: [PATCH 05/21] Skip past nodes within the Suspense boundary This lets us continue hydrating sibling nodes. --- .../src/client/ReactDOMHostConfig.js | 34 +++++++++++++++++-- .../src/ReactFiberCompleteWork.js | 2 ++ .../src/ReactFiberHydrationContext.js | 21 ++++++++++++ .../src/forks/ReactFiberHostConfig.custom.js | 2 ++ packages/shared/HostConfigWithNoHydration.js | 1 + 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index a7fcdf27b1908..d881159ef81c2 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -86,7 +86,8 @@ if (__DEV__) { SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; } -const SUSPENSE_DATA = '$'; +const SUSPENSE_START_DATA = '$'; +const SUSPENSE_END_DATA = '/$'; const STYLE = 'style'; @@ -518,7 +519,7 @@ export function getNextHydratableSibling( node && node.nodeType !== ELEMENT_NODE && node.nodeType !== TEXT_NODE && - (node.nodeType !== COMMENT_NODE || (node: any).data !== SUSPENSE_DATA) + (node.nodeType !== COMMENT_NODE || (node: any).data !== SUSPENSE_START_DATA) ) { node = node.nextSibling; } @@ -534,7 +535,7 @@ export function getFirstHydratableChild( next && next.nodeType !== ELEMENT_NODE && next.nodeType !== TEXT_NODE && - (next.nodeType !== COMMENT_NODE || (next: any).data !== SUSPENSE_DATA) + (next.nodeType !== COMMENT_NODE || (next: any).data !== SUSPENSE_START_DATA) ) { next = next.nextSibling; } @@ -578,6 +579,33 @@ export function hydrateTextInstance( return diffHydratedText(textInstance, text); } +export function getNextHydratableInstanceAfterSuspenseInstance( + suspenseInstance: SuspenseInstance, +): null | HydratableInstance { + let node = suspenseInstance.nextSibling; + // Skip past all nodes within this suspense boundary. + // There might be nested nodes so we need to keep track of how + // deep we are and only break out when we're back on top. + let depth = 0; + while (node) { + if (node.nodeType === COMMENT_NODE) { + let data = ((node: any).data: string); + if (data === SUSPENSE_END_DATA) { + if (depth === 0) { + return getNextHydratableSibling((node: any)); + } else { + depth--; + } + } else if (data === SUSPENSE_START_DATA) { + depth++; + } + } + node = node.nextSibling; + } + // TODO: Warn, we didn't find the end comment boundary. + return null; +} + export function didNotMatchHydratedContainerTextInstance( parentContainer: Container, textInstance: TextInstance, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 1d092ef29e64e..3be2ddab6623b 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -81,6 +81,7 @@ import {popProvider} from './ReactFiberNewContext'; import { prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, + skipPastDehydratedSuspenseInstance, popHydrationState, } from './ReactFiberHydrationContext'; @@ -771,6 +772,7 @@ function completeWork( 'A dehydrated suspense component was completed without a hydrated node. ' + 'This is probably a bug in React.', ); + skipPastDehydratedSuspenseInstance(workInProgress); } break; } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 65d54b19a7867..9171876a90665 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -38,6 +38,7 @@ import { getFirstHydratableChild, hydrateInstance, hydrateTextInstance, + getNextHydratableInstanceAfterSuspenseInstance, didNotMatchHydratedContainerTextInstance, didNotMatchHydratedTextInstance, didNotHydrateContainerInstance, @@ -326,6 +327,25 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { return shouldUpdate; } +function skipPastDehydratedSuspenseInstance(fiber: Fiber): void { + if (!supportsHydration) { + invariant( + false, + 'Expected skipPastDehydratedSuspenseInstance() to never be called. ' + + 'This error is likely caused by a bug in React. Please file an issue.', + ); + } + let suspenseInstance = fiber.stateNode; + invariant( + suspenseInstance, + 'Expected to have a hydrated suspense instance. ' + + 'This error is likely caused by a bug in React. Please file an issue.', + ); + nextHydratableInstance = getNextHydratableInstanceAfterSuspenseInstance( + suspenseInstance, + ); +} + function popToNextHostParent(fiber: Fiber): void { let parent = fiber.return; while ( @@ -400,5 +420,6 @@ export { tryToClaimNextHydratableInstance, prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, + skipPastDehydratedSuspenseInstance, popHydrationState, }; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 7acf8944a7115..bb7d517994bcb 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -110,6 +110,8 @@ export const getNextHydratableSibling = $$$hostConfig.getNextHydratableSibling; export const getFirstHydratableChild = $$$hostConfig.getFirstHydratableChild; export const hydrateInstance = $$$hostConfig.hydrateInstance; export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; +export const getNextHydratableInstanceAfterSuspenseInstance = + $$$hostConfig.getNextHydratableInstanceAfterSuspenseInstance; export const didNotMatchHydratedContainerTextInstance = $$$hostConfig.didNotMatchHydratedContainerTextInstance; export const didNotMatchHydratedTextInstance = diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 84731687096c7..802cd637a7116 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -30,6 +30,7 @@ export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const hydrateInstance = shim; export const hydrateTextInstance = shim; +export const getNextHydratableInstanceAfterSuspenseInstance = shim; export const didNotMatchHydratedContainerTextInstance = shim; export const didNotMatchHydratedTextInstance = shim; export const didNotHydrateContainerInstance = shim; From 1b3e0a2ae5124f510800f8a3b1276b350a6896ae Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 27 Jan 2019 19:01:59 -0800 Subject: [PATCH 06/21] A dehydrated suspense boundary comment should be considered a sibling --- .../ReactDOMServerPartialHydration-test.js | 58 +++++++++++++++++++ .../src/client/ReactDOMHostConfig.js | 4 +- .../src/ReactFiberCommitWork.js | 7 ++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js index 04f1f197c6c86..3fe5ba733e6e2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -70,4 +70,62 @@ describe('ReactDOMServerPartialHydration', () => { // Resolving the promise should continue hydration resolve(); }); + + it('can insert siblings before the dehydrated boundary', () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let showSibling; + + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + function Sibling() { + let [visible, setVisibilty] = React.useState(false); + showSibling = () => setVisibilty(true); + if (visible) { + return
Hello
; + } + return null; + } + + function App() { + return ( +
+ + + + + + +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + ReactDOM.hydrate(, container); + + expect(container.firstChild.firstChild.tagName).not.toBe('DIV'); + + // In this state, we can still update the siblings. + showSibling(); + + expect(container.firstChild.firstChild.tagName).toBe('DIV'); + expect(container.firstChild.firstChild.textContent).toBe('Hello'); + + // Resolving the promise should continue hydration + resolve(); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index d881159ef81c2..5e81494670eaa 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -401,7 +401,7 @@ export function appendChildToContainer( export function insertBefore( parentInstance: Instance, child: Instance | TextInstance, - beforeChild: Instance | TextInstance, + beforeChild: Instance | TextInstance | SuspenseInstance, ): void { parentInstance.insertBefore(child, beforeChild); } @@ -409,7 +409,7 @@ export function insertBefore( export function insertInContainerBefore( container: Container, child: Instance | TextInstance, - beforeChild: Instance | TextInstance, + beforeChild: Instance | TextInstance | SuspenseInstance, ): void { if (container.nodeType === COMMENT_NODE) { (container.parentNode: any).insertBefore(child, beforeChild); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 1ffb2f0eff4a0..8e510f8897d5b 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -37,6 +37,7 @@ import { HostPortal, Profiler, SuspenseComponent, + DehydratedSuspenseComponent, IncompleteClassComponent, MemoComponent, SimpleMemoComponent, @@ -881,7 +882,11 @@ function getHostSibling(fiber: Fiber): ?Instance { } node.sibling.return = node.return; node = node.sibling; - while (node.tag !== HostComponent && node.tag !== HostText) { + while ( + node.tag !== HostComponent && + node.tag !== HostText && + node.tag !== DehydratedSuspenseComponent + ) { // If it is not host node and, we might have a host node inside it. // Try to search down until we find one. if (node.effectTag & Placement) { From 6febcae193c83b3171c8c001c28b6b1035b1002e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 27 Jan 2019 21:41:58 -0800 Subject: [PATCH 07/21] Retry hydrating at offscreen pri or after ping if suspended --- .../ReactDOMServerPartialHydration-test.js | 21 ++-- .../src/ReactFiberBeginWork.js | 50 +++++++- .../src/ReactFiberCompleteWork.js | 9 ++ .../src/ReactFiberScheduler.js | 19 +++- .../src/ReactFiberUnwindWork.js | 107 ++++++++++++------ 5 files changed, 159 insertions(+), 47 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js index 3fe5ba733e6e2..125f27715d31a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -28,7 +28,7 @@ describe('ReactDOMServerPartialHydration', () => { Suspense = React.Suspense; }); - it('hydrates a parent even if a child Suspense boundary is blocked', () => { + it('hydrates a parent even if a child Suspense boundary is blocked', async () => { let suspend = false; let resolve; let promise = new Promise(resolvePromise => (resolve = resolvePromise)); @@ -65,23 +65,27 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; - ReactDOM.hydrate(, container); + const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + jest.runAllTimers(); // Resolving the promise should continue hydration + suspend = false; resolve(); + await promise; + jest.runAllTimers(); }); it('can insert siblings before the dehydrated boundary', () => { let suspend = false; - let resolve; - let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let promise = new Promise(() => {}); let showSibling; function Child() { if (suspend) { throw promise; } else { - return 'Hello'; + return 'Second'; } } @@ -89,7 +93,7 @@ describe('ReactDOMServerPartialHydration', () => { let [visible, setVisibilty] = React.useState(false); showSibling = () => setVisibilty(true); if (visible) { - return
Hello
; + return
First
; } return null; } @@ -123,9 +127,6 @@ describe('ReactDOMServerPartialHydration', () => { showSibling(); expect(container.firstChild.firstChild.tagName).toBe('DIV'); - expect(container.firstChild.firstChild.textContent).toBe('Hello'); - - // Resolving the promise should continue hydration - resolve(); + expect(container.firstChild.firstChild.textContent).toBe('First'); }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 84df11b422d81..274067b4c6891 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1397,7 +1397,11 @@ function updateSuspenseComponent( tryToClaimNextHydratableInstance(workInProgress); // This could've changed the tag if this was a dehydrated suspense component. if (workInProgress.tag === DehydratedSuspenseComponent) { - return updateDehydratedSuspenseComponent(null, workInProgress); + return updateDehydratedSuspenseComponent( + null, + workInProgress, + renderExpirationTime, + ); } // This is the initial mount. This branch is pretty simple because there's @@ -1609,8 +1613,37 @@ function updateSuspenseComponent( function updateDehydratedSuspenseComponent( current: Fiber | null, workInProgress: Fiber, + renderExpirationTime: ExpirationTime, ) { - return null; + if (current === null) { + // During the first pass, we'll bail out and not drill into the children. + // Instead, we'll leave the content in place and try to hydrate it later. + workInProgress.expirationTime = workInProgress.childExpirationTime = Never; + return null; + } + if ((workInProgress.effectTag & DidCapture) === NoEffect) { + // This is the first attempt. + const prevProps = current.memoizedProps; + const nextProps = workInProgress.pendingProps; + if (prevProps !== nextProps) { + // TODO: Delete children and upgrade to a regular suspense component without + // hydrating. + } + // TODO: Restore hydration state + const nextChildren = nextProps.children; + workInProgress.child = mountChildFibers( + workInProgress, + null, + nextChildren, + renderExpirationTime, + ); + return workInProgress.child; + } else { + // Something suspended. Leave the existing children in place. + // TODO: In non-concurrent mode, should we commit the nodes we have hydrated so far? + workInProgress.child = null; + return null; + } } function updatePortalComponent( @@ -1897,6 +1930,13 @@ function beginWork( } break; } + case DehydratedSuspenseComponent: { + // We know that this component will suspend again because if it has + // been unsuspended it has committed as a regular Suspense component. + // If it needs to be retried, it should have work scheduled on it. + workInProgress.effectTag |= DidCapture; + break; + } } return bailoutOnAlreadyFinishedWork( current, @@ -2067,7 +2107,11 @@ function beginWork( ); } case DehydratedSuspenseComponent: { - return updateDehydratedSuspenseComponent(current, workInProgress); + return updateDehydratedSuspenseComponent( + current, + workInProgress, + renderExpirationTime, + ); } default: invariant( diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 3be2ddab6623b..851a3b729ae2b 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -773,6 +773,15 @@ function completeWork( 'This is probably a bug in React.', ); skipPastDehydratedSuspenseInstance(workInProgress); + } else if ((workInProgress.effectTag & DidCapture) === NoEffect) { + // This boundary did not suspend so it's now hydrated. + // To handle any future suspense cases, we're going to now upgrade it + // to a Suspense component. We detach it from the existing current fiber. + current.alternate = null; + workInProgress.alternate = null; + workInProgress.tag = SuspenseComponent; + workInProgress.memoizedState = null; + workInProgress.stateNode = null; } break; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index baa100f0d266a..bfb480514c93b 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -60,6 +60,8 @@ import { HostRoot, MemoComponent, SimpleMemoComponent, + SuspenseComponent, + DehydratedSuspenseComponent, } from 'shared/ReactWorkTags'; import { enableSchedulerTracing, @@ -1697,8 +1699,21 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, thenable: Thenable) { // resolved, which means at least part of the tree was likely unblocked. Try // rendering again, at a new expiration time. - const retryCache: WeakSet | Set | null = - boundaryFiber.stateNode; + let retryCache: WeakSet | Set | null; + switch (boundaryFiber.tag) { + case SuspenseComponent: + retryCache = boundaryFiber.stateNode; + break; + case DehydratedSuspenseComponent: + retryCache = boundaryFiber.memoizedState; + break; + default: + invariant( + false, + 'Pinged unknown suspense boundary type. ' + + 'This is probably a bug in React.', + ); + } if (retryCache !== null) { // The thenable resolved, so we no longer need to memoize, because it will // never be thrown again. diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 7d48e5bf54201..8ad292d44d10a 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -66,6 +66,7 @@ import { markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, + retryTimedOutBoundary, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -77,6 +78,7 @@ import { } from './ReactFiberExpirationTime'; import {findEarliestOutstandingPriorityLevel} from './ReactFiberPendingPriority'; +const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; function createRootErrorUpdate( @@ -148,6 +150,43 @@ function createClassErrorUpdate( return update; } +function attachPingListener( + root: FiberRoot, + renderExpirationTime: ExpirationTime, + thenable: Thenable, +) { + // Attach a listener to the promise to "ping" the root and retry. But + // only if one does not already exist for the current render expiration + // time (which acts like a "thread ID" here). + let pingCache = root.pingCache; + let threadIDs; + if (pingCache === null) { + pingCache = root.pingCache = new PossiblyWeakMap(); + threadIDs = new Set(); + pingCache.set(thenable, threadIDs); + } else { + threadIDs = pingCache.get(thenable); + if (threadIDs === undefined) { + threadIDs = new Set(); + pingCache.set(thenable, threadIDs); + } + } + if (!threadIDs.has(renderExpirationTime)) { + // Memoize using the thread ID to prevent redundant listeners. + threadIDs.add(renderExpirationTime); + let ping = pingSuspendedRoot.bind( + null, + root, + thenable, + renderExpirationTime, + ); + if (enableSchedulerTracing) { + ping = Schedule_tracing_wrap(ping); + } + thenable.then(ping, ping); + } +} + function throwException( root: FiberRoot, returnFiber: Fiber, @@ -271,36 +310,7 @@ function throwException( // Confirmed that the boundary is in a concurrent mode tree. Continue // with the normal suspend path. - // Attach a listener to the promise to "ping" the root and retry. But - // only if one does not already exist for the current render expiration - // time (which acts like a "thread ID" here). - let pingCache = root.pingCache; - let threadIDs; - if (pingCache === null) { - pingCache = root.pingCache = new PossiblyWeakMap(); - threadIDs = new Set(); - pingCache.set(thenable, threadIDs); - } else { - threadIDs = pingCache.get(thenable); - if (threadIDs === undefined) { - threadIDs = new Set(); - pingCache.set(thenable, threadIDs); - } - } - if (!threadIDs.has(renderExpirationTime)) { - // Memoize using the thread ID to prevent redundant listeners. - threadIDs.add(renderExpirationTime); - let ping = pingSuspendedRoot.bind( - null, - root, - thenable, - renderExpirationTime, - ); - if (enableSchedulerTracing) { - ping = Schedule_tracing_wrap(ping); - } - thenable.then(ping, ping); - } + attachPingListener(root, renderExpirationTime, thenable); let absoluteTimeoutMs; if (earliestTimeoutMs === -1) { @@ -344,7 +354,35 @@ function throwException( workInProgress.tag === DehydratedSuspenseComponent && shouldCaptureDehydratedSuspense(workInProgress) ) { - // TODO + attachPingListener(root, renderExpirationTime, thenable); + + // Since we already have a current fiber, we can eagerly add a ping listener. + let retryCache = workInProgress.memoizedState; + if (retryCache === null) { + retryCache = workInProgress.memoizedState = new PossiblyWeakSet(); + const current = workInProgress.alternate; + invariant( + current, + 'A dehydrated suspense boundary must commit before trying to render. ' + + 'This is probably a bug in React.', + ); + current.memoizedState = retryCache; + } + // Memoize using the boundary fiber to prevent redundant listeners. + if (!retryCache.has(thenable)) { + retryCache.add(thenable); + let retry = retryTimedOutBoundary.bind( + null, + workInProgress, + thenable, + ); + if (enableSchedulerTracing) { + retry = Schedule_tracing_wrap(retry); + } + thenable.then(retry, retry); + } + workInProgress.effectTag |= ShouldCapture; + workInProgress.expirationTime = renderExpirationTime; return; } // This boundary already captured during this render. Continue to the next @@ -459,7 +497,12 @@ function unwindWork( } case DehydratedSuspenseComponent: { // TODO: popHydrationState - // TODO: Maybe re-render if it captured? + const effectTag = workInProgress.effectTag; + if (effectTag & ShouldCapture) { + workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture; + // Captured a suspense effect. Re-render the boundary. + return workInProgress; + } return null; } case HostPortal: From 7af0b8a6d9471caef80c67aee8992013e6206161 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 27 Jan 2019 21:55:16 -0800 Subject: [PATCH 08/21] Enter hydration state when retrying dehydrated suspense boundary --- .../ReactDOMServerPartialHydration-test.js | 12 ++++++++++-- .../react-reconciler/src/ReactFiberBeginWork.js | 3 ++- .../src/ReactFiberHydrationContext.js | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js index 125f27715d31a..f348ff78e199c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -32,6 +32,7 @@ describe('ReactDOMServerPartialHydration', () => { let suspend = false; let resolve; let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); function Child() { if (suspend) { @@ -45,7 +46,7 @@ describe('ReactDOMServerPartialHydration', () => { return (
- + @@ -62,18 +63,25 @@ describe('ReactDOMServerPartialHydration', () => { let container = document.createElement('div'); container.innerHTML = finalHTML; + let span = container.getElementsByTagName('span')[0]; + // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; - const root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); jest.runAllTimers(); + expect(ref.current).toBe(null); + // Resolving the promise should continue hydration suspend = false; resolve(); await promise; jest.runAllTimers(); + + // We should now have hydrated with a ref on the existing span. + expect(ref.current).toBe(span); }); it('can insert siblings before the dehydrated boundary', () => { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 274067b4c6891..48b8e632c4a0b 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -104,6 +104,7 @@ import { } from './ReactFiberContext'; import { enterHydrationState, + reenterHydrationStateFromDehydratedSuspenseInstance, resetHydrationState, tryToClaimNextHydratableInstance, } from './ReactFiberHydrationContext'; @@ -1629,7 +1630,7 @@ function updateDehydratedSuspenseComponent( // TODO: Delete children and upgrade to a regular suspense component without // hydrating. } - // TODO: Restore hydration state + reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress); const nextChildren = nextProps.children; workInProgress.child = mountChildFibers( workInProgress, diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 9171876a90665..f7c1d5f0e6486 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -69,6 +69,20 @@ function enterHydrationState(fiber: Fiber): boolean { return true; } +function reenterHydrationStateFromDehydratedSuspenseInstance( + fiber: Fiber, +): boolean { + if (!supportsHydration) { + return false; + } + + const suspenseInstance = fiber.stateNode; + nextHydratableInstance = getNextHydratableSibling(suspenseInstance); + popToNextHostParent(fiber); + isHydrating = true; + return true; +} + function deleteHydratableInstance( returnFiber: Fiber, instance: HydratableInstance, @@ -416,6 +430,7 @@ function resetHydrationState(): void { export { enterHydrationState, + reenterHydrationStateFromDehydratedSuspenseInstance, resetHydrationState, tryToClaimNextHydratableInstance, prepareToHydrateHostInstance, From dac06884324eaad8b10c03bf87ef9b20d2b53284 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 28 Jan 2019 11:23:58 -0800 Subject: [PATCH 09/21] Delete all children within a dehydrated suspense boundary when it's deleted --- .../ReactDOMServerPartialHydration-test.js | 53 +++++++++++++++++++ .../src/client/ReactDOMHostConfig.js | 43 +++++++++++++++ .../src/ReactFiberCommitWork.js | 26 ++++++++- .../src/forks/ReactFiberHostConfig.custom.js | 4 ++ packages/shared/HostConfigWithNoHydration.js | 3 ++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js index f348ff78e199c..81ce222da472b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -137,4 +137,57 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.firstChild.firstChild.tagName).toBe('DIV'); expect(container.firstChild.firstChild.textContent).toBe('First'); }); + + it('can delete the dehydrated boundary before it is hydrated', () => { + let suspend = false; + let promise = new Promise(() => {}); + let hideMiddle; + + function Child() { + if (suspend) { + throw promise; + } else { + return ( + +
Middle
+ Some text +
+ ); + } + } + + function App() { + let [visible, setVisibilty] = React.useState(true); + hideMiddle = () => setVisibilty(false); + + return ( +
+
Before
+ {visible ? ( + + + + ) : null} +
After
+
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + ReactDOM.hydrate(, container); + + expect(container.firstChild.children[1].textContent).toBe('Middle'); + + // In this state, we can still delete the boundary. + hideMiddle(); + + expect(container.firstChild.children[1].textContent).toBe('After'); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 5e81494670eaa..82da6a9cf538e 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -436,6 +436,49 @@ export function removeChildFromContainer( } } +export function clearSuspenseBoundary( + parentInstance: Instance, + suspenseInstance: SuspenseInstance, +): void { + let node = suspenseInstance; + // Delete all nodes within this suspense boundary. + // There might be nested nodes so we need to keep track of how + // deep we are and only break out when we're back on top. + let depth = 0; + do { + let nextNode = node.nextSibling; + parentInstance.removeChild(node); + if (nextNode && nextNode.nodeType === COMMENT_NODE) { + let data = ((nextNode: any).data: string); + if (data === SUSPENSE_END_DATA) { + if (depth === 0) { + parentInstance.removeChild(nextNode); + return; + } else { + depth--; + } + } else if (nextNode === SUSPENSE_START_DATA) { + depth++; + } + } + node = nextNode; + } while (node); + // TODO: Warn, we didn't find the end comment boundary. +} + +export function clearSuspenseBoundaryFromContainer( + container: Container, + suspenseInstance: SuspenseInstance, +): void { + if (container.nodeType === COMMENT_NODE) { + clearSuspenseBoundary((container.parentNode: any), suspenseInstance); + } else if (container.nodeType === ELEMENT_NODE) { + clearSuspenseBoundary((container: any), suspenseInstance); + } else { + // Document nodes should never contain suspense boundaries. + } +} + export function hideInstance(instance: Instance): void { // TODO: Does this work for all element types? What about MathML? Should we // pass host context to this method? diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 8e510f8897d5b..13c19bf8039b7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -10,6 +10,7 @@ import type { Instance, TextInstance, + SuspenseInstance, Container, ChildSet, UpdatePayload, @@ -80,6 +81,8 @@ import { insertInContainerBefore, removeChild, removeChildFromContainer, + clearSuspenseBoundary, + clearSuspenseBoundaryFromContainer, replaceContainerChildren, createContainerChildSet, hideInstance, @@ -1037,11 +1040,30 @@ function unmountHostComponents(current): void { // After all the children have unmounted, it is now safe to remove the // node from the tree. if (currentParentIsContainer) { - removeChildFromContainer((currentParent: any), node.stateNode); + removeChildFromContainer( + ((currentParent: any): Container), + (node.stateNode: Instance | TextInstance), + ); } else { - removeChild((currentParent: any), node.stateNode); + removeChild( + ((currentParent: any): Instance), + (node.stateNode: Instance | TextInstance), + ); } // Don't visit children because we already visited them. + } else if (node.tag === DehydratedSuspenseComponent) { + // Delete the dehydrated suspense boundary and all of its content. + if (currentParentIsContainer) { + clearSuspenseBoundaryFromContainer( + ((currentParent: any): Container), + (node.stateNode: SuspenseInstance), + ); + } else { + clearSuspenseBoundary( + ((currentParent: any): Instance), + (node.stateNode: SuspenseInstance), + ); + } } else if (node.tag === HostPortal) { // When we go into a portal, it becomes the parent to remove from. // We will reassign it back when we pop the portal on the way up. diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index bb7d517994bcb..1cb343db25ecf 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -29,6 +29,7 @@ export opaque type Props = mixed; // eslint-disable-line no-undef export opaque type Container = mixed; // eslint-disable-line no-undef export opaque type Instance = mixed; // eslint-disable-line no-undef export opaque type TextInstance = mixed; // eslint-disable-line no-undef +export opaque type SuspenseInstance = mixed; // eslint-disable-line no-undef export opaque type HydratableInstance = mixed; // eslint-disable-line no-undef export opaque type PublicInstance = mixed; // eslint-disable-line no-undef export opaque type HostContext = mixed; // eslint-disable-line no-undef @@ -112,6 +113,9 @@ export const hydrateInstance = $$$hostConfig.hydrateInstance; export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; export const getNextHydratableInstanceAfterSuspenseInstance = $$$hostConfig.getNextHydratableInstanceAfterSuspenseInstance; +export const clearSuspenseBoundary = $$$hostConfig.clearSuspenseBoundary; +export const clearSuspenseBoundaryFromContainer = + $$$hostConfig.clearSuspenseBoundaryFromContainer; export const didNotMatchHydratedContainerTextInstance = $$$hostConfig.didNotMatchHydratedContainerTextInstance; export const didNotMatchHydratedTextInstance = diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 802cd637a7116..b8e57f80889ac 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -22,6 +22,7 @@ function shim(...args: any) { } // Hydration (when unsupported) +export type SuspenseInstance = mixed; export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; @@ -31,6 +32,8 @@ export const getFirstHydratableChild = shim; export const hydrateInstance = shim; export const hydrateTextInstance = shim; export const getNextHydratableInstanceAfterSuspenseInstance = shim; +export const clearSuspenseBoundary = shim; +export const clearSuspenseBoundaryFromContainer = shim; export const didNotMatchHydratedContainerTextInstance = shim; export const didNotMatchHydratedTextInstance = shim; export const didNotHydrateContainerInstance = shim; From 92fb62a174637584f88e8dd1ec339892cbc66d45 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 28 Jan 2019 16:10:59 -0800 Subject: [PATCH 10/21] Delete server rendered content when props change before hydration completes --- .../ReactDOMServerPartialHydration-test.js | 135 ++++++++++++++++++ .../src/ReactFiberBeginWork.js | 47 +++++- 2 files changed, 176 insertions(+), 6 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js index 81ce222da472b..faaec19f0d294 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js @@ -190,4 +190,139 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.firstChild.children[1].textContent).toBe('After'); }); + + it('regenerates the content if props have changed before hydration completes', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return text; + } + } + + function App({text, className}) { + return ( +
+ + + + + +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString( + , + ); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + let span = container.getElementsByTagName('span')[0]; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + jest.runAllTimers(); + + expect(ref.current).toBe(null); + expect(span.textContent).toBe('Hello'); + + // Render an update, which will be higher or the same priority as pinging the hydration. + root.render(); + + // At the same time, resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + // Flushing both of these in the same batch won't be able to hydrate so we'll + // probably throw away the existing subtree. + jest.runAllTimers(); + + // Pick up the new span. In an ideal implementation this might be the same span + // but patched up. At the time of writing, this will be a new span though. + span = container.getElementsByTagName('span')[0]; + + // We should now have fully rendered with a ref on the new span. + expect(ref.current).toBe(span); + expect(span.textContent).toBe('Hi'); + // If we ended up hydrating the existing content, we won't have properly + // patched up the tree, which might mean we haven't patched the className. + expect(span.className).toBe('hi'); + }); + + it('shows the fallback if props have changed before hydration completes and is still suspended', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + let ref = React.createRef(); + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return text; + } + } + + function App({text, className}) { + return ( +
+ + + + + +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString( + , + ); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + jest.runAllTimers(); + + expect(ref.current).toBe(null); + + // Render an update, but leave it still suspended. + root.render(); + + // Flushing now should delete the existing content and show the fallback. + jest.runAllTimers(); + + expect(container.getElementsByTagName('span').length).toBe(0); + expect(ref.current).toBe(null); + expect(container.textContent).toBe('Loading...'); + + // Unsuspending shows the content. + suspend = false; + resolve(); + await promise; + + jest.runAllTimers(); + + let span = container.getElementsByTagName('span')[0]; + expect(span.textContent).toBe('Hi'); + expect(span.className).toBe('hi'); + expect(ref.current).toBe(span); + expect(container.textContent).toBe('Hi'); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 48b8e632c4a0b..1637bf22911c9 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -44,6 +44,7 @@ import { DidCapture, Update, Ref, + Deletion, } from 'shared/ReactSideEffectTags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -1622,14 +1623,48 @@ function updateDehydratedSuspenseComponent( workInProgress.expirationTime = workInProgress.childExpirationTime = Never; return null; } + const prevProps = current.memoizedProps; + const nextProps = workInProgress.pendingProps; + if (prevProps !== nextProps) { + // This boundary has changed since the first render. This means that we are now unable to + // hydrate it. We might still be able to hydrate it using an earlier expiration time but + // during this render we can't. Instead, we're going to delete the whole subtree and + // instead inject a new real Suspense boundary to take its place, which may render content + // or fallback. The real Suspense boundary will suspend for a while so we have some time + // to ensure it can produce real content, but all state and pending events will be lost. + + // Detach from the current dehydrated boundary. + current.alternate = null; + workInProgress.alternate = null; + + // Insert a deletion in the effect list. + let returnFiber = workInProgress.return; + invariant( + returnFiber !== null, + 'Suspense boundaries are never on the root. ' + + 'This is probably a bug in React.', + ); + const last = returnFiber.lastEffect; + if (last !== null) { + last.nextEffect = current; + returnFiber.lastEffect = current; + } else { + returnFiber.firstEffect = returnFiber.lastEffect = current; + } + current.nextEffect = null; + current.effectTag = Deletion; + + // Upgrade this work in progress to a real Suspense component. + workInProgress.tag = SuspenseComponent; + workInProgress.stateNode = null; + workInProgress.memoizedState = null; + // This is now an insertion. + workInProgress.effectTag |= Placement; + // Retry as a real Suspense component. + return updateSuspenseComponent(null, workInProgress, renderExpirationTime); + } if ((workInProgress.effectTag & DidCapture) === NoEffect) { // This is the first attempt. - const prevProps = current.memoizedProps; - const nextProps = workInProgress.pendingProps; - if (prevProps !== nextProps) { - // TODO: Delete children and upgrade to a regular suspense component without - // hydrating. - } reenterHydrationStateFromDehydratedSuspenseInstance(workInProgress); const nextChildren = nextProps.children; workInProgress.child = mountChildFibers( From 6ae914cc2421db04da4758f92c64e0aa921a3340 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 28 Jan 2019 18:37:13 -0800 Subject: [PATCH 11/21] Make test internal --- ...on-test.js => ReactDOMServerPartialHydration-test.internal.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/react-dom/src/__tests__/{ReactDOMServerPartialHydration-test.js => ReactDOMServerPartialHydration-test.internal.js} (100%) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js similarity index 100% rename from packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.js rename to packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js From e144a5e02cabe8e1d9c006ccd8e30075154fca87 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 9 Feb 2019 17:06:56 -0800 Subject: [PATCH 12/21] Wrap in act --- ...eactDOMServerPartialHydration-test.internal.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index faaec19f0d294..2d85c31c2583d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -14,6 +14,7 @@ let ReactDOM; let ReactDOMServer; let ReactFeatureFlags; let Suspense; +let act; describe('ReactDOMServerPartialHydration', () => { beforeEach(() => { @@ -24,6 +25,7 @@ describe('ReactDOMServerPartialHydration', () => { React = require('react'); ReactDOM = require('react-dom'); + act = require('react-dom/test-utils').act; ReactDOMServer = require('react-dom/server'); Suspense = React.Suspense; }); @@ -127,12 +129,15 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; - ReactDOM.hydrate(, container); + + act(() => { + ReactDOM.hydrate(, container); + }); expect(container.firstChild.firstChild.tagName).not.toBe('DIV'); // In this state, we can still update the siblings. - showSibling(); + act(() => showSibling()); expect(container.firstChild.firstChild.tagName).toBe('DIV'); expect(container.firstChild.firstChild.textContent).toBe('First'); @@ -181,12 +186,14 @@ describe('ReactDOMServerPartialHydration', () => { // On the client we don't have all data yet but we want to start // hydrating anyway. suspend = true; - ReactDOM.hydrate(, container); + act(() => { + ReactDOM.hydrate(, container); + }); expect(container.firstChild.children[1].textContent).toBe('Middle'); // In this state, we can still delete the boundary. - hideMiddle(); + act(() => hideMiddle()); expect(container.firstChild.children[1].textContent).toBe('After'); }); From eb3ea2d28710977fdaa59b11c4149d7a70276666 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 8 Feb 2019 16:27:22 -0800 Subject: [PATCH 13/21] Change SSR Fixture to use Partial Hydration This requires the enableSuspenseServerRenderer flag to be manually enabled for the build to work. --- fixtures/ssr/src/components/App.js | 39 ++++++++++++++++++-------- fixtures/ssr/src/components/Chrome.css | 24 ++++++++++++++++ fixtures/ssr/src/components/Chrome.js | 12 ++++++-- fixtures/ssr/src/components/Page.css | 15 +++++++++- fixtures/ssr/src/components/Page.js | 20 +++++++++---- fixtures/ssr/src/components/Page2.js | 15 ++++++++++ fixtures/ssr/src/components/Suspend.js | 21 ++++++++++++++ fixtures/ssr/src/components/Theme.js | 25 +++++++++++++++++ fixtures/ssr/src/index.js | 5 ++-- 9 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 fixtures/ssr/src/components/Page2.js create mode 100644 fixtures/ssr/src/components/Suspend.js create mode 100644 fixtures/ssr/src/components/Theme.js diff --git a/fixtures/ssr/src/components/App.js b/fixtures/ssr/src/components/App.js index da63dd4fd9c36..ebfafc9d15bb6 100644 --- a/fixtures/ssr/src/components/App.js +++ b/fixtures/ssr/src/components/App.js @@ -1,17 +1,32 @@ -import React, {Component} from 'react'; +import React, {useContext, useState, Suspense} from 'react'; import Chrome from './Chrome'; import Page from './Page'; +import Page2 from './Page2'; +import Theme from './Theme'; -export default class App extends Component { - render() { - return ( - -
-

Hello World

- -
-
- ); - } +function LoadingIndicator() { + let theme = useContext(Theme); + return
Loading...
; +} + +export default function App({assets}) { + let [CurrentPage, switchPage] = useState(() => Page); + return ( + + + + ); } diff --git a/fixtures/ssr/src/components/Chrome.css b/fixtures/ssr/src/components/Chrome.css index b019b57b1db81..96932a6938a9a 100644 --- a/fixtures/ssr/src/components/Chrome.css +++ b/fixtures/ssr/src/components/Chrome.css @@ -3,3 +3,27 @@ body { padding: 0; font-family: sans-serif; } + +body.light { + background-color: #FFFFFF; + color: #333333; +} + +body.dark { + background-color: #000000; + color: #CCCCCC; +} + +.light-loading { + margin: 10px 0; + padding: 10px; + background-color: #CCCCCC; + color: #666666; +} + +.dark-loading { + margin: 10px 0; + padding: 10px; + background-color: #333333; + color: #999999; +} diff --git a/fixtures/ssr/src/components/Chrome.js b/fixtures/ssr/src/components/Chrome.js index 541d96901c5fb..b52c8d0ee74d4 100644 --- a/fixtures/ssr/src/components/Chrome.js +++ b/fixtures/ssr/src/components/Chrome.js @@ -1,8 +1,11 @@ import React, {Component} from 'react'; +import Theme, {ThemeToggleButton} from './Theme'; + import './Chrome.css'; export default class Chrome extends Component { + state = {theme: 'light'}; render() { const assets = this.props.assets; return ( @@ -14,13 +17,18 @@ export default class Chrome extends Component { {this.props.title} - +