diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 0d3a347086..9243b68e87 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -130,6 +130,8 @@ declare namespace React { subscribe: (flush: () => void) => () => void, getSnapshot: () => T ): T; + // React 19 hooks + export import use = _hooks.use; export function useEffectEvent(cb: T): T; // Preact Defaults diff --git a/compat/src/index.js b/compat/src/index.js index 18c709ee42..d08def5ccd 100644 --- a/compat/src/index.js +++ b/compat/src/index.js @@ -19,7 +19,8 @@ import { useMemo, useReducer, useRef, - useState + useState, + use } from 'preact/hooks'; import { Children } from './Children'; import { PureComponent } from './PureComponent'; @@ -208,6 +209,7 @@ export default { useMemo, useCallback, useContext, + use, useDebugValue, version, Children, diff --git a/compat/test/browser/exports.test.js b/compat/test/browser/exports.test.js index b3dd287f42..c4f9b63d64 100644 --- a/compat/test/browser/exports.test.js +++ b/compat/test/browser/exports.test.js @@ -41,6 +41,7 @@ describe('compat exports', () => { expect(Compat.useInsertionEffect).to.be.a('function'); expect(Compat.useTransition).to.be.a('function'); expect(Compat.useDeferredValue).to.be.a('function'); + expect(Compat.use).to.be.a('function'); // Suspense expect(Compat.Suspense).to.be.a('function'); @@ -82,6 +83,7 @@ describe('compat exports', () => { expect(Named.useMemo).to.be.a('function'); expect(Named.useCallback).to.be.a('function'); expect(Named.useContext).to.be.a('function'); + expect(Named.use).to.be.a('function'); // Suspense expect(Named.Suspense).to.be.a('function'); diff --git a/compat/test/ts/index.tsx b/compat/test/ts/index.tsx index 51dbd60adc..43ff1f2f4b 100644 --- a/compat/test/ts/index.tsx +++ b/compat/test/ts/index.tsx @@ -17,6 +17,12 @@ React.createPortal(
, document.createDocumentFragment()); React.createPortal(
, document.body.shadowRoot!); const Ctx = React.createContext({ contextValue: '' }); +const contextValue: { contextValue: string } = React.use(Ctx); +const promiseValue: string = React.use(Promise.resolve('value')); + +contextValue.contextValue.toLowerCase(); +promiseValue.toLowerCase(); + class SimpleComponentWithContextAsProvider extends React.Component { componentProp = 'componentProp'; render() { diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 5acc8ffee3..c4ee2a993b 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -123,6 +123,17 @@ export function useMemo(factory: () => T, inputs: Inputs | undefined): T; */ export function useContext(context: PreactContext): T; +/** + * Read the value of a resource like a Promise or a Context. Unlike other + * hooks, `use` may be called conditionally. + * + * When passed a pending Promise the component suspends until it settles, + * rethrowing the rejection reason if it rejects. + * + * @param resource The Promise or Context to read + */ +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 c56e1c68f4..f36df5c0f4 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -164,6 +164,48 @@ function getHookState(index, type) { return hooks._list[index]; } +/** + * Read the value of a Promise (suspending while pending) or a Context. + * Unlike other hooks, `use` may be called conditionally. + * @template T + * @param {Promise | import('preact').PreactContext} resource + * @returns {T} + */ +export function use(resource) { + if (options._hook) { + options._hook(currentComponent, currentIndex, 12); + } + + if (typeof resource.then == 'function') { + const thenable = + /** @type {Promise & { status?: string, value?: T, reason?: any }} */ ( + resource + ); + if (thenable.status == 'fulfilled') return thenable.value; + if (thenable.status == 'rejected') throw thenable.reason; + if (thenable.status != 'pending') { + thenable.status = 'pending'; + thenable.then( + value => { + thenable.status = 'fulfilled'; + thenable.value = value; + }, + reason => { + thenable.status = 'rejected'; + thenable.reason = reason; + } + ); + } + throw thenable; + } + + const context = /** @type {import('./internal').PreactContext} */ (resource); + const provider = currentComponent.context[context._id]; + if (!provider) return context._defaultValue; + provider.sub(currentComponent); + return provider.props.value; +} + /** * @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..f90dc6bc35 --- /dev/null +++ b/hooks/test/browser/use.test.jsx @@ -0,0 +1,293 @@ +import { createElement, render, createContext, Component } from 'preact'; +import { setupRerender } from 'preact/test-utils'; +import { Suspense } from 'preact/compat'; +import { use, useState } from 'preact/hooks'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +describe('use(promise)', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + async function flushPromise(promise) { + await promise; + rerender(); + rerender(); + } + + it('suspends while pending and renders the fulfilled value', async () => { + /** @type {(value: string) => void} */ + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + + function Data() { + return
Data: {use(promise)}
; + } + + render( + Loading
}> + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + resolve('hello'); + await flushPromise(promise); + expect(scratch.innerHTML).to.equal('
Data: hello
'); + }); + + it('supports falsy fulfilled values', async () => { + const promise = Promise.resolve(0); + + function Data() { + return
{use(promise)}
; + } + + render( + Loading
}> + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + await flushPromise(promise); + expect(scratch.innerHTML).to.equal('
0
'); + }); + + it('renders multiple consumers of the same fulfilled promise', async () => { + /** @type {(value: string) => void} */ + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + + function A() { + return
A: {use(promise)}
; + } + + function B() { + return
B: {use(promise)}
; + } + + render( + Loading}> + + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + resolve('x'); + await flushPromise(promise); + expect(scratch.innerHTML).to.equal('
A: x
B: x
'); + }); + + it('throws the rejection reason after suspension', async () => { + /** @type {(error: Error) => void} */ + let reject; + const promise = new Promise((_res, rej) => { + reject = rej; + }); + + class ErrorBoundary extends Component { + state = { error: null }; + + componentDidCatch(error) { + this.setState({ error }); + } + + render(props, state) { + return state.error ? ( +
Caught: {state.error.message}
+ ) : ( + props.children + ); + } + } + + function Data() { + return
Data: {use(promise)}
; + } + + render( + + Loading}> + + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
Loading
'); + + reject(new Error('boom')); + await flushPromise(promise.catch(() => {})); + expect(scratch.innerHTML).to.equal('
Caught: boom
'); + }); + + it('can be called conditionally without shifting hook state', () => { + const promise = + /** @type {Promise & { status: string, value: string }} */ ( + Promise.resolve('done') + ); + promise.status = 'fulfilled'; + promise.value = 'done'; + /** @type {(value: string) => void} */ + let setSecond; + + function App(props) { + const [first] = useState('first'); + let value = 'skipped'; + if (props.show) value = use(promise); + const [second, updateSecond] = useState('second'); + setSecond = updateSecond; + return ( +
+ {first}:{value}:{second} +
+ ); + } + + render(, scratch); + expect(scratch.innerHTML).to.equal('
first:skipped:second
'); + + setSecond('updated'); + rerender(); + expect(scratch.innerHTML).to.equal('
first:skipped:updated
'); + + render(, scratch); + expect(scratch.innerHTML).to.equal('
first:done:updated
'); + }); +}); + +describe('use(context)', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('reads the current context value', () => { + const Context = createContext(13); + + function App() { + return
{use(Context)}
; + } + + render(, scratch); + expect(scratch.innerHTML).to.equal('
13
'); + + render( + + + , + scratch + ); + expect(scratch.innerHTML).to.equal('
42
'); + }); + + it('subscribes to provider updates', () => { + const Context = createContext('a'); + + class NoUpdate extends Component { + shouldComponentUpdate() { + return false; + } + + render() { + return this.props.children; + } + } + + function App() { + return
{use(Context)}
; + } + + render( + + + + + , + scratch + ); + expect(scratch.innerHTML).to.equal('
a
'); + + render( + + + + + , + scratch + ); + rerender(); + expect(scratch.innerHTML).to.equal('
b
'); + }); + + it('can be called conditionally without shifting hook state', () => { + const Context = createContext('context'); + /** @type {(value: string) => void} */ + let setSecond; + + function App(props) { + const [first] = useState('first'); + const value = props.show ? use(Context) : 'skipped'; + const [second, updateSecond] = useState('second'); + setSecond = updateSecond; + return ( +
+ {first}:{value}:{second} +
+ ); + } + + render( + + + , + scratch + ); + expect(scratch.innerHTML).to.equal('
first:skipped:second
'); + + setSecond('updated'); + rerender(); + expect(scratch.innerHTML).to.equal('
first:skipped:updated
'); + + render( + + + , + scratch + ); + expect(scratch.innerHTML).to.equal('
first:provided:updated
'); + }); +}); diff --git a/src/internal.d.ts b/src/internal.d.ts index 3e69f6ab38..6e1bd95150 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -12,7 +12,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 { diff --git a/test/ts/hooks.test.tsx b/test/ts/hooks.test.tsx new file mode 100644 index 0000000000..a0d0dd2e72 --- /dev/null +++ b/test/ts/hooks.test.tsx @@ -0,0 +1,20 @@ +import { createContext } from '../../'; +import { use } from '../../hooks'; + +const Context = createContext(1); + +function UseTypes() { + const contextValue: number = use(Context); + const promiseValue: number = use(Promise.resolve(1)); + + contextValue.toFixed(); + promiseValue.toFixed(); + + return null; +} + +void UseTypes; + +describe('hooks types', () => { + it('typechecks use()', () => {}); +});