Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions compat/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ declare namespace React {
subscribe: (flush: () => void) => () => void,
getSnapshot: () => T
): T;
// React 19 hooks
export import use = _hooks.use;

// Preact Defaults
export import Context = preact1.Context;
Expand Down
5 changes: 5 additions & 0 deletions hooks/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export function useMemo<T>(factory: () => T, inputs: Inputs | undefined): T;
*/
export function useContext<T>(context: PreactContext<T>): T;

/**
* Supports Promise and Context consumption
*/
export function use<T>(resource: Promise<T> | PreactContext<T>): T;

/**
* Customize the displayed value in the devtools panel.
*
Expand Down
48 changes: 48 additions & 0 deletions hooks/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,54 @@ function getHookState(index, type) {
return hooks._list[index];
}

/**
* Supports Promise and Context consumption
*
* @template T
* @param {Promise<T> | import('preact').PreactContext<T>} resource
* @returns {T}
*/
export function use(resource) {
if ('then' in resource && typeof resource.then === 'function') {
return usePromise(resource);
}

return useContext(
/** @type {import('./internal').PreactContext} */ (
/** @type {unknown} */ (resource)
)
);
}

/**
* Internal function to handle Promise resources
* @template T
* @param {Promise<T>} promise
* @returns {T}
*/
function usePromise(promise) {
const [promiseState, update] = useState({
value: undefined,
reason: undefined
});

if (!promiseState.value && !promiseState.reason) {
promise.then(
value => {
update({ status: 'fulfilled', value });
},
reason => {
update({ status: 'rejected', reason });
}
);
}

if (promiseState.value) return promiseState.value;
if (promiseState.reason) throw promiseState.reason;

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]
]);
});
});
49 changes: 10 additions & 39 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,10 @@ export abstract class Component<P, S> {
export function createElement(
type: 'input',
props:
| (DOMAttributes<HTMLInputElement> &
ClassAttributes<HTMLInputElement>)
| (DOMAttributes<HTMLInputElement> & ClassAttributes<HTMLInputElement>)
| null,
...children: ComponentChildren[]
): VNode<
DOMAttributes<HTMLInputElement> &
ClassAttributes<HTMLInputElement>
>;
): VNode<DOMAttributes<HTMLInputElement> & ClassAttributes<HTMLInputElement>>;
export function createElement<
P extends HTMLAttributes<T>,
T extends HTMLElement
Expand All @@ -215,15 +211,9 @@ export function createElement<
): VNode<ClassAttributes<T> & P>;
export function createElement<T extends HTMLElement>(
type: string,
props:
| (ClassAttributes<T> &
HTMLAttributes &
SVGAttributes)
| null,
props: (ClassAttributes<T> & HTMLAttributes & SVGAttributes) | null,
...children: ComponentChildren[]
): VNode<
ClassAttributes<T> & HTMLAttributes & SVGAttributes
>;
): VNode<ClassAttributes<T> & HTMLAttributes & SVGAttributes>;
export function createElement<P>(
type: ComponentType<P> | string,
props: (Attributes & P) | null,
Expand All @@ -236,44 +226,25 @@ export namespace createElement {
export function h(
type: 'input',
props:
| (DOMAttributes<HTMLInputElement> &
ClassAttributes<HTMLInputElement>)
| (DOMAttributes<HTMLInputElement> & ClassAttributes<HTMLInputElement>)
| null,
...children: ComponentChildren[]
): VNode<
DOMAttributes<HTMLInputElement> &
ClassAttributes<HTMLInputElement>
>;
export function h<
P extends HTMLAttributes<T>,
T extends HTMLElement
>(
): VNode<DOMAttributes<HTMLInputElement> & ClassAttributes<HTMLInputElement>>;
export function h<P extends HTMLAttributes<T>, T extends HTMLElement>(
type: keyof JSXInternal.IntrinsicElements,
props: (ClassAttributes<T> & P) | null,
...children: ComponentChildren[]
): VNode<ClassAttributes<T> & P>;
export function h<
P extends SVGAttributes<T>,
T extends HTMLElement
>(
export function h<P extends SVGAttributes<T>, T extends HTMLElement>(
type: keyof JSXInternal.IntrinsicSVGElements,
props: (ClassAttributes<T> & P) | null,
...children: ComponentChildren[]
): VNode<ClassAttributes<T> & P>;
export function h<T extends HTMLElement>(
type: string,
props:
| (ClassAttributes<T> &
HTMLAttributes &
SVGAttributes)
| null,
props: (ClassAttributes<T> & HTMLAttributes & SVGAttributes) | null,
...children: ComponentChildren[]
): VNode<
| (ClassAttributes<T> &
HTMLAttributes &
SVGAttributes)
| null
>;
): VNode<(ClassAttributes<T> & HTMLAttributes & SVGAttributes) | null>;
export function h<P>(
type: ComponentType<P> | string,
props: (Attributes & P) | null,
Expand Down
3 changes: 2 additions & 1 deletion src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export enum HookType {
useContext = 9,
useErrorBoundary = 10,
// Not a real hook, but the devtools treat is as such
useDebugvalue = 11
useDebugvalue = 11,
use = 12
}

export interface DevSource {
Expand Down