Skip to content
Open
51 changes: 1 addition & 50 deletions compat/src/hooks.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,4 @@
import { useState, useLayoutEffect, useEffect, useRef } from 'preact/hooks';

/**
* 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();

/**
* @typedef {{ _instance: Store }} StoreRef
* @type {[StoreRef, (store: StoreRef) => void]}
*/
const [{ _instance }, forceUpdate] = useState({
_instance: { _value: value, _getSnapshot: getSnapshot }
});

useLayoutEffect(() => {
_instance._value = value;
_instance._getSnapshot = getSnapshot;

if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}
}, [subscribe, value, getSnapshot]);

useEffect(() => {
if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}

return subscribe(() => {
if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}
});
}, [subscribe]);

return value;
}

/** @type {(inst: Store) => boolean} */
function didSnapshotChange(inst) {
try {
return !Object.is(inst._value, inst._getSnapshot());
} catch (error) {
return true;
}
}
import { useLayoutEffect, useRef } from 'preact/hooks';

export function startTransition(cb) {
cb();
Expand Down
3 changes: 2 additions & 1 deletion compat/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ declare namespace React {
export function useDeferredValue<T = any>(val: T): T;
export function useSyncExternalStore<T>(
subscribe: (flush: () => void) => () => void,
getSnapshot: () => T
getSnapshot: () => T,
getServerSnapshot?: () => T
): T;
export function useEffectEvent<T extends Function>(cb: T): T;

Expand Down
4 changes: 2 additions & 2 deletions compat/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
useDeferredValue,
useEffectEvent,
useInsertionEffect,
useSyncExternalStore,
useTransition
} from './hooks';
import { memo } from './memo';
Expand All @@ -38,7 +37,8 @@ import {
REACT_ELEMENT_TYPE,
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
hydrate,
render
render,
useSyncExternalStore
} from './render';
import { Suspense, lazy } from './suspense';

Expand Down
69 changes: 62 additions & 7 deletions compat/src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,74 @@ import {
useRef,
useState
} from 'preact/hooks';
import {
useDeferredValue,
useInsertionEffect,
useSyncExternalStore,
useTransition
} from './index';
import { useDeferredValue, useInsertionEffect, useTransition } from './index';
import { assign, IS_NON_DIMENSIONAL } from './util';

export const REACT_ELEMENT_TYPE = Symbol.for('react.element');

const MODE_HYDRATE = 1 << 5;

const CAMEL_PROPS =
/^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image(!S)|letter|lighting|marker(?!H|W|U)|overline|paint|pointer|shape|stop|strikethrough|stroke|text(?!L)|transform|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/;
const CAMEL_REPLACE = /[A-Z0-9]/g;
const IS_DOM = typeof document !== 'undefined';

/**
* 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,
getServerSnapshot
) {
const serverRendering = options._skipEffects || hydrationRoot;
const value = serverRendering
? (getServerSnapshot || getSnapshot)()
: getSnapshot();

/**
* @typedef {{ _instance: Store }} StoreRef
* @type {[StoreRef, (store: StoreRef) => void]}
*/
const [{ _instance }, forceUpdate] = useState({
_instance: { _value: value, _getSnapshot: getSnapshot }
});

useLayoutEffect(() => {
_instance._value = value;
_instance._getSnapshot = getSnapshot;

if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}
}, [subscribe, value, getSnapshot]);

useEffect(() => {
if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}

return subscribe(() => {
if (didSnapshotChange(_instance)) {
forceUpdate({ _instance });
}
});
}, [subscribe]);

return value;
}

/** @type {(inst: Store) => boolean} */
function didSnapshotChange(inst) {
try {
return !Object.is(inst._value, inst._getSnapshot());
} catch (error) {
return true;
}
}

// Input types for which onchange should not be converted to oninput.
const onChangeInputType = type => /fil|che|rad/.test(type);

Expand Down Expand Up @@ -258,12 +311,13 @@ options.vnode = vnode => {
};

// Only needed for react-relay
let currentComponent;
let currentComponent, hydrationRoot;
const oldBeforeRender = options._render;
options._render = function (vnode) {
if (oldBeforeRender) {
oldBeforeRender(vnode);
}
if (vnode._flags & MODE_HYDRATE) hydrationRoot = vnode;
currentComponent = vnode._component;
};

