diff --git a/compat/src/hooks.js b/compat/src/hooks.js index d0aeeb7d7c..4a83e4a0b0 100644 --- a/compat/src/hooks.js +++ b/compat/src/hooks.js @@ -1,12 +1,36 @@ import { useState, useLayoutEffect, useEffect } from 'preact/hooks'; +import { options as _options } from 'preact'; + +const MODE_HYDRATE = 1 << 5; + +/** @type {boolean} */ +let hydrating; +// Cast to use internal Options type +const options = /** @type {import('../../src/internal').Options} */ (_options); +let oldBeforeRender = options._render; + +/** @type {(vnode: import('./internal').VNode) => void} */ +options._render = _vnode => { + hydrating = !!(_vnode._flags & MODE_HYDRATE); + if (oldBeforeRender) oldBeforeRender(_vnode); +}; /** * This is taken from https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L84 * on a high level this cuts out the warnings, ... and attempts a smaller implementation * @typedef {{ _value: any; _getSnapshot: () => any }} Store */ -export function useSyncExternalStore(subscribe, getSnapshot) { - const value = getSnapshot(); +export function useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot +) { + const value = + typeof window === 'undefined' || hydrating + ? getServerSnapshot + ? getServerSnapshot() + : missingGetServerSnapshot() + : getSnapshot(); /** * @typedef {{ _instance: Store }} StoreRef @@ -52,6 +76,12 @@ function didSnapshotChange(inst) { } } +function missingGetServerSnapshot() { + throw new Error( + 'Missing "getServerSnapshot" parameter for "useSyncExternalStore", this is required for server rendering & hydration of server-rendered content' + ); +} + export function startTransition(cb) { cb(); } diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index eb3fa7bbdb..d0caf40eb3 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -130,7 +130,8 @@ declare namespace React { export function useDeferredValue(val: T): T; export function useSyncExternalStore( subscribe: (flush: () => void) => () => void, - getSnapshot: () => T + getSnapshot: () => T, + getServerSnapshot?: () => T ): T; // Preact Defaults diff --git a/compat/test/browser/useSyncExternalStore.test.jsx b/compat/test/browser/useSyncExternalStore.test.jsx index 18839f36a0..2ff80efd47 100644 --- a/compat/test/browser/useSyncExternalStore.test.jsx +++ b/compat/test/browser/useSyncExternalStore.test.jsx @@ -3,6 +3,7 @@ import React, { Fragment, useSyncExternalStore, render, + hydrate, useState, useCallback, useEffect, @@ -781,6 +782,46 @@ describe('useSyncExternalStore', () => { expect(container.textContent).to.equal('NaN'); }); + it('basic server hydration', async () => { + const store = createExternalStore('client'); + + const ref = React.createRef(); + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server' + ); + useEffect(() => { + Scheduler.log('Passive effect: ' + text); + }, [text]); + return ( +
+ +
+ ); + } + + const container = document.createElement('div'); + container.innerHTML = '
server
'; + const serverRenderedDiv = container.getElementsByTagName('div')[0]; + + await act(() => { + hydrate(, container); + }); + assertLog([ + // 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' + ]); + + expect(container.textContent).toEqual('client'); + expect(ref.current).toEqual(serverRenderedDiv); + }); + it('regression test for facebook/react#23150', async () => { const store = createExternalStore('Initial');