From 79b8fc66704a9a24588bf41b25afbcd818358b80 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 20 Sep 2021 11:32:13 -0400 Subject: [PATCH] Implement getServerSnapshot in userspace shim (#22359) * Convert useSES shim tests to use React DOM Idea is that eventually we'll run these tests against an actual build of React DOM 17 to test backwards compatibility. * Implement getServerSnapshot in userspace shim If the DOM is not present, we assume that we are running in a server environment and return the result of `getServerSnapshot`. This heuristic doesn't work in React Native, so we'll need to provide a separate native build (using the `.native` extension). I've left this for a follow-up. We can't call `getServerSnapshot` on the client, because in versions of React before 18, there's no built-in mechanism to detect whether we're hydrating. To avoid a server mismatch warning, users must account for this themselves and return the correct value inside `getSnapshot`. Note that none of this is relevant to the built-in API that is being added in 18. This only affects the userspace shim that is provided for backwards compatibility with versions 16 and 17. --- .../useSyncExternalStoreShared-test.js | 203 ++++++++++++------ .../useSyncExternalStoreShimServer-test.js | 98 +++++++++ .../src/useSyncExternalStore.js | 35 ++- scripts/error-codes/codes.json | 3 +- 4 files changed, 264 insertions(+), 75 deletions(-) create mode 100644 packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js index 93dd24c838231..6536f65b7f2a2 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -12,7 +12,7 @@ let useSyncExternalStore; let useSyncExternalStoreExtra; let React; -let ReactNoop; +let ReactDOM; let Scheduler; let act; let useState; @@ -27,11 +27,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { // Remove the built-in API from the React exports to force the package to // use the shim. - // TODO: Don't do this during a variant test run. That way these tests run - // against both the shim and the built-in implementation. - if (gate(flags => flags.variant)) { - // We'll use the variant flag to represent the native implementation - } else { + if (!gate(flags => flags.supportsNativeUseSyncExternalStore)) { // and the non-variant tests for the shim. // // Remove useSyncExternalStore from the React imports so that we use the @@ -55,7 +51,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { } React = require('react'); - ReactNoop = require('react-noop-renderer'); + ReactDOM = require('react-dom'); Scheduler = require('scheduler'); useState = React.useState; useEffect = React.useEffect; @@ -66,7 +62,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { // The internal act implementation doesn't batch updates by default, since // it's mostly used to test concurrent mode. But since these tests run // in both concurrent and legacy mode, I'm adding batching here. - act = cb => internalAct(() => ReactNoop.batchedUpdates(cb)); + act = cb => internalAct(() => ReactDOM.unstable_batchedUpdates(cb)); useSyncExternalStore = require('use-sync-external-store') .useSyncExternalStore; @@ -79,19 +75,24 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return text; } - function createRoot(element) { + function createRoot(container) { // This wrapper function exists so we can test both legacy roots and // concurrent roots. - if (gate(flags => flags.variant)) { + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) { // The native implementation only exists in 18+, so we test using // concurrent mode. To test the legacy root behavior in the native // implementation (which is supported in the sense that it needs to have // the correct behavior, despite the fact that the legacy root API // triggers a warning in 18), write a test that uses // createLegacyRoot directly. - return ReactNoop.createRoot(); + return ReactDOM.createRoot(container); } else { - return ReactNoop.createLegacyRoot(); + ReactDOM.render(null, container); + return { + render(children) { + ReactDOM.render(children, container); + }, + }; } } @@ -101,7 +102,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return { set(text) { currentState = text; - ReactNoop.batchedUpdates(() => { + ReactDOM.unstable_batchedUpdates(() => { listeners.forEach(listener => listener()); }); }, @@ -126,17 +127,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); await act(() => root.render()); expect(Scheduler).toHaveYielded(['Initial']); - expect(root).toMatchRenderedOutput('Initial'); + expect(container.textContent).toEqual('Initial'); await act(() => { store.set('Updated'); }); expect(Scheduler).toHaveYielded(['Updated']); - expect(root).toMatchRenderedOutput('Updated'); + expect(container.textContent).toEqual('Updated'); }); test('skips re-rendering if nothing changes', () => { @@ -147,11 +149,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['Initial']); - expect(root).toMatchRenderedOutput('Initial'); + expect(container.textContent).toEqual('Initial'); // Update to the same value act(() => { @@ -159,7 +162,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Should not re-render expect(Scheduler).toHaveYielded([]); - expect(root).toMatchRenderedOutput('Initial'); + expect(container.textContent).toEqual('Initial'); }); test('switch to a different store', async () => { @@ -174,21 +177,22 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); await act(() => root.render()); expect(Scheduler).toHaveYielded([0]); - expect(root).toMatchRenderedOutput('0'); + expect(container.textContent).toEqual('0'); await act(() => { storeA.set(1); }); expect(Scheduler).toHaveYielded([1]); - expect(root).toMatchRenderedOutput('1'); + expect(container.textContent).toEqual('1'); // Switch stores and update in the same batch act(() => { - ReactNoop.flushSync(() => { + ReactDOM.flushSync(() => { // This update will be disregarded storeA.set(2); setStore(storeB); @@ -196,7 +200,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Now reading from B instead of A expect(Scheduler).toHaveYielded([0]); - expect(root).toMatchRenderedOutput('0'); + expect(container.textContent).toEqual('0'); // Update A await act(() => { @@ -204,14 +208,14 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Nothing happened, because we're no longer subscribed to A expect(Scheduler).toHaveYielded([]); - expect(root).toMatchRenderedOutput('0'); + expect(container.textContent).toEqual('0'); // Update B await act(() => { storeB.set(1); }); expect(Scheduler).toHaveYielded([1]); - expect(root).toMatchRenderedOutput('1'); + expect(container.textContent).toEqual('1'); }); test('selecting a specific value inside getSnapshot', async () => { @@ -235,11 +239,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['A0', 'B0']); - expect(root).toMatchRenderedOutput('A0B0'); + expect(container.textContent).toEqual('A0B0'); // Update b but not a await act(() => { @@ -247,7 +252,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Only b re-renders expect(Scheduler).toHaveYielded(['B1']); - expect(root).toMatchRenderedOutput('A0B1'); + expect(container.textContent).toEqual('A0B1'); // Update a but not b await act(() => { @@ -255,12 +260,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Only a re-renders expect(Scheduler).toHaveYielded(['A1']); - expect(root).toMatchRenderedOutput('A1B1'); + expect(container.textContent).toEqual('A1B1'); }); // In React 18, you can't observe in between a sync render and its // passive effects, so this is only relevant to legacy roots - // @gate !variant + // @gate !supportsNativeUseSyncExternalStore test( "compares to current state before bailing out, even when there's a " + 'mutation in between the sync and passive effects', @@ -275,7 +280,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']); @@ -287,7 +293,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { 1, // Passive effect hasn't fired yet ]); - expect(root).toMatchRenderedOutput('1'); + expect(container.textContent).toEqual('1'); // Flip the store state back to the previous value. store.set(0); @@ -300,7 +306,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { 0, ]); // Should flip back to 0 - expect(root).toMatchRenderedOutput('0'); + expect(container.textContent).toEqual('0'); }, ); @@ -344,10 +350,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['A1']); - expect(root).toMatchRenderedOutput('A1'); + expect(container.textContent).toEqual('A1'); act(() => { // Change getSnapshot and update the store in the same batch @@ -360,7 +367,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { // incorrectly bailed out here instead of re-rendering. 'B2', ]); - expect(root).toMatchRenderedOutput('B2'); + expect(container.textContent).toEqual('B2'); }); test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => { @@ -401,10 +408,11 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['A1']); - expect(root).toMatchRenderedOutput('A1'); + expect(container.textContent).toEqual('A1'); // This will cause a layout effect, and in the layout effect we'll update // the store @@ -418,7 +426,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { 'Update B in commit phase', // No re-render ]); - expect(root).toMatchRenderedOutput('A1'); + expect(container.textContent).toEqual('A1'); }); test("does not bail out if the previous update hasn't finished yet", async () => { @@ -440,7 +448,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render( <> @@ -450,13 +459,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ), ); expect(Scheduler).toHaveYielded([0, 0]); - expect(root).toMatchRenderedOutput('00'); + expect(container.textContent).toEqual('00'); await act(() => { store.set(1); }); expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]); - expect(root).toMatchRenderedOutput('00'); + expect(container.textContent).toEqual('00'); }); test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => { @@ -473,20 +482,21 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded([0]); // Update the store and getSnapshot at the same time act(() => { - ReactNoop.flushSync(() => { + ReactDOM.flushSync(() => { setGetSnapshot(() => getSnapshotB); store.set({a: 1, b: 2}); }); }); // It should read from B instead of A expect(Scheduler).toHaveYielded([2]); - expect(root).toMatchRenderedOutput('2'); + expect(container.textContent).toEqual('2'); }); test('handles errors thrown by getSnapshot', async () => { @@ -521,7 +531,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { } const errorBoundary = React.createRef(null); - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render( @@ -530,13 +541,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ), ); expect(Scheduler).toHaveYielded([0]); - expect(root).toMatchRenderedOutput('0'); + expect(container.textContent).toEqual('0'); // Update that throws in a getSnapshot. We can catch it with an error boundary. await act(() => { store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false}); }); - if (gate(flags => flags.variant)) { + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) { expect(Scheduler).toHaveYielded([ 'Error in getSnapshot', // In a concurrent root, React renders a second time to attempt to @@ -546,7 +557,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { } else { expect(Scheduler).toHaveYielded(['Error in getSnapshot']); } - expect(root).toMatchRenderedOutput('Error in getSnapshot'); + expect(container.textContent).toEqual('Error in getSnapshot'); }); test('Infinite loop if getSnapshot keeps returning new reference', () => { @@ -557,19 +568,18 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - spyOnDev(console, 'error'); - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); - expect(() => act(() => root.render())).toThrow( - 'Maximum update depth exceeded. This can happen when a component repeatedly ' + - 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' + - 'the number of nested updates to prevent infinite loops.', - ); - if (__DEV__) { - expect(console.error.calls.argsFor(0)[0]).toMatch( - 'The result of getSnapshot should be cached to avoid an infinite loop', + expect(() => { + expect(() => act(() => root.render())).toThrow( + 'Maximum update depth exceeded. This can happen when a component repeatedly ' + + 'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' + + 'the number of nested updates to prevent infinite loops.', ); - } + }).toErrorDev( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); }); describe('extra features implemented in user-space', () => { @@ -594,11 +604,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']); - expect(root).toMatchRenderedOutput('A0'); + expect(container.textContent).toEqual('A0'); // Update the store await act(() => { @@ -612,7 +623,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { // the previous result without running the selector again 'A1', ]); - expect(root).toMatchRenderedOutput('A1'); + expect(container.textContent).toEqual('A1'); }); // The selector implementation uses the lazy ref initialization pattern @@ -652,11 +663,12 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); act(() => root.render()); expect(Scheduler).toHaveYielded(['A0', 'B0']); - expect(root).toMatchRenderedOutput('A0B0'); + expect(container.textContent).toEqual('A0B0'); // Update b but not a await act(() => { @@ -664,7 +676,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Only b re-renders expect(Scheduler).toHaveYielded(['B1']); - expect(root).toMatchRenderedOutput('A0B1'); + expect(container.textContent).toEqual('A0B1'); // Update a but not b await act(() => { @@ -672,7 +684,59 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }); // Only a re-renders expect(Scheduler).toHaveYielded(['A1']); - expect(root).toMatchRenderedOutput('A1B1'); + expect(container.textContent).toEqual('A1B1'); + }); + + test('basic server hydration', async () => { + const store = createExternalStore('client'); + + const ref = React.createRef(); + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect: ' + text); + }, [text]); + return ( +
+ +
+ ); + } + + const container = document.createElement('div'); + container.innerHTML = '
server
'; + const serverRenderedDiv = container.getElementsByTagName('div')[0]; + + if (gate(flags => flags.supportsNativeUseSyncExternalStore)) { + act(() => { + ReactDOM.hydrateRoot(container, ); + }); + expect(Scheduler).toHaveYielded([ + // First it hydrates the server rendered HTML + 'server', + 'Passive effect: server', + // Then in a second paint, it re-renders with the client state + 'client', + 'Passive effect: client', + ]); + } else { + // In the userspace shim, there's no mechanism to detect whether we're + // currently hydrating, so `getServerSnapshot` is not called on the + // client. To avoid this server mismatch warning, user must account for + // this themselves and return the correct value inside `getSnapshot`. + act(() => { + expect(() => ReactDOM.hydrate(, container)).toErrorDev( + 'Text content did not match', + ); + }); + expect(Scheduler).toHaveYielded(['client', 'Passive effect: client']); + } + expect(container.textContent).toEqual('client'); + expect(ref.current).toEqual(serverRenderedDiv); }); }); @@ -725,7 +789,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const container = document.createElement('div'); + const root = createRoot(container); await act(() => { root.render(); }); diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js new file mode 100644 index 0000000000000..9fa5b9ce21ebf --- /dev/null +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShimServer-test.js @@ -0,0 +1,98 @@ +/** + * 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 + * + * @jest-environment node + */ + +'use strict'; + +let useSyncExternalStore; +let React; +let ReactDOM; +let ReactDOMServer; +let Scheduler; + +// This tests the userspace shim of `useSyncExternalStore` in a server-rendering +// (Node) environment +describe('useSyncExternalStore (userspace shim, server rendering)', () => { + beforeEach(() => { + jest.resetModules(); + + // Remove useSyncExternalStore from the React imports so that we use the + // shim instead. Also removing startTransition, since we use that to detect + // outdated 18 alphas that don't yet include useSyncExternalStore. + // + // Longer term, we'll probably test this branch using an actual build of + // React 17. + jest.mock('react', () => { + const { + // eslint-disable-next-line no-unused-vars + startTransition: _, + // eslint-disable-next-line no-unused-vars + useSyncExternalStore: __, + // eslint-disable-next-line no-unused-vars + unstable_useSyncExternalStore: ___, + ...otherExports + } = jest.requireActual('react'); + return otherExports; + }); + + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); + + useSyncExternalStore = require('use-sync-external-store') + .useSyncExternalStore; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function createExternalStore(initialState) { + const listeners = new Set(); + let currentState = initialState; + return { + set(text) { + currentState = text; + ReactDOM.unstable_batchedUpdates(() => { + listeners.forEach(listener => listener()); + }); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState() { + return currentState; + }, + getSubscriberCount() { + return listeners.size; + }, + }; + } + + test('basic server render', async () => { + const store = createExternalStore('client'); + + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + return ; + } + + const html = ReactDOMServer.renderToString(); + expect(Scheduler).toHaveYielded(['server']); + expect(html).toEqual('server'); + }); +}); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 6d3199d7f2d52..6b30854aea03a 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -9,6 +9,8 @@ import * as React from 'react'; import is from 'shared/objectIs'; +import invariant from 'shared/invariant'; +import {canUseDOM} from 'shared/ExecutionEnvironment'; // Intentionally not using named imports because Rollup uses dynamic // dispatch for CommonJS interop named imports. @@ -21,17 +23,38 @@ const { unstable_useSyncExternalStore: builtInAPI, } = React; +// TODO: This heuristic doesn't work in React Native. We'll need to provide a +// special build, using the `.native` extension. +const isServerEnvironment = !canUseDOM; + // Prefer the built-in API, if it exists. If it doesn't exist, then we assume // we're in version 16 or 17, so rendering is always synchronous. The shim // does not support concurrent rendering, only the built-in API. export const useSyncExternalStore = builtInAPI !== undefined - ? ((builtInAPI: any): typeof useSyncExternalStore_shim) - : useSyncExternalStore_shim; + ? ((builtInAPI: any): typeof useSyncExternalStore_client) + : isServerEnvironment + ? useSyncExternalStore_server + : useSyncExternalStore_client; let didWarnOld18Alpha = false; let didWarnUncachedGetSnapshot = false; +function useSyncExternalStore_server( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, +): T { + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for server-' + + 'rendered content.', + ); + } + return getServerSnapshot(); +} + // Disclaimer: This shim breaks many of the rules of React, and only works // because of a very particular set of implementation details and assumptions // -- change any one of them and it will break. The most important assumption @@ -42,10 +65,13 @@ let didWarnUncachedGetSnapshot = false; // // Do not assume that the clever hacks used by this hook also work in general. // The point of this shim is to replace the need for hacks by other libraries. -function useSyncExternalStore_shim( +function useSyncExternalStore_client( subscribe: (() => void) => () => void, getSnapshot: () => T, - // TODO: Add a canUseDOM check and use this one on the server + // Note: The client shim does not use getServerSnapshot, because pre-18 + // versions of React do not expose a way to check if we're hydrating. So + // users of the shim will need to track that themselves and return the + // correct value from `getSnapshot`. getServerSnapshot?: () => T, ): T { if (__DEV__) { @@ -97,7 +123,6 @@ function useSyncExternalStore_shim( // Track the latest getSnapshot function with a ref. This needs to be updated // in the layout phase so we can access it during the tearing check that // happens on subscribe. - // TODO: Circumvent SSR warning with canUseDOM check useLayoutEffect(() => { inst.value = value; inst.getSnapshot = getSnapshot; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index c3206daa0d339..1a6afe0233ed1 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -395,5 +395,6 @@ "404": "Invalid hook call. Hooks can only be called inside of the body of a function component.", "405": "hydrateRoot(...): Target container is not a DOM element.", "406": "act(...) is not supported in production builds of React.", - "407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering." + "407": "Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.", + "408": "Missing getServerSnapshot, which is required for server-rendered content." }