Expand All @@ -287,6 +341,7 @@ options.diffed = function (vnode) {
}

currentComponent = null;
if (hydrationRoot == vnode) hydrationRoot = null;
};

// This is a very very private internal function for React it
Expand Down
43 changes: 42 additions & 1 deletion compat/test/browser/suspense-hydration.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import React, {
Fragment,
Suspense,
memo,
useState
useState,
useSyncExternalStore
} from 'preact/compat';
import { logCall, getLog, clearLog } from '../../../test/_util/logCall';
import {
Expand Down Expand Up @@ -98,6 +99,46 @@ describe('suspense hydration', () => {
});
});

it('should use the server snapshot when suspended hydration resumes', async () => {
scratch.innerHTML = '<div>server</div>';
clearLog();

const snapshots = [];
const [Lazy, resolve] = createLazy();

function StoreValue() {
const value = useSyncExternalStore(
() => () => {},
() => {
snapshots.push('client');
return 'client';
},
() => {
snapshots.push('server');
return 'server';
}
);
return <div>{value}</div>;
}

hydrate(
<Suspense>
<Lazy />
</Suspense>,
scratch
);
rerender();
expect(snapshots).to.deep.equal([]);

await resolve(() => <StoreValue />);
rerender();

const [first, ...rest] = snapshots;
expect(first).to.equal('server');
expect(rest.every(snap => snap === 'client')).to.equal(true);
expect(scratch.innerHTML).to.equal('<div>client</div>');
});

it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => {
const originalHtml = '<div>Hello</div>';
scratch.innerHTML = originalHtml;
Expand Down
74 changes: 74 additions & 0 deletions compat/test/browser/useSyncExternalStore.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import React, {
Fragment,
useSyncExternalStore,
render,
hydrate,
useState,
useCallback,
useEffect,
useLayoutEffect
} from 'preact/compat';
import ReactDOMServer from 'preact/compat/server';
import { setupRerender, act } from 'preact/test-utils';
import { setupScratch, teardown } from '../../../test/_util/helpers';
import { vi } from 'vitest';
Expand Down Expand Up @@ -307,6 +309,34 @@ describe('useSyncExternalStore', () => {
// The following tests are taken from the React test suite:
// https://github.com/facebook/react/blob/3e09c27b880e1fecdb1eca5db510ecce37ea6be2/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js
describe('React useSyncExternalStore test suite', () => {
it('uses the server snapshot during server rendering', () => {
const getSnapshot = vi.fn(() => 'client');
const getServerSnapshot = vi.fn(() => 'server');

function App() {
return useSyncExternalStore(
() => () => {},
getSnapshot,
getServerSnapshot
);
}

expect(ReactDOMServer.renderToString(<App />)).to.equal('server');
expect(getSnapshot).not.toHaveBeenCalled();
expect(getServerSnapshot).toHaveBeenCalledOnce();
});

it('falls back to the snapshot during server rendering', () => {
const getSnapshot = vi.fn(() => 'client');

function App() {
return useSyncExternalStore(() => () => {}, getSnapshot);
}

expect(ReactDOMServer.renderToString(<App />)).to.equal('client');
expect(getSnapshot).toHaveBeenCalledOnce();
});

it('basic usage', async () => {
const store = createExternalStore('Initial');

Expand Down Expand Up @@ -777,6 +807,50 @@ describe('useSyncExternalStore', () => {
expect(container.textContent).to.equal('NaN');
});

it('basic server hydration', async () => {
Comment thread
rschristian marked this conversation as resolved.
const store = createExternalStore('client');

const ref = React.createRef();
function Child() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server'
);
useEffect(() => {
Scheduler.log('Passive effect: ' + text);
}, [text]);
return (
<div ref={ref}>
<Text text={text} />
</div>
);
}

function App() {
return <Child />;
}

const container = document.createElement('div');
container.innerHTML = '<div>server</div>';
const serverRenderedDiv = container.getElementsByTagName('div')[0];

await act(() => {
hydrate(<App />, 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');

Expand Down
1 change: 1 addition & 0 deletions src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function diff(
(isHydrating = oldVNode._flags & MODE_HYDRATE) &&
oldVNode._component._excess
) {
newVNode._flags |= MODE_HYDRATE;
let excess = oldVNode._component._excess;
excessDomChildren = [];
if (excess.nodeType == 8) {
Expand Down
Loading