// Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import { beforeEach, afterEach, vi } from 'vitest'; import { globalEnvManager } from '../../snapshot/utils/envManager'; import { describe } from 'vitest'; import { it } from 'vitest'; import { expect } from 'vitest'; import { beforeAll } from 'vitest'; import { replaceCommitHook } from '../../../src/snapshot/lifecycle/patch/commit'; import { elementTree } from '../../snapshot/utils/nativeMethod'; import { __root } from '../../../src/root'; import { installMainThreadHooks, useState, useMemo, useEffect, useLayoutEffect, useImperativeHandle, useRef, useCallback, useDebugValue, useId, useErrorBoundary, useContext, } from '../../../src/core/hooks/mainThread'; import { action, batch, computed, createModel, effect, Signal, signal, untracked, useComputed, useModel, useSignal, useSignalEffect, } from '@lynx-js/react-signals/lepus'; import { options, createContext } from 'preact'; import { DIFF, HOOK } from '../../../src/shared/render-constants.js'; beforeAll(() => { replaceCommitHook(); }); beforeEach(() => { globalEnvManager.resetEnv(); }); afterEach(() => { elementTree.clear(); vi.resetModules(); vi.restoreAllMocks(); globalThis.__GLOBAL_PROPS_MODE__ = 'reactive'; }); describe('mainThread hooks', () => { it('should skip duplicate installation', () => { const diffHook = options[DIFF]; installMainThreadHooks(); expect(options[DIFF]).toBe(diffHook); }); it('should get initialValue', () => { let setCount; options[HOOK] = vi.fn(); options.useDebugValue = vi.fn(); lynx.reportError = (e) => { console.error('Error boundary caught error', e); }; const ThemeContext = createContext(); const App = () => { return ( ); }; const Comp = () => { const [count, _setCount] = useState(0); const [content] = useState(() => 'hello'); const memoCount = useMemo(() => count, [count]); useEffect(() => {}, []); useLayoutEffect(() => {}, []); useImperativeHandle(null, () => ({})); const ref = useRef(null); const handleTap = useCallback(() => { setCount(count + 1); }, []); useDebugValue(count); useErrorBoundary(() => {}); const id = useId(); const contextValue = useContext(ThemeContext); setCount = _setCount; return ( <> {count}-{content}-{memoCount}-{contextValue}-{id} ); }; const SubComp = () => { const id = useId(); return SubComp-{id}; }; // main thread render { __root.__jsx = ; renderPage(); expect(__root.__element_root).toMatchInlineSnapshot(` `); expect(options[HOOK]).toBeCalledTimes(9); // useState expect(options[HOOK]).toHaveBeenNthCalledWith(1, expect.anything(), 0, 1); expect(options[HOOK]).toHaveBeenNthCalledWith(2, expect.anything(), 1, 1); // useMemo expect(options[HOOK]).toHaveBeenNthCalledWith(3, expect.anything(), 2, 7); // useRef expect(options[HOOK]).toHaveBeenNthCalledWith(4, expect.anything(), 3, 5); // useCallback expect(options[HOOK]).toHaveBeenNthCalledWith(5, expect.anything(), 4, 8); // useErrorBoundary expect(options[HOOK]).toHaveBeenNthCalledWith(6, expect.anything(), 5, 10); // useId expect(options[HOOK]).toHaveBeenNthCalledWith(7, expect.anything(), 6, 11); // useContext expect(options[HOOK]).toHaveBeenNthCalledWith(8, expect.anything(), 7, 9); // useDebugValue expect(options.useDebugValue).toBeCalledWith(0); // SubComp useId expect(options[HOOK]).toHaveBeenNthCalledWith(9, expect.anything(), 0, 11); console.error = vi.fn(); setCount(1); expect(console.error).toBeCalledWith('Cannot update state in main thread!'); } }); it('should keep signals static and effects inactive', () => { const signalEffect = vi.fn(); const subscriber = vi.fn(); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); const count = signal(1, { name: 'count' }); const doubled = computed(() => count.value * 2, { name: 'doubled' }); const unsubscribe = count.subscribe(subscriber); const unsubscribeComputed = doubled.subscribe(subscriber); const dispose = effect(signalEffect); const batchResult = batch(() => { count.value = 2; return 'batch-result'; }); expect(batchResult).toBe('batch-result'); expect(count.name).toBe('count'); expect(count.value).toBe(1); expect(count.peek()).toBe(1); expect(count.valueOf()).toBe(1); expect(count.toString()).toBe('1'); expect(count.toJSON()).toBe(1); expect(doubled.name).toBe('doubled'); expect(doubled.value).toBe(2); expect(doubled.peek()).toBe(2); expect(doubled.valueOf()).toBe(2); expect(doubled.toString()).toBe('2'); expect(doubled.toJSON()).toBe(2); expect(untracked(() => count.value)).toBe(1); expect(new Signal(5).value).toBe(5); expect(signal().value).toBeUndefined(); expect(signalEffect).not.toHaveBeenCalled(); expect(subscriber).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith( 'Cannot update signal in main thread!', ); dispose(); unsubscribe(); unsubscribeComputed(); let localSignal; let localComputed; let emptySignal; let localModel; const hookEffect = vi.fn(); const increment = action((value: number) => value + 1); const Counter = createModel(() => ({ count: signal(7) })); function App() { localSignal = useSignal(3, { name: 'local' }); localComputed = useComputed(() => localSignal.value * 2); emptySignal = useSignal(); localModel = useModel(Counter); useSignalEffect(hookEffect); return {localSignal.value}-{localComputed.value}; } __root.__jsx = ; renderPage(); expect(localSignal.value).toBe(3); expect(localComputed.value).toBe(6); expect(emptySignal.value).toBeUndefined(); expect(localModel.count.value).toBe(7); expect(increment(1)).toBe(2); expect(hookEffect).not.toHaveBeenCalled(); localSignal.value = 4; expect(localSignal.value).toBe(3); }); });