diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index eb3fa7bbdb..fcc3c16a8c 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -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; diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 523ac8484b..bbe95a8cd7 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -125,6 +125,11 @@ export function useMemo(factory: () => T, inputs: Inputs | undefined): T; */ export function useContext(context: PreactContext): T; +/** + * Supports Promise and Context consumption + */ +export function use(resource: Promise | PreactContext): T; + /** * Customize the displayed value in the devtools panel. * diff --git a/hooks/src/index.js b/hooks/src/index.js index 67f63e4bd0..3e3e762b97 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -164,6 +164,54 @@ function getHookState(index, type) { return hooks._list[index]; } +/** + * Supports Promise and Context consumption + * + * @template T + * @param {Promise | import('preact').PreactContext} 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} 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>} [initialState] diff --git a/hooks/test/browser/use.test.jsx b/hooks/test/browser/use.test.jsx new file mode 100644 index 0000000000..cc0bcf3fd6 --- /dev/null +++ b/hooks/test/browser/use.test.jsx @@ -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
Data: {val}
; + } + + render( + Loading}> + + , + scratch + ); + // Initial render followed by rerender to reflect fallback during suspension + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + resolve('hello'); + await p; + rerender(); + expect(scratch.innerHTML).to.equal('
Data: hello
'); + }); + + 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
A: {val}
; + } + function B() { + const val = use(p); + return
B: {val}
; + } + + render( + Loading}> + + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + resolve('x'); + await p; + rerender(); + expect(scratch.innerHTML).to.equal('
A: x
B: x
'); + }); + + 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 ?
Caught: {err.message}
: props.children; + } + + function Data() { + const val = use(p); + return
Data: {val}
; + } + + render( + Loading}> + + + + , + scratch + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + reject(); + + await new Promise(resolve => setTimeout(resolve, 0)); + rerender(); + + expect(scratch.innerHTML).to.equal('
Caught: boom
'); + }); +}); + +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(, scratch); + render( + + + , + scratch + ); + render( + + + , + 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
; + } + + render(, 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
; + } + + render( + + + + + , + scratch + ); + expect(reads).to.deep.equal([[0, 10]]); + + render( + + + + + , + scratch + ); + expect(reads).to.deep.equal([ + [0, 10], + [11, 42] + ]); + }); +}); diff --git a/src/index.d.ts b/src/index.d.ts index 96f9b42dde..3568734a39 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -189,14 +189,10 @@ export abstract class Component { export function createElement( type: 'input', props: - | (DOMAttributes & - ClassAttributes) + | (DOMAttributes & ClassAttributes) | null, ...children: ComponentChildren[] -): VNode< - DOMAttributes & - ClassAttributes ->; +): VNode & ClassAttributes>; export function createElement< P extends HTMLAttributes, T extends HTMLElement @@ -215,15 +211,9 @@ export function createElement< ): VNode & P>; export function createElement( type: string, - props: - | (ClassAttributes & - HTMLAttributes & - SVGAttributes) - | null, + props: (ClassAttributes & HTMLAttributes & SVGAttributes) | null, ...children: ComponentChildren[] -): VNode< - ClassAttributes & HTMLAttributes & SVGAttributes ->; +): VNode & HTMLAttributes & SVGAttributes>; export function createElement

( type: ComponentType

| string, props: (Attributes & P) | null, @@ -236,44 +226,25 @@ export namespace createElement { export function h( type: 'input', props: - | (DOMAttributes & - ClassAttributes) + | (DOMAttributes & ClassAttributes) | null, ...children: ComponentChildren[] -): VNode< - DOMAttributes & - ClassAttributes ->; -export function h< - P extends HTMLAttributes, - T extends HTMLElement ->( +): VNode & ClassAttributes>; +export function h

, T extends HTMLElement>( type: keyof JSXInternal.IntrinsicElements, props: (ClassAttributes & P) | null, ...children: ComponentChildren[] ): VNode & P>; -export function h< - P extends SVGAttributes, - T extends HTMLElement ->( +export function h

, T extends HTMLElement>( type: keyof JSXInternal.IntrinsicSVGElements, props: (ClassAttributes & P) | null, ...children: ComponentChildren[] ): VNode & P>; export function h( type: string, - props: - | (ClassAttributes & - HTMLAttributes & - SVGAttributes) - | null, + props: (ClassAttributes & HTMLAttributes & SVGAttributes) | null, ...children: ComponentChildren[] -): VNode< - | (ClassAttributes & - HTMLAttributes & - SVGAttributes) - | null ->; +): VNode<(ClassAttributes & HTMLAttributes & SVGAttributes) | null>; export function h

( type: ComponentType

| string, props: (Attributes & P) | null, diff --git a/src/internal.d.ts b/src/internal.d.ts index fe6713e071..c03bffd230 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -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 {