Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion src/renderer/__tests__/hooks.node.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -200,4 +215,85 @@ describe('Hooks', () => {
const output = renderVoltraVariantToJson(<Component />)
expect(output.c).toBe('2')
})

test('16. use(context) reads context value', () => {
const ThemeContext = createContext('light')
const Consumer = () => {
const theme = use(ThemeContext)
return <Text>{theme}</Text>
}
const output = renderVoltraVariantToJson(
<ThemeContext.Provider value="dark">
<Consumer />
</ThemeContext.Provider>
)
expect(output.c).toBe('dark')
})

test('17. use(context) without provider returns default', () => {
const ThemeContext = createContext('light')
const Consumer = () => {
const theme = use(ThemeContext)
return <Text>{theme}</Text>
}
const output = renderVoltraVariantToJson(<Consumer />)
expect(output.c).toBe('light')
})

test('18. use(promise) throws', () => {
const Component = () => {
use(Promise.resolve('value'))
return <Text>test</Text>
}
expect(() => renderVoltraVariantToJson(<Component />)).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<unknown>)
return <Text>test</Text>
}
expect(() => renderVoltraVariantToJson(<Component />)).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 <Text>{state.count}</Text>
}
const output = renderVoltraVariantToJson(<Component />)
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 <Text>{value}</Text>
}
const output = renderVoltraVariantToJson(<Component />)
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 <Text>test</Text>
}
renderVoltraVariantToJson(<Component />)
expect(eventFn).toBe(fn)
})
})
75 changes: 60 additions & 15 deletions src/renderer/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -30,6 +41,27 @@ declare module 'react' {

export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatcher => ({
useContext: <T>(context: Context<T>) => registry.readContext(context),
use: <T>(usable: React.Usable<T>): T => {
if (
usable !== null &&
typeof usable === 'object' &&
(usable as { $$typeof?: symbol }).$$typeof === REACT_CONTEXT_TYPE
) {
return registry.readContext(usable as unknown as Context<T>)
}

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: <S>(initial?: S | (() => S)) => [
typeof initial === 'function' ? (initial as () => S)() : initial,
() => {}, // No-op setter
Expand Down Expand Up @@ -58,6 +90,19 @@ export const getHooksDispatcher = (registry: ContextRegistry): ReactHooksDispatc
useSyncExternalStore: (_, getSnapshot) => {
return getSnapshot()
},
// No-op stubs for React 19 hooks
useActionState: <S>(_: unknown, initialState: S, _permalink?: string) =>
[initialState, () => {}, false] as [S, () => void, boolean],
useOptimistic: <T>(passthrough: T) => [passthrough, () => {}] as [T, (action: unknown) => void],
useEffectEvent: <T extends Function>(callback: T): T => callback,
useMemoCache: (size: number) => {
const data = new Array<unknown>(size)
for (let i = 0; i < size; i++) {
data[i] = REACT_MEMO_CACHE_SENTINEL
}
return data
},
useCacheRefresh: () => () => {},
})

export const getReactCurrentDispatcher = (): ReactDispatcher => {
Expand Down
Loading