diff --git a/src/renderer/__tests__/hooks.node.test.tsx b/src/renderer/__tests__/hooks.node.test.tsx index f0376c43..002393f9 100644 --- a/src/renderer/__tests__/hooks.node.test.tsx +++ b/src/renderer/__tests__/hooks.node.test.tsx @@ -1,4 +1,19 @@ -import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react' +import React, { + createContext, + Usable, + use, + useActionState, + useCallback, + useEffect, + useEffectEvent, + useId, + useLayoutEffect, + useMemo, + useOptimistic, + useReducer, + useRef, + useState, +} from 'react' import { Text } from '../../jsx/Text' import { renderVoltraVariantToJson } from '../renderer' @@ -200,4 +215,85 @@ describe('Hooks', () => { const output = renderVoltraVariantToJson() expect(output.c).toBe('2') }) + + test('16. use(context) reads context value', () => { + const ThemeContext = createContext('light') + const Consumer = () => { + const theme = use(ThemeContext) + return {theme} + } + const output = renderVoltraVariantToJson( + + + + ) + expect(output.c).toBe('dark') + }) + + test('17. use(context) without provider returns default', () => { + const ThemeContext = createContext('light') + const Consumer = () => { + const theme = use(ThemeContext) + return {theme} + } + const output = renderVoltraVariantToJson() + expect(output.c).toBe('light') + }) + + test('18. use(promise) throws', () => { + const Component = () => { + use(Promise.resolve('value')) + return test + } + expect(() => renderVoltraVariantToJson()).toThrow('use() with promises is not supported in Voltra') + }) + + test('19. use(unsupportedValue) throws', () => { + const Component = () => { + use('not a context or promise' as unknown as Usable) + return test + } + expect(() => renderVoltraVariantToJson()).toThrow('An unsupported type was passed to use()') + }) + + test('20. useActionState returns [initialState, dispatch, false]', () => { + const action = jest.fn() + let state, dispatch, isPending + const Component = () => { + ;[state, dispatch, isPending] = useActionState(action, { count: 0 }) + return {state.count} + } + const output = renderVoltraVariantToJson() + expect(output.c).toBe('0') + expect(state).toEqual({ count: 0 }) + expect(typeof dispatch).toBe('function') + expect(isPending).toBe(false) + dispatch() + expect(action).not.toHaveBeenCalled() + }) + + test('21. useOptimistic returns [passthrough, setter]', () => { + let value, setOptimistic + const Component = () => { + ;[value, setOptimistic] = useOptimistic('real') + return {value} + } + const output = renderVoltraVariantToJson() + expect(output.c).toBe('real') + expect(value).toBe('real') + expect(typeof setOptimistic).toBe('function') + setOptimistic('optimistic') + expect(value).toBe('real') + }) + + test('22. useEffectEvent returns callback identity', () => { + const fn = jest.fn() + let eventFn + const Component = () => { + eventFn = useEffectEvent(fn) + return test + } + renderVoltraVariantToJson() + expect(eventFn).toBe(fn) + }) }) diff --git a/src/renderer/dispatcher.ts b/src/renderer/dispatcher.ts index 36beb270..f6d31d4c 100644 --- a/src/renderer/dispatcher.ts +++ b/src/renderer/dispatcher.ts @@ -2,23 +2,34 @@ import React, { Context, ReactDispatcher, ReactHooksDispatcher } from 'react' import { ContextRegistry } from './context-registry.js' +const REACT_CONTEXT_TYPE = Symbol.for('react.context') +const REACT_MEMO_CACHE_SENTINEL = Symbol.for('react.memo_cache_sentinel') + declare module 'react' { + type HookFn = (...args: any[]) => any + export type ReactHooksDispatcher = { - useState: typeof import('react').useState - useReducer: typeof import('react').useReducer - useEffect: typeof import('react').useEffect - useLayoutEffect: typeof import('react').useLayoutEffect - useInsertionEffect: typeof import('react').useInsertionEffect - useCallback: typeof import('react').useCallback - useMemo: typeof import('react').useMemo - useRef: typeof import('react').useRef - useContext: typeof import('react').useContext - useId: typeof import('react').useId - useImperativeHandle: typeof import('react').useImperativeHandle - useDebugValue: typeof import('react').useDebugValue - useDeferredValue: typeof import('react').useDeferredValue - useTransition: typeof import('react').useTransition - useSyncExternalStore: typeof import('react').useSyncExternalStore + useState: HookFn + useReducer: HookFn + useEffect: HookFn + useLayoutEffect: HookFn + useInsertionEffect: HookFn + useCallback: HookFn + useMemo: HookFn + useRef: HookFn + useContext: HookFn + useId: HookFn + useImperativeHandle: HookFn + useDebugValue: HookFn + useDeferredValue: HookFn + useTransition: HookFn + useSyncExternalStore: HookFn + use: HookFn + useActionState: HookFn + useOptimistic: HookFn + useEffectEvent: HookFn + useMemoCache: HookFn + useCacheRefresh: HookFn } export type ReactDispatcher = { @@ -30,6 +41,27 @@ declare module 'react' { export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatcher => ({ useContext: (context: Context) => registry.readContext(context), + use: (usable: React.Usable): T => { + if ( + usable !== null && + typeof usable === 'object' && + (usable as { $$typeof?: symbol }).$$typeof === REACT_CONTEXT_TYPE + ) { + return registry.readContext(usable as unknown as Context) + } + + if ( + usable !== null && + (typeof usable === 'object' || typeof usable === 'function') && + typeof (usable as { then?: unknown }).then === 'function' + ) { + throw new Error( + 'use() with promises is not supported in Voltra. Async data fetching is not available in this synchronous renderer.' + ) + } + + throw new Error(`An unsupported type was passed to use(): ${String(usable)}`) + }, useState: (initial?: S | (() => S)) => [ typeof initial === 'function' ? (initial as () => S)() : initial, () => {}, // No-op setter @@ -58,6 +90,19 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc useSyncExternalStore: (_, getSnapshot) => { return getSnapshot() }, + // No-op stubs for React 19 hooks + useActionState: (_: unknown, initialState: S, _permalink?: string) => + [initialState, () => {}, false] as [S, () => void, boolean], + useOptimistic: (passthrough: T) => [passthrough, () => {}] as [T, (action: unknown) => void], + useEffectEvent: (callback: T): T => callback, + useMemoCache: (size: number) => { + const data = new Array(size) + for (let i = 0; i < size; i++) { + data[i] = REACT_MEMO_CACHE_SENTINEL + } + return data + }, + useCacheRefresh: () => () => {}, }) export const getReactCurrentDispatcher = (): ReactDispatcher => {