Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compat/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ declare namespace React {
export import useRef = _hooks.useRef;
export import useState = _hooks.useState;
// React 18 hooks
export import use = _hooks.use;
export import useInsertionEffect = _hooks.useLayoutEffect;
export function useTransition(): [false, typeof startTransition];
export function useDeferredValue<T = any>(val: T): T;
Expand Down
6 changes: 6 additions & 0 deletions hooks/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ export function useMemo<T>(factory: () => T, inputs: Inputs | undefined): T;
*/
export function useContext<T>(context: PreactContext<T>): T;

/**
* Preact implementation of React's use hook
* Supports Promise and Context consumption
*/
export function use<T>(usable: Promise<T> | PreactContext<T>): T;

/**
* Customize the displayed value in the devtools panel.
*
Expand Down
97 changes: 96 additions & 1 deletion hooks/src/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { options as _options } from 'preact';
import { COMPONENT_FORCE } from '../../src/constants';
import { COMPONENT_FORCE, CONTEXT_TYPE } from '../../src/constants';

const ObjectIs = Object.is;

Expand Down Expand Up @@ -164,6 +164,101 @@ function getHookState(index, type) {
return hooks._list[index];
}

const PROMISE_CACHE = new WeakMap();
/**
* Preact implementation of React's use hook
* Supports Promise and Context consumption
*
* @template T
* @param {Promise<T> | import('preact').PreactContext<T>} usable
* @returns {T}
*/
export function use(usable) {
if (
usable != null &&
(typeof usable === 'object' || typeof usable === 'function')
) {
if ('then' in usable && typeof usable.then === 'function') {
return usePromise(usable);
}

if ('$$typeof' in usable && usable.$$typeof === CONTEXT_TYPE) {
return useContext(
/** @type {import('./internal').PreactContext} */ (
/** @type {unknown} */ (usable)
)
);
}
}

throw new Error(`An unsupported type was passed to use(): ${usable}`);
}

/**
* Internal function to handle Promise resources
* @template T
* @param {Promise<T>} promise
* @returns {T}
*/
function usePromise(promise) {
const [, forceUpdate] = useState(/** @type {object} */ ({}));

let promiseState = PROMISE_CACHE.get(promise);

if (!promiseState) {
promiseState = {
status: 'pending',
value: undefined,
reason: undefined,
subscribers: new Set()
};

PROMISE_CACHE.set(promise, promiseState);

promise.then(
value => {
if (promiseState.status === 'pending') {
promiseState.status = 'fulfilled';
promiseState.value = value;
promiseState.subscribers.forEach(cb => cb({}));
promiseState.subscribers.clear();
}
},
reason => {
if (promiseState.status === 'pending') {
promiseState.status = 'rejected';
promiseState.reason = reason;
promiseState.subscribers.forEach(cb => cb({}));
promiseState.subscribers.clear();
}
}
);
}

if (promiseState.status === 'pending') {
promiseState.subscribers.add(forceUpdate);
}

useEffect(() => {
return () => {
if (promiseState) {
promiseState.subscribers.delete(forceUpdate);
}
};
}, [promise]);

switch (promiseState.status) {
case 'fulfilled':
return promiseState.value;
case 'rejected':
throw promiseState.reason;
case 'pending':
throw promise;
default:
throw promise;
}
}

/**
* @template {unknown} S
* @param {import('./index').Dispatch<import('./index').StateUpdater<S>>} [initialState]
Expand Down
210 changes: 210 additions & 0 deletions hooks/test/browser/use.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { setupScratch, teardown } from '../../../test/_util/helpers';
import { setupRerender } from 'preact/test-utils';
import { createElement, render, createContext } from 'preact';
import { Suspense } from 'preact/compat';
import { use, useErrorBoundary } from 'preact/hooks';

describe('use(promise)', () => {
/** @type {HTMLDivElement} */
let scratch;
/** @type {() => void} */
let rerender;

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

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

it('suspends on pending and renders fallback, then shows resolved data', async () => {
/** @type {(v: string) => void} */
let resolve;
const p = new Promise((res, _rej) => {
resolve = v => res(v);
});

function Data() {
const val = use(p);
return <div>Data: {val}</div>;
}

render(
<Suspense fallback={<div>Loading</div>}>
<Data />
</Suspense>,
scratch
);
// Initial render followed by rerender to reflect fallback during suspension
rerender();
expect(scratch.innerHTML).to.equal('<div>Loading</div>');

resolve('hello');
await p;
rerender();
expect(scratch.innerHTML).to.equal('<div>Data: hello</div>');
});

it('renders two components using same promise and updates both on resolve', async () => {
/** @type {(v: string) => void} */
let resolve;
const p = new Promise((res, _rej) => {
resolve = v => res(v);
});

function A() {
const val = use(p);
return <div>A: {val}</div>;
}
function B() {
const val = use(p);
return <div>B: {val}</div>;
}

render(
<Suspense fallback={<div>Loading</div>}>
<A />
<B />
</Suspense>,
scratch
);
rerender();
expect(scratch.innerHTML).to.equal('<div>Loading</div>');

resolve('x');
await p;
rerender();
expect(scratch.innerHTML).to.equal('<div>A: x</div><div>B: x</div>');
});

it('propagates rejection to error boundary after suspension', async () => {
/** @type {() => void} */
let reject;
const p = new Promise((res, rej) => {
reject = () => rej(new Error('boom'));
});
p.catch(() => {});

function Catcher(props) {
const [err] = useErrorBoundary();
return err ? <div>Caught: {err.message}</div> : props.children;
}

function Data() {
const val = use(p);
return <div>Data: {val}</div>;
}

render(
<Suspense fallback={<div>Loading</div>}>
<Catcher>
<Data />
</Catcher>
</Suspense>,
scratch
);

await new Promise(resolve => setTimeout(resolve, 0));
rerender();
expect(scratch.innerHTML).to.equal('<div>Loading</div>');

reject();

await new Promise(resolve => setTimeout(resolve, 0));
rerender();

expect(scratch.innerHTML).to.equal('<div>Caught: boom</div>');
});
});

