Skip to content

Commit

Permalink
Merge pull request #3568 from preactjs/redo-hooks
Browse files Browse the repository at this point in the history
React 18 hooks
  • Loading branch information
marvinhagemeister authored Jun 29, 2022
2 parents 8a1cbd4 + 557a8e4 commit 9e51ee1
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 0 deletions.
9 changes: 9 additions & 0 deletions compat/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ declare namespace React {
export import useReducer = _hooks.useReducer;
export import useRef = _hooks.useRef;
export import useState = _hooks.useState;
// React 18 hooks
export import useInsertionEffect = _hooks.useLayoutEffect;
export function useTransition(): [false, typeof startTransition];
export function useDeferredValue<T = any>(val: T): T;
export function useSyncExternalStore<T>(
subscribe: (flush: () => void) => () => void,
getSnapshot: () => T
): T;

// Preact Defaults
export import ContextType = preact.ContextType;
Expand All @@ -51,6 +59,7 @@ declare namespace React {
// Compat
export import StrictMode = preact.Fragment;
export const version: string;
export function startTransition(cb: () => void): void;

// HTML
export import HTMLAttributes = JSXInternal.HTMLAttributes;
Expand Down
36 changes: 36 additions & 0 deletions compat/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ const flushSync = (callback, arg) => callback(arg);
*/
const StrictMode = Fragment;

export function startTransition(cb) {
cb();
}

export function useDeferredValue(val) {
return val;
}

export function useTransition() {
return [false, startTransition];
}

// TODO: in theory this should be done after a VNode is diffed as we want to insert
// styles/... before it attaches
export const useInsertionEffect = useLayoutEffect;

export function useSyncExternalStore(subscribe, getSnapshot) {
const [state, setState] = useState(getSnapshot);

// TODO: in suspense for data we could have a discrepancy here because Preact won't re-init the "useState"
// when this unsuspends which could lead to stale state as the subscription is torn down.

useEffect(() => {
return subscribe(() => {
setState(getSnapshot());
});
}, [subscribe, getSnapshot]);

return state;
}

export * from 'preact/hooks';
export {
version,
Expand Down Expand Up @@ -153,6 +184,11 @@ export default {
useReducer,
useEffect,
useLayoutEffect,
useInsertionEffect,
useTransition,
useDeferredValue,
useSyncExternalStore,
startTransition,
useRef,
useImperativeHandle,
useMemo,
Expand Down
5 changes: 5 additions & 0 deletions compat/test/browser/exports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ describe('compat exports', () => {
expect(Compat.useMemo).to.be.a('function');
expect(Compat.useCallback).to.be.a('function');
expect(Compat.useContext).to.be.a('function');
expect(Compat.useSyncExternalStore).to.be.a('function');
expect(Compat.useInsertionEffect).to.be.a('function');
expect(Compat.useTransition).to.be.a('function');
expect(Compat.useDeferredValue).to.be.a('function');

// Suspense
expect(Compat.Suspense).to.be.a('function');
Expand All @@ -41,6 +45,7 @@ describe('compat exports', () => {
expect(Compat.unmountComponentAtNode).to.exist.and.be.a('function');
expect(Compat.unstable_batchedUpdates).to.exist.and.be.a('function');
expect(Compat.version).to.exist.and.be.a('string');
expect(Compat.startTransition).to.be.a('function');
});

it('should have named exports', () => {
Expand Down
131 changes: 131 additions & 0 deletions compat/test/browser/hooks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React, {
createElement,
useDeferredValue,
useInsertionEffect,
useSyncExternalStore,
useTransition,
render
} from 'preact/compat';
import { setupRerender, act } from 'preact/test-utils';
import { setupScratch, teardown } from '../../../test/_util/helpers';

describe('React-18-hooks', () => {
/** @type {HTMLDivElement} */
let scratch;

/** @type {() => void} */
let rerender;

beforeEach(() => {
scratch = setupScratch();
rerender = setupRerender();
});

afterEach(() => {
teardown(scratch);
});

describe('useDeferredValue', () => {
it('returns the value', () => {
const App = props => {
const val = useDeferredValue(props.text);
return <p>{val}</p>;
};

render(<App text="hello world" />, scratch);

expect(scratch.innerHTML).to.equal('<p>hello world</p>');
});
});

describe('useInsertionEffect', () => {
it('runs the effect', () => {
const spy = sinon.spy();
const App = () => {
useInsertionEffect(spy, []);
return <p>hello world</p>;
};

act(() => {
render(<App />, scratch);
});

expect(scratch.innerHTML).to.equal('<p>hello world</p>');
expect(spy).to.be.calledOnce;
});
});

describe('useTransition', () => {
it('runs transitions', () => {
const spy = sinon.spy();

let go;
const App = () => {
const [isPending, start] = useTransition();
go = start;
return <p>Pending: {isPending ? 'yes' : 'no'}</p>;
};

render(<App />, scratch);
expect(scratch.innerHTML).to.equal('<p>Pending: no</p>');

go(spy);
rerender();
expect(spy).to.be.calledOnce;
expect(scratch.innerHTML).to.equal('<p>Pending: no</p>');
});
});

describe('useSyncExternalStore', () => {
it('subscribes and follows effects', () => {
const subscribe = sinon.spy(() => () => {});
const getSnapshot = sinon.spy(() => 'hello world');

const App = () => {
const value = useSyncExternalStore(subscribe, getSnapshot);
return <p>{value}</p>;
};

act(() => {
render(<App />, scratch);
});
expect(scratch.innerHTML).to.equal('<p>hello world</p>');
expect(subscribe).to.be.calledOnce;
expect(getSnapshot).to.be.calledOnce;
});

it('subscribes and rerenders when called', () => {
let flush;
const subscribe = sinon.spy(cb => {
flush = cb;
return () => {};
});
let called = false;
const getSnapshot = sinon.spy(() => {
if (called) {
return 'hello new world';
}

return 'hello world';
});

const App = () => {
const value = useSyncExternalStore(subscribe, getSnapshot);
return <p>{value}</p>;
};

act(() => {
render(<App />, scratch);
});
expect(scratch.innerHTML).to.equal('<p>hello world</p>');
expect(subscribe).to.be.calledOnce;
expect(getSnapshot).to.be.calledOnce;

called = true;
flush();
rerender();

expect(scratch.innerHTML).to.equal('<p>hello new world</p>');
});
});
});

0 comments on commit 9e51ee1

Please sign in to comment.