From 4d8a8a5f5f6e68dfc1d7055db528911b7650ae0f Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Wed, 19 Jun 2024 09:34:23 +0100 Subject: [PATCH] fix: SSR hydration when removing devtools manager --- .changeset/hip-shirts-tickle.md | 5 ++ .../react/src/components/DataProvider.tsx | 6 +- .../react/src/components/renderDevButton.tsx | 9 +- .../react/src/server/createPersistedStore.tsx | 6 +- .../createPersistedStoreServer.tsx | 13 ++- .../react/src/server/redux/DataProvider.tsx | 17 +++- .../src/server/redux/ExternalDataProvider.tsx | 10 ++- packages/react/src/server/redux/redux.d.ts | 34 +++---- packages/react/src/server/redux/redux.js | 56 ------------ .../editor-types/@data-client/core.d.ts | 12 +-- .../@data-client/react/redux.d.ts | 9 +- .../editor-types/@data-client/react/ssr.d.ts | 90 ++++--------------- 12 files changed, 96 insertions(+), 171 deletions(-) create mode 100644 .changeset/hip-shirts-tickle.md diff --git a/.changeset/hip-shirts-tickle.md b/.changeset/hip-shirts-tickle.md new file mode 100644 index 000000000000..b4e4a0745644 --- /dev/null +++ b/.changeset/hip-shirts-tickle.md @@ -0,0 +1,5 @@ +--- +'@data-client/react': patch +--- + +Fix SSR hydration when removing devtools manager diff --git a/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx index 7474979d1cd9..276225032011 100644 --- a/packages/react/src/components/DataProvider.tsx +++ b/packages/react/src/components/DataProvider.tsx @@ -71,6 +71,10 @@ See https://dataclient.io/docs/guides/ssr.`, managersRef.current, ); + // only include if they have devtools integrated + const hasDevManager = !!managersRef.current.find( + manager => manager instanceof DevToolsManager, + ); return ( {children} - {renderDevButton(devButton, managersRef.current)} + {renderDevButton(devButton, hasDevManager)} ); } diff --git a/packages/react/src/components/renderDevButton.tsx b/packages/react/src/components/renderDevButton.tsx index 73849136f3ea..264294581181 100644 --- a/packages/react/src/components/renderDevButton.tsx +++ b/packages/react/src/components/renderDevButton.tsx @@ -1,4 +1,3 @@ -import { DevToolsManager, type Manager } from '@data-client/core'; import { lazy } from 'react'; import type { DevToolsPosition } from './DevToolsButton.js'; @@ -7,14 +6,10 @@ import UniversalSuspense from './UniversalSuspense.js'; export function renderDevButton( devButton: DevToolsPosition | null | undefined, - managers: Manager[], + hasDevManager: boolean, ) { /* istanbul ignore else */ - if ( - process.env.NODE_ENV !== 'production' && - // only include if they have devtools integrated - managers.find(manager => manager instanceof DevToolsManager) - ) { + if (process.env.NODE_ENV !== 'production' && hasDevManager) { return ( { diff --git a/packages/react/src/server/createPersistedStore.tsx b/packages/react/src/server/createPersistedStore.tsx index 6c7d7d46f4aa..49364c32fbe6 100644 --- a/packages/react/src/server/createPersistedStore.tsx +++ b/packages/react/src/server/createPersistedStore.tsx @@ -13,7 +13,10 @@ import { useSyncExternalStore } from 'react'; import { ExternalDataProvider, PromiseifyMiddleware } from './redux/index.js'; import { createStore, applyMiddleware } from './redux/redux.js'; -export default function createPersistedStore(managers?: Manager[]) { +export default function createPersistedStore( + managers?: Manager[], + hasDevManager: boolean = true, +) { const controller = new Controller(); managers = managers ?? [new NetworkManager()]; const nm: NetworkManager = managers.find( @@ -56,6 +59,7 @@ export default function createPersistedStore(managers?: Manager[]) { store={store} selector={selector} controller={controller} + hasDevManager={hasDevManager} > {children} diff --git a/packages/react/src/server/nextjs/DataProvider/createPersistedStoreServer.tsx b/packages/react/src/server/nextjs/DataProvider/createPersistedStoreServer.tsx index 00a90c492c3f..6407ec74db84 100644 --- a/packages/react/src/server/nextjs/DataProvider/createPersistedStoreServer.tsx +++ b/packages/react/src/server/nextjs/DataProvider/createPersistedStoreServer.tsx @@ -6,6 +6,7 @@ import { createReducer, initialState, applyManager, + DevToolsManager, } from '@data-client/core'; import type { ComponentProps } from 'react'; @@ -61,12 +62,22 @@ export default function createPersistedStore(managers?: Manager[]) { return getState(); })(); - const StoreDataProvider = ({ children }: ProviderProps) => { + const StoreDataProvider = ({ + children, + devButton, + managers, + }: ProviderProps) => { + // only include if they have devtools integrated + const hasDevManager = !!managers?.find( + manager => manager instanceof DevToolsManager, + ); return ( {children} diff --git a/packages/react/src/server/redux/DataProvider.tsx b/packages/react/src/server/redux/DataProvider.tsx index 76c8c11afedd..64e7a220f4fa 100644 --- a/packages/react/src/server/redux/DataProvider.tsx +++ b/packages/react/src/server/redux/DataProvider.tsx @@ -1,9 +1,15 @@ 'use client'; -import type { Controller, Manager, State } from '@data-client/core'; +import { + DevToolsManager, + type Controller, + type Manager, + type State, +} from '@data-client/core'; import React, { useEffect, useMemo } from 'react'; import ExternalCacheProvider from './ExternalDataProvider.js'; import { prepareStore } from './prepareStore.js'; +import { DevToolsPosition } from '../../components/DevToolsButton.js'; /** For usage with https://dataclient.io/docs/api/makeRenderDataClient */ export default function ExternalDataProvider({ @@ -11,6 +17,7 @@ export default function ExternalDataProvider({ managers, initialState, Controller, + devButton = 'bottom-right', }: Props) { const { selector, store, controller } = useMemo( () => prepareStore(initialState, managers, Controller), @@ -33,11 +40,18 @@ export default function ExternalDataProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [...managers]); + // only include if they have devtools integrated + const hasDevManager = !!managers.find( + manager => manager instanceof DevToolsManager, + ); + return ( {children} @@ -49,4 +63,5 @@ interface Props { managers: Manager[]; initialState: State; Controller: typeof Controller; + devButton?: DevToolsPosition | null | undefined; } diff --git a/packages/react/src/server/redux/ExternalDataProvider.tsx b/packages/react/src/server/redux/ExternalDataProvider.tsx index 21e2d2b6f38c..ba5ae9e91429 100644 --- a/packages/react/src/server/redux/ExternalDataProvider.tsx +++ b/packages/react/src/server/redux/ExternalDataProvider.tsx @@ -13,6 +13,8 @@ import React, { useCallback, } from 'react'; +import type { DevToolsPosition } from '../../components/DevToolsButton.js'; +import { renderDevButton } from '../../components/renderDevButton.js'; import { ControllerContext, StoreContext, @@ -32,6 +34,8 @@ interface Props { store: Store; selector: (state: S) => State; controller: Controller; + devButton?: DevToolsPosition | null | undefined; + hasDevManager?: boolean; } /** @@ -43,6 +47,8 @@ export default function ExternalDataProvider({ store, selector, controller, + devButton = 'bottom-right', + hasDevManager = false, }: Props) { const masterReducer = useMemo(() => createReducer(controller), [controller]); const selectState = useCallback(() => { @@ -80,9 +86,7 @@ export default function ExternalDataProvider({ }> {children} - {process.env.NODE_ENV !== 'production' ? - - : undefined} + {renderDevButton(devButton, hasDevManager)} diff --git a/packages/react/src/server/redux/redux.d.ts b/packages/react/src/server/redux/redux.d.ts index 8793f21f36d9..26b428bb342e 100644 --- a/packages/react/src/server/redux/redux.d.ts +++ b/packages/react/src/server/redux/redux.d.ts @@ -291,23 +291,23 @@ interface Store { * @returns A function to remove this change listener. */ subscribe(listener: ListenerCallback): Unsubscribe; - /** - * Replaces the reducer currently used by the store to calculate the state. - * - * You might need this if your app implements code splitting and you want to - * load some of the reducers dynamically. You might also need this if you - * implement a hot reloading mechanism for Redux. - * - * @param nextReducer The reducer for the store to use instead. - */ - replaceReducer(nextReducer: Reducer): void; - /** - * Interoperability point for observable/reactive libraries. - * @returns {observable} A minimal observable of state changes. - * For more information, see the observable proposal: - * https://github.com/tc39/proposal-observable - */ - [Symbol.observable](): Observable; + // /** + // * Replaces the reducer currently used by the store to calculate the state. + // * + // * You might need this if your app implements code splitting and you want to + // * load some of the reducers dynamically. You might also need this if you + // * implement a hot reloading mechanism for Redux. + // * + // * @param nextReducer The reducer for the store to use instead. + // */ + // replaceReducer(nextReducer: Reducer): void; + // /** + // * Interoperability point for observable/reactive libraries. + // * @returns {observable} A minimal observable of state changes. + // * For more information, see the observable proposal: + // * https://github.com/tc39/proposal-observable + // */ + // [Symbol.observable](): Observable; } type UnknownIfNonSpecific = {} extends T ? unknown : T; /** diff --git a/packages/react/src/server/redux/redux.js b/packages/react/src/server/redux/redux.js index 3c9d4c2b4f29..6adad4492cfe 100644 --- a/packages/react/src/server/redux/redux.js +++ b/packages/react/src/server/redux/redux.js @@ -3,11 +3,6 @@ export function formatProdErrorMessage(code) { return `Minified Redux error #${code}; visit https://redux.js.org/Errors?code=${code} for the full message or use the non-minified dev environment for full errors. `; } -// src/utils/symbol-observable.ts -var $$observable = /* @__PURE__ */ (() => - (typeof Symbol === 'function' && Symbol.observable) || '@@observable')(); -var symbol_observable_default = $$observable; - // src/utils/actionTypes.ts var randomString = () => Math.random().toString(36).substring(7).split('').join('.'); @@ -224,55 +219,6 @@ function createStore(reducer, preloadedState, enhancer) { }); return action; } - function replaceReducer(nextReducer) { - if (typeof nextReducer !== 'function') { - throw new Error( - process.env.NODE_ENV === 'production' ? - formatProdErrorMessage(10) - : `Expected the nextReducer to be a function. Instead, received: '${kindOf(nextReducer)}`, - ); - } - currentReducer = nextReducer; - dispatch({ - type: actionTypes_default.REPLACE, - }); - } - function observable() { - const outerSubscribe = subscribe; - return { - /** - * The minimal observable subscription method. - * @param observer Any object that can be used as an observer. - * The observer object should have a `next` method. - * @returns An object with an `unsubscribe` method that can - * be used to unsubscribe the observable from the store, and prevent further - * emission of values from the observable. - */ - subscribe(observer) { - if (typeof observer !== 'object' || observer === null) { - throw new Error( - process.env.NODE_ENV === 'production' ? - formatProdErrorMessage(11) - : `Expected the observer to be an object. Instead, received: '${kindOf(observer)}'`, - ); - } - function observeState() { - const observerAsObserver = observer; - if (observerAsObserver.next) { - observerAsObserver.next(getState()); - } - } - observeState(); - const unsubscribe = outerSubscribe(observeState); - return { - unsubscribe, - }; - }, - [symbol_observable_default]() { - return this; - }, - }; - } dispatch({ type: actionTypes_default.INIT, }); @@ -280,8 +226,6 @@ function createStore(reducer, preloadedState, enhancer) { dispatch, subscribe, getState, - replaceReducer, - [symbol_observable_default]: observable, }; return store; } diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index cb5e53970a4d..a3256732f956 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -485,9 +485,7 @@ declare class Controller { * Forces refetching and suspense on useSuspense with the same Endpoint and parameters. * @see https://dataclient.io/docs/api/Controller#invalidate */ - invalidate: >(endpoint: E, ...args: readonly [...Parameters] | readonly [ - null - ]) => Promise; + invalidate: >(endpoint: E, ...args: readonly [...Parameters] | readonly [null]) => Promise; /** * Forces refetching and suspense on useSuspense on all matching endpoint result keys. * @see https://dataclient.io/docs/api/Controller#invalidateAll @@ -549,16 +547,12 @@ declare class Controller { * Marks a new subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#subscribe */ - subscribe: >(endpoint: E, ...args: readonly [...Parameters] | readonly [ - null - ]) => Promise; + subscribe: >(endpoint: E, ...args: readonly [...Parameters] | readonly [null]) => Promise; /** * Marks completion of subscription to a given Endpoint. * @see https://dataclient.io/docs/api/Controller#unsubscribe */ - unsubscribe: >(endpoint: E, ...args: readonly [...Parameters] | readonly [ - null - ]) => Promise; + unsubscribe: >(endpoint: E, ...args: readonly [...Parameters] | readonly [null]) => Promise; /*************** More ***************/ /** * Gets a snapshot (https://dataclient.io/docs/api/Snapshot) diff --git a/website/src/components/Playground/editor-types/@data-client/react/redux.d.ts b/website/src/components/Playground/editor-types/@data-client/react/redux.d.ts index e4660f94dbfe..65e79275f173 100644 --- a/website/src/components/Playground/editor-types/@data-client/react/redux.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react/redux.d.ts @@ -149,13 +149,16 @@ type ReducersMapObject; } : never; +type DevToolsPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; + /** For usage with https://dataclient.io/docs/api/makeRenderDataClient */ -declare function ExternalDataProvider$1({ children, managers, initialState, Controller, }: Props$1): react_jsx_runtime.JSX.Element; +declare function ExternalDataProvider$1({ children, managers, initialState, Controller, devButton, }: Props$1): react_jsx_runtime.JSX.Element; interface Props$1 { children: React$1.ReactNode; managers: Manager[]; initialState: State; Controller: typeof Controller; + devButton?: DevToolsPosition | null | undefined; } interface Store { @@ -168,12 +171,14 @@ interface Props { store: Store; selector: (state: S) => State; controller: Controller; + devButton?: DevToolsPosition | null | undefined; + hasDevManager?: boolean; } /** * Like DataProvider, but for an external store * @see https://dataclient.io/docs/api/ExternalDataProvider */ -declare function ExternalDataProvider({ children, store, selector, controller, }: Props): react_jsx_runtime.JSX.Element; +declare function ExternalDataProvider({ children, store, selector, controller, devButton, hasDevManager, }: Props): react_jsx_runtime.JSX.Element; declare const mapMiddleware: >[]>(selector: (state: any) => State) => (...middlewares: Middleware[]) => M; //# sourceMappingURL=mapMiddleware.d.ts.map diff --git a/website/src/components/Playground/editor-types/@data-client/react/ssr.d.ts b/website/src/components/Playground/editor-types/@data-client/react/ssr.d.ts index 075f9cca169c..aa7f4db62b7d 100644 --- a/website/src/components/Playground/editor-types/@data-client/react/ssr.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react/ssr.d.ts @@ -31,36 +31,6 @@ interface UnknownAction extends Action { [extraProps: string]: unknown; } -/** - * A *reducer* is a function that accepts - * an accumulation and a value and returns a new accumulation. They are used - * to reduce a collection of values down to a single value - * - * Reducers are not unique to Redux—they are a fundamental concept in - * functional programming. Even most non-functional languages, like - * JavaScript, have a built-in API for reducing. In JavaScript, it's - * `Array.prototype.reduce()`. - * - * In Redux, the accumulated value is the state object, and the values being - * accumulated are actions. Reducers calculate a new state given the previous - * state and an action. They must be *pure functions*—functions that return - * the exact same output for given inputs. They should also be free of - * side-effects. This is what enables exciting features like hot reloading and - * time travel. - * - * Reducers are the most important concept in Redux. - * - * *Do not put API calls into reducers.* - * - * @template S The type of state consumed and produced by this reducer. - * @template A The type of actions the reducer can potentially respond to. - * @template PreloadedState The type of state consumed by this reducer the first time it's called. - */ -type Reducer = ( - state: S | PreloadedState | undefined, - action: A, -) => S; - /** * A *dispatching function* (or simply *dispatch function*) is a function that * accepts an action or an async action; it then may or may not dispatch one @@ -97,32 +67,6 @@ declare global { readonly observable: symbol; } } -/** - * A minimal observable of state changes. - * For more information, see the observable proposal: - * https://github.com/tc39/proposal-observable - */ -type Observable = { - /** - * The minimal observable subscription method. - * @param {Object} observer Any object that can be used as an observer. - * The observer object should have a `next` method. - * @returns {subscription} An object with an `unsubscribe` method that can - * be used to unsubscribe the observable from the store, and prevent further - * emission of values from the observable. - */ - subscribe: (observer: Observer) => { - unsubscribe: Unsubscribe; - }; - [Symbol.observable](): Observable; -}; -/** - * An Observer is used to receive data from an Observable, and is supplied as - * an argument to subscribe. - */ -type Observer = { - next?(value: T): void; -}; /** * A store is an object that holds the application's state tree. * There should only be a single store in a Redux app, as the composition @@ -191,23 +135,23 @@ interface Store { * @returns A function to remove this change listener. */ subscribe(listener: ListenerCallback): Unsubscribe; - /** - * Replaces the reducer currently used by the store to calculate the state. - * - * You might need this if your app implements code splitting and you want to - * load some of the reducers dynamically. You might also need this if you - * implement a hot reloading mechanism for Redux. - * - * @param nextReducer The reducer for the store to use instead. - */ - replaceReducer(nextReducer: Reducer): void; - /** - * Interoperability point for observable/reactive libraries. - * @returns {observable} A minimal observable of state changes. - * For more information, see the observable proposal: - * https://github.com/tc39/proposal-observable - */ - [Symbol.observable](): Observable; + // /** + // * Replaces the reducer currently used by the store to calculate the state. + // * + // * You might need this if your app implements code splitting and you want to + // * load some of the reducers dynamically. You might also need this if you + // * implement a hot reloading mechanism for Redux. + // * + // * @param nextReducer The reducer for the store to use instead. + // */ + // replaceReducer(nextReducer: Reducer): void; + // /** + // * Interoperability point for observable/reactive libraries. + // * @returns {observable} A minimal observable of state changes. + // * For more information, see the observable proposal: + // * https://github.com/tc39/proposal-observable + // */ + // [Symbol.observable](): Observable; } declare function createPersistedStore(managers?: Manager[]): readonly [({ children }: {