diff --git a/.changeset/polite-ligers-travel.md b/.changeset/polite-ligers-travel.md new file mode 100644 index 0000000..a43e6f4 --- /dev/null +++ b/.changeset/polite-ligers-travel.md @@ -0,0 +1,5 @@ +--- +'@unisonjs/vue': patch +--- + +Fix trigger of hook ref diff --git a/packages/vue/__tests__/toUnisonHook.spec.tsx b/packages/vue/__tests__/toUnisonHook.spec.tsx new file mode 100644 index 0000000..74bd0b9 --- /dev/null +++ b/packages/vue/__tests__/toUnisonHook.spec.tsx @@ -0,0 +1,215 @@ +import { render } from '@testing-library/react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { $unison, watchEffect, toUnisonHook, nextTick, ref } from '../src/index'; + +type Action = 'body' | 'effect (pre)' | 'effect (post)' | 'hook' | 'render'; + +describe('toUnisonHook test suite', () => { + it("should track react hook's result object", async () => { + function useBooleanValue() { + const [state, setState] = useState(false); + + const mutate = useCallback(() => { + setState((value) => !value); + }, []); + + return { value: state, mutate }; + } + + const booleanValue = toUnisonHook(useBooleanValue); + + let dummy; + const events: Action[] = []; + const Comp = $unison(() => { + const { value, mutate } = booleanValue(); + + watchEffect(() => { + dummy = value.value; + }); + return () => { + events.push('render'); + return ( + + ); + }; + }); + + const { container } = render(); + + const counter = container.querySelector('#action') as HTMLButtonElement; + + expect(dummy).toBe(false); + + counter.click(); + + await nextTick(); + expect(dummy).toBe(true); + expect(events).toEqual(['render', 'render']); + }); + + it('should update once', async () => { + function useBooleanValue() { + const [state, setState] = useState(false); + + const mutate = useCallback(() => { + setState((value) => !value); + }, []); + + return {}; + } + + const booleanValue = toUnisonHook(useBooleanValue, { shallow: true }); + + const events: Action[] = []; + const Comp = $unison(() => { + booleanValue(); + const counter = ref(0); + + return () => { + events.push('render'); + return ( + + ); + }; + }); + + const { container } = render(); + + const counter = container.querySelector('#action') as HTMLButtonElement; + + counter.click(); + await nextTick(); + expect(events).toEqual(['render', 'render']); + }); + it("should track react hook's result (shallow)", async () => { + function useValue() { + const [state, setState] = useState({ value: 1 }); + + useEffect(() => { + const timeout = setTimeout(() => void setState({ value: 2 }), 30); + + return () => clearTimeout(timeout); + }, []); + + return state; + } + + const value = toUnisonHook(useValue); + + let dummy; + const events: Action[] = []; + const VueComponent = $unison(() => { + const result = value(); + + watchEffect(() => { + dummy = result.value; + }); + + return () => { + events.push('render'); + return ; + }; + }); + + render(); + + expect(dummy.value).toBe(1); + + await new Promise((res) => setTimeout(res, 30)); + + expect(dummy.value).toBe(2); + expect(events).toEqual(['render', 'render']); + }); + + it("should not track react hook's result deeply (shallow)", async () => { + function useBooleanValue() { + const [state, setState] = useState(false); + + const mutate = useCallback(() => { + setState((value) => !value); + }, []); + + return useMemo(() => ({ value: state, mutate }), []); + } + + const booleanValue = toUnisonHook(useBooleanValue, { shallow: true }); + + let dummy; + const events: Action[] = []; + const VueComponent = $unison(() => { + const result = booleanValue(); + + watchEffect(() => { + events.push('effect (pre)'); + dummy = result.value.value; + }); + + return () => { + events.push('render'); + return ( + + ); + }; + }); + + const { container } = render(); + + const counter = container.querySelector('#action') as HTMLButtonElement; + + expect(dummy).toBeDefined(); + + let prev = dummy; + counter.click(); + await nextTick(); + + expect(dummy).toBe(prev); + expect(events).toEqual(['effect (pre)', 'render']); + }); + + it('should run hook when no effects is declared', async () => { + const events: Action[] = []; + function useBooleanValue() { + events.push('hook'); + const [state, setState] = useState(false); + + const mutate = useCallback(() => { + setState((value) => !value); + }, []); + + return { value: state, mutate }; + } + + const booleanValue = toUnisonHook(useBooleanValue); + + const Comp = $unison(() => { + const count = ref(0); + booleanValue(); + + function increment() { + count.value++; + } + return () => { + events.push('render'); + return ( + + ); + }; + }); + + const { container } = render(); + + const counter = container.querySelector('#action') as HTMLButtonElement; + + counter.click(); + await nextTick(); + expect(events).toEqual(['hook', 'render', 'hook', 'render']); + }); +}); diff --git a/packages/vue/src/react-hook/hookRef.ts b/packages/vue/src/react-hook/hookRef.ts index a85d40b..9ccfb23 100644 --- a/packages/vue/src/react-hook/hookRef.ts +++ b/packages/vue/src/react-hook/hookRef.ts @@ -1,7 +1,7 @@ import { ReactiveFlags, TrackOpTypes } from '../reactivity/constants'; -import { type HookManager, currentListener, Dep, Listener } from '@unisonjs/core'; +import { type HookManager, BaseSignal, currentListener, Dep, Listener } from '@unisonjs/core'; -class HookRef { +class HookRef extends BaseSignal { /** * @internal */ @@ -36,6 +36,7 @@ class HookRef { public readonly [ReactiveFlags.IS_READONLY]: boolean = true; constructor(manager: HookManager, hookIndex: number, valueIndex: number) { + super(); this.#manager = manager; this.#hookIndex = hookIndex; this.#valueIndex = valueIndex; @@ -59,6 +60,15 @@ class HookRef { return currentValue as T; } + + trigger() { + const {listeners} = this; + if (listeners) { + for (const listener of listeners) listener.trigger?.(); + listeners.length = 0; + } + this.dep.trigger() + } } export default HookRef;