diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 2ea4e22c50..21ee331610 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; // Preact Defaults export import Context = _preact.Context; diff --git a/compat/src/index.js b/compat/src/index.js index ef868cef0b..567d764f3f 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'; @@ -205,6 +206,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..becdb3e3d3 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -123,6 +123,8 @@ export function useMemo(factory: () => T, inputs: Inputs | undefined): T; */ export function useContext(context: PreactContext): T; +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 08d44ac4ee..05239be698 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -164,6 +164,65 @@ function getHookState(index, type) { return hooks._list[index]; } +/** + * @template T + * @param {Promise | import('preact').PreactContext} resource + * @returns {T} + */ +export function use(resource) { + if (options._hook) { + options._hook(currentComponent, currentIndex, 12); + } + + if (resource && typeof resource.then == 'function') { + return usePromise(/** @type {Promise} */ (resource)); + } + + const context = /** @type {import('./internal').PreactContext} */ ( + /** @type {unknown} */ (resource) + ); + const provider = currentComponent.context[context._id]; + if (!provider) return context._defaultValue; + + const contexts = + currentComponent._contexts || (currentComponent._contexts = {}); + if (!contexts[context._id]) { + contexts[context._id] = true; + provider.sub(currentComponent); + } + + return provider.props.value; +} + +/** + * @template T + * @param {Promise} promise + * @returns {T} + */ +function usePromise(promise) { + /** @type {Promise & { status?: string, value?: T, reason?: unknown }} */ + const p = promise; + + if (p.status == 'fulfilled') return /** @type {T} */ (p.value); + if (p.status == 'rejected') throw p.reason; + + if (p.status != 'pending') { + p.status = 'pending'; + p.then( + value => { + p.status = 'fulfilled'; + p.value = value; + }, + reason => { + p.status = 'rejected'; + p.reason = reason; + } + ); + } + + throw p; +} + /** * @template {unknown} S * @param {import('./index').Dispatch>} [initialState] diff --git a/hooks/src/internal.d.ts b/hooks/src/internal.d.ts index e2b9d6ddb0..39d53b489a 100644 --- a/hooks/src/internal.d.ts +++ b/hooks/src/internal.d.ts @@ -36,6 +36,7 @@ export interface ComponentHooks { export interface Component extends Omit, '_renderCallbacks'> { __hooks?: ComponentHooks; + _contexts?: Record; // Extend to include HookStates _renderCallbacks?: Array void)>; _hasScuFromHooks?: boolean; 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 f530064c19..3deef2fe44 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()', () => {}); +});