From ef6cdc28ba033e7b550e779f385d495965046d9b Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:39:51 +0800 Subject: [PATCH 1/8] feat: support use hook --- compat/src/index.d.ts | 1 + hooks/src/index.d.ts | 6 + hooks/src/index.js | 97 ++++++++++++++- hooks/test/browser/use.test.jsx | 210 ++++++++++++++++++++++++++++++++ src/constants.js | 2 + src/create-context.js | 3 +- src/index.d.ts | 50 ++------ src/internal.d.ts | 3 +- 8 files changed, 330 insertions(+), 42 deletions(-) create mode 100644 hooks/test/browser/use.test.jsx diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index eb3fa7bbdb..9fd1153ec6 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -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(val: T): T; diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 523ac8484b..0d40dcb1ba 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -125,6 +125,12 @@ export function useMemo(factory: () => T, inputs: Inputs | undefined): T; */ export function useContext(context: PreactContext): T; +/** + * Preact implementation of React's use hook + * Supports Promise and Context consumption + */ +export function use(usable: 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..dea6bbb7b9 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -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; @@ -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 | import('preact').PreactContext} 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} 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>} [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/constants.js b/src/constants.js index 1ad82b208e..ebdd3a601b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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'); diff --git a/src/create-context.js b/src/create-context.js index 57a760f3f8..726156df9d 100644 --- a/src/create-context.js +++ b/src/create-context.js @@ -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; @@ -42,6 +42,7 @@ export function createContext(defaultValue) { return props.children; } + Context.$$typeof = CONTEXT_TYPE; Context._id = '__cC' + i++; Context._defaultValue = defaultValue; diff --git a/src/index.d.ts b/src/index.d.ts index 96f9b42dde..dc435aba8c 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, @@ -377,6 +348,7 @@ export type ContextType> = C extends Context : never; export interface Context extends preact.Provider { + $$typeof?: symbol; Consumer: preact.Consumer; Provider: preact.Provider; displayName?: string; 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 { From 0273f327ae1259b9411298536d5cedebaf3420dd Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:19:51 +0800 Subject: [PATCH 2/8] Apply suggestion from @rschristian Co-authored-by: Ryan Christian <33403762+rschristian@users.noreply.github.com> --- hooks/src/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 0d40dcb1ba..9ebf41c827 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -129,7 +129,7 @@ export function useContext(context: PreactContext): T; * Preact implementation of React's use hook * Supports Promise and Context consumption */ -export function use(usable: Promise | PreactContext): T; +export function use(resource: Promise | PreactContext): T; /** * Customize the displayed value in the devtools panel. From 66ef156f8a5cabbf0e5eeff0b349baa0dfaf85ef Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:20:12 +0800 Subject: [PATCH 3/8] Apply suggestion from @rschristian Co-authored-by: Ryan Christian <33403762+rschristian@users.noreply.github.com> --- hooks/src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/src/index.js b/hooks/src/index.js index dea6bbb7b9..951b28d8ba 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -173,7 +173,7 @@ const PROMISE_CACHE = new WeakMap(); * @param {Promise | import('preact').PreactContext} usable * @returns {T} */ -export function use(usable) { +export function use(resource) { if ( usable != null && (typeof usable === 'object' || typeof usable === 'function') From 6897dc74019ca6dae6f247606face98eb7ec856c Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:20:30 +0800 Subject: [PATCH 4/8] Apply suggestion from @JoviDeCroock Co-authored-by: Jovi De Croock --- hooks/src/index.js | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/hooks/src/index.js b/hooks/src/index.js index 951b28d8ba..7e4fc9fb61 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -174,24 +174,15 @@ const PROMISE_CACHE = new WeakMap(); * @returns {T} */ export function use(resource) { - 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}`); + if (usable && typeof usable.then === 'function') { + return usePromise(usable); + } + + return useContext( + /** @type {import('./internal').PreactContext} */ ( + /** @type {unknown} */ (usable) + ) + ); } /** From 0ef32f007cc4239b31bf82c70b086f038cbf442c Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:46:14 +0800 Subject: [PATCH 5/8] chore: rename usable to resource & delete CONTEXT_TYPE --- compat/src/index.d.ts | 3 ++- hooks/src/index.d.ts | 1 - hooks/src/index.js | 13 ++++++------- src/create-context.js | 3 +-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 9fd1153ec6..fcc3c16a8c 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -125,7 +125,6 @@ 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(val: T): T; @@ -133,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 9ebf41c827..bbe95a8cd7 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -126,7 +126,6 @@ export function useMemo(factory: () => T, inputs: Inputs | undefined): T; export function useContext(context: PreactContext): T; /** - * Preact implementation of React's use hook * Supports Promise and Context consumption */ export function use(resource: Promise | PreactContext): T; diff --git a/hooks/src/index.js b/hooks/src/index.js index 7e4fc9fb61..ab36223bcc 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -1,5 +1,5 @@ import { options as _options } from 'preact'; -import { COMPONENT_FORCE, CONTEXT_TYPE } from '../../src/constants'; +import { COMPONENT_FORCE } from '../../src/constants'; const ObjectIs = Object.is; @@ -166,21 +166,20 @@ function getHookState(index, type) { const PROMISE_CACHE = new WeakMap(); /** - * Preact implementation of React's use hook * Supports Promise and Context consumption * * @template T - * @param {Promise | import('preact').PreactContext} usable + * @param {Promise | import('preact').PreactContext} resource * @returns {T} */ export function use(resource) { - if (usable && typeof usable.then === 'function') { - return usePromise(usable); - } + if ('then' in resource && typeof resource.then === 'function') { + return usePromise(resource); + } return useContext( /** @type {import('./internal').PreactContext} */ ( - /** @type {unknown} */ (usable) + /** @type {unknown} */ (resource) ) ); } diff --git a/src/create-context.js b/src/create-context.js index 726156df9d..57a760f3f8 100644 --- a/src/create-context.js +++ b/src/create-context.js @@ -1,5 +1,5 @@ import { enqueueRender } from './component'; -import { NULL, COMPONENT_FORCE, CONTEXT_TYPE } from './constants'; +import { NULL, COMPONENT_FORCE } from './constants'; export let i = 0; @@ -42,7 +42,6 @@ export function createContext(defaultValue) { return props.children; } - Context.$$typeof = CONTEXT_TYPE; Context._id = '__cC' + i++; Context._defaultValue = defaultValue; From 846560d8491e7c9dfbde01343eb53810ced31025 Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:47:37 +0800 Subject: [PATCH 6/8] Apply suggestion from @JoviDeCroock Co-authored-by: Jovi De Croock --- hooks/src/index.js | 54 ++++++++-------------------------------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/hooks/src/index.js b/hooks/src/index.js index ab36223bcc..a824633311 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -191,62 +191,26 @@ export function use(resource) { * @returns {T} */ function usePromise(promise) { - const [, forceUpdate] = useState(/** @type {object} */ ({})); - - let promiseState = PROMISE_CACHE.get(promise); - - if (!promiseState) { - promiseState = { - status: 'pending', + const [promiseState, update] = useState({ value: undefined, reason: undefined, - subscribers: new Set() - }; - - PROMISE_CACHE.set(promise, promiseState); + }); + if (!promiseState.value && !promiseState.reason) { promise.then( value => { - if (promiseState.status === 'pending') { - promiseState.status = 'fulfilled'; - promiseState.value = value; - promiseState.subscribers.forEach(cb => cb({})); - promiseState.subscribers.clear(); - } + update({ status: 'fulfilled', value }) }, reason => { - if (promiseState.status === 'pending') { - promiseState.status = 'rejected'; - promiseState.reason = reason; - promiseState.subscribers.forEach(cb => cb({})); - promiseState.subscribers.clear(); - } + update({ status: 'rejected', reason }) } ); } - 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; - } + if (promiseState.value) return promiseState.value; + if (promiseState.reason) throw promiseState.reason; + + throw promise; } /** From 11f624154f78749f1578d89b18bf9a5ea18422e9 Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:47:48 +0800 Subject: [PATCH 7/8] Apply suggestion from @JoviDeCroock Co-authored-by: Jovi De Croock --- hooks/src/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/hooks/src/index.js b/hooks/src/index.js index a824633311..8b00c2c15f 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -164,7 +164,6 @@ function getHookState(index, type) { return hooks._list[index]; } -const PROMISE_CACHE = new WeakMap(); /** * Supports Promise and Context consumption * From 970714737988457de3455a8bb1fcd2de099f48f6 Mon Sep 17 00:00:00 2001 From: BitterGourd <91231822+gaoachao@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:18:30 +0800 Subject: [PATCH 8/8] chore: delete CONTEXT_TYPE --- hooks/src/index.js | 16 ++++++++-------- src/constants.js | 2 -- src/index.d.ts | 1 - 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/hooks/src/index.js b/hooks/src/index.js index 8b00c2c15f..3e3e762b97 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -191,25 +191,25 @@ export function use(resource) { */ function usePromise(promise) { const [promiseState, update] = useState({ - value: undefined, - reason: undefined, + value: undefined, + reason: undefined }); if (!promiseState.value && !promiseState.reason) { promise.then( value => { - update({ status: 'fulfilled', value }) + update({ status: 'fulfilled', value }); }, reason => { - update({ status: 'rejected', reason }) + update({ status: 'rejected', reason }); } ); } - if (promiseState.value) return promiseState.value; - if (promiseState.reason) throw promiseState.reason; - - throw promise; + if (promiseState.value) return promiseState.value; + if (promiseState.reason) throw promiseState.reason; + + throw promise; } /** diff --git a/src/constants.js b/src/constants.js index ebdd3a601b..1ad82b208e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -33,5 +33,3 @@ 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'); diff --git a/src/index.d.ts b/src/index.d.ts index dc435aba8c..3568734a39 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -348,7 +348,6 @@ export type ContextType> = C extends Context : never; export interface Context extends preact.Provider { - $$typeof?: symbol; Consumer: preact.Consumer; Provider: preact.Provider; displayName?: string;