From 7c7cbf01c97b1f1a92371862d10c33065a7e9c79 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 13 Feb 2026 09:48:49 +0100 Subject: [PATCH 1/3] feat: add missing React 19 hooks to renderer dispatcher Co-authored-by: Cursor --- src/renderer/__tests__/hooks.node.test.tsx | 98 +++++++++++++++++++++- src/renderer/dispatcher.ts | 57 +++++++++++-- 2 files changed, 147 insertions(+), 8 deletions(-) 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..8838bb82 100644 --- a/src/renderer/dispatcher.ts +++ b/src/renderer/dispatcher.ts @@ -2,6 +2,9 @@ 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' { export type ReactHooksDispatcher = { useState: typeof import('react').useState @@ -19,6 +22,12 @@ declare module 'react' { useDeferredValue: typeof import('react').useDeferredValue useTransition: typeof import('react').useTransition useSyncExternalStore: typeof import('react').useSyncExternalStore + use: typeof import('react').use + useActionState: typeof import('react').useActionState + useOptimistic: typeof import('react').useOptimistic + useEffectEvent: typeof import('react').useEffectEvent + useMemoCache: (size: number) => unknown[] + useCacheRefresh: (...args: unknown[]) => unknown } export type ReactDispatcher = { @@ -30,9 +39,30 @@ 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 + () => { }, // No-op setter ], useReducer: ( _: (prevState: S, ...args: A) => S, @@ -40,24 +70,37 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc init?: (i: I) => S ): [S, React.ActionDispatch] => { const state = init ? init(initialArg) : initialArg - return [state as S, () => {}] + return [state as S, () => { }] }, // Direct pass-throughs useMemo: (factory) => factory(), useCallback: (cb) => cb, useRef: (initial) => ({ current: initial }), // No-ops for effects - useEffect: () => {}, - useLayoutEffect: () => {}, - useInsertionEffect: () => {}, + useEffect: () => { }, + useLayoutEffect: () => { }, + useInsertionEffect: () => { }, useId: () => Math.random().toString(36).substr(2, 9), - useDebugValue: () => {}, - useImperativeHandle: () => {}, + useDebugValue: () => { }, + useImperativeHandle: () => { }, useDeferredValue: (value: T) => value, useTransition: () => [false, (func: () => void) => func()], 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 => { From a0b8010071baaeff7c9b51b4ac60604cd114bb29 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 13 Feb 2026 09:55:29 +0100 Subject: [PATCH 2/3] fix: types --- src/renderer/dispatcher.ts | 44 ++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/renderer/dispatcher.ts b/src/renderer/dispatcher.ts index 8838bb82..ab225e04 100644 --- a/src/renderer/dispatcher.ts +++ b/src/renderer/dispatcher.ts @@ -6,28 +6,30 @@ 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 - use: typeof import('react').use - useActionState: typeof import('react').useActionState - useOptimistic: typeof import('react').useOptimistic - useEffectEvent: typeof import('react').useEffectEvent - useMemoCache: (size: number) => unknown[] - useCacheRefresh: (...args: unknown[]) => unknown + 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 = { From 82b421d11082ac940d42c7b2c7daf9d3dd0b2377 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 13 Feb 2026 10:04:40 +0100 Subject: [PATCH 3/3] fix: lint --- src/renderer/dispatcher.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/renderer/dispatcher.ts b/src/renderer/dispatcher.ts index ab225e04..f6d31d4c 100644 --- a/src/renderer/dispatcher.ts +++ b/src/renderer/dispatcher.ts @@ -64,7 +64,7 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc }, useState: (initial?: S | (() => S)) => [ typeof initial === 'function' ? (initial as () => S)() : initial, - () => { }, // No-op setter + () => {}, // No-op setter ], useReducer: ( _: (prevState: S, ...args: A) => S, @@ -72,19 +72,19 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc init?: (i: I) => S ): [S, React.ActionDispatch] => { const state = init ? init(initialArg) : initialArg - return [state as S, () => { }] + return [state as S, () => {}] }, // Direct pass-throughs useMemo: (factory) => factory(), useCallback: (cb) => cb, useRef: (initial) => ({ current: initial }), // No-ops for effects - useEffect: () => { }, - useLayoutEffect: () => { }, - useInsertionEffect: () => { }, + useEffect: () => {}, + useLayoutEffect: () => {}, + useInsertionEffect: () => {}, useId: () => Math.random().toString(36).substr(2, 9), - useDebugValue: () => { }, - useImperativeHandle: () => { }, + useDebugValue: () => {}, + useImperativeHandle: () => {}, useDeferredValue: (value: T) => value, useTransition: () => [false, (func: () => void) => func()], useSyncExternalStore: (_, getSnapshot) => { @@ -92,8 +92,8 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc }, // 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], + [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) @@ -102,7 +102,7 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc } return data }, - useCacheRefresh: () => () => { }, + useCacheRefresh: () => () => {}, }) export const getReactCurrentDispatcher = (): ReactDispatcher => {