describe('use(context)', () => {
/** @type {HTMLDivElement} */
let scratch;

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

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

it('gets values from context via use(Context)', () => {
const values = [];
const Ctx = createContext(13);

function Comp() {
const value = use(Ctx);
values.push(value);
return null;
}

render(<Comp />, scratch);
render(
<Ctx.Provider value={42}>
<Comp />
</Ctx.Provider>,
scratch
);
render(
<Ctx.Provider value={69}>
<Comp />
</Ctx.Provider>,
scratch
);

expect(values).to.deep.equal([13, 42, 69]);
});

it('uses default value when no provider is present', () => {
const Foo = createContext(42);
let read;

function App() {
read = use(Foo);
return <div />;
}

render(<App />, scratch);
expect(read).to.equal(42);
});

it('supports multiple contexts via use(Context)', () => {
const Foo = createContext(0);
const Bar = createContext(10);
/** @type {Array<[number, number]>} */
const reads = [];

function Comp() {
const foo = use(Foo);
const bar = use(Bar);
reads.push([foo, bar]);
return <div />;
}

render(
<Foo.Provider value={0}>
<Bar.Provider value={10}>
<Comp />
</Bar.Provider>
</Foo.Provider>,
scratch
);
expect(reads).to.deep.equal([[0, 10]]);

render(
<Foo.Provider value={11}>
<Bar.Provider value={42}>
<Comp />
</Bar.Provider>
</Foo.Provider>,
scratch
);
expect(reads).to.deep.equal([
[0, 10],
[11, 42]
]);
});
});
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ export const EMPTY_OBJ = /** @type {any} */ ({});
export const EMPTY_ARR = [];

export const MATHML_TOKEN_ELEMENTS = /(mi|mn|mo|ms$|mte|msp)/;

export const CONTEXT_TYPE = Symbol.for('react.context');
3 changes: 2 additions & 1 deletion src/create-context.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { enqueueRender } from './component';
import { NULL, COMPONENT_FORCE } from './constants';
import { NULL, COMPONENT_FORCE, CONTEXT_TYPE } from './constants';

export let i = 0;

Expand Down Expand Up @@ -42,6 +42,7 @@ export function createContext(defaultValue) {
return props.children;
}

Context.$$typeof = CONTEXT_TYPE;
Context._id = '__cC' + i++;
Context._defaultValue = defaultValue;

Expand Down
Loading