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()', () => {});
+});