From 64b7990976a6462ba3dbb2b5e3be580b6bf753d0 Mon Sep 17 00:00:00 2001 From: NGBAMA William Date: Sat, 31 Jan 2026 03:50:56 +0100 Subject: [PATCH 1/2] add tests for Vue composition API compliance on react hooks --- .../features/compatibility.spec.ts | 387 ++++++ .../features/compositions.spec.ts | 300 ++++ .../hook-manager/tracking-manager.spec.ts | 1225 +++++++++++++++++ .../tracking-types/function.spec.ts | 142 ++ .../tracking-types/object.spec.ts | 536 ++++++++ .../tracking-types/reactive.spec.ts | 167 +++ .../hook-manager/tracking-types/ref.spec.ts | 165 +++ .../tracking-types/shallow-object.spec.ts | 433 ++++++ .../tracking-types/shallow-reactive.spec.ts | 274 ++++ .../tracking-types/shallow-ref.spec.ts | 343 +++++ .../tracking-types/shallow.spec.ts | 382 +++++ .../tracking-types/to-refs.spec.ts | 180 +++ 12 files changed, 4534 insertions(+) create mode 100644 packages/vue/__tests__/hook-manager/features/compatibility.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/features/compositions.spec.ts create mode 100755 packages/vue/__tests__/hook-manager/tracking-manager.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/function.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/object.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/reactive.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/ref.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/shallow-object.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/shallow-reactive.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/shallow-ref.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/shallow.spec.ts create mode 100644 packages/vue/__tests__/hook-manager/tracking-types/to-refs.spec.ts diff --git a/packages/vue/__tests__/hook-manager/features/compatibility.spec.ts b/packages/vue/__tests__/hook-manager/features/compatibility.spec.ts new file mode 100644 index 0000000..1f36cb0 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/features/compatibility.spec.ts @@ -0,0 +1,387 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl, t } from '../../../src/hook-manager'; +import { isShallow } from '../../../src'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; + +describe('Type Compatibility and Fallbacks', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + describe('High Priority Fallbacks', () => { + it('should fallback t.toRefs() on primitive to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Type incompatibility')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('toRefs')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('primitive')); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.reactive() on primitive to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 'string'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('reactive')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('primitive')); + expect(isRef(result)).toBe(true); + expect(result.value).toBe('string'); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.shallowReactive() on primitive to shallowRef() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => true); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('shallowReactive')); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.object() on primitive to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 123); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('object')); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(123); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.array() on object to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ key: 'value' })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.array() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('array')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('object')); + expect(isRef(result)).toBe(true); + expect(result.value).toEqual({ key: 'value' }); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.objectShallow() on primitive to shallowRef() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.objectShallow() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('objectShallow')); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.arrayShallow() on object to shallowRef() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ not: 'array' })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.arrayShallow() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('arrayShallow')); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.function() on non-function to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ key: 'value' })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('function')); + expect(isRef(result)).toBe(true); + expect(result.value).toEqual({ key: 'value' }); + + consoleSpy.mockRestore(); + }); + + it('should verify fallback suggestion mentions t.ref()', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + useTestHook(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('t.ref()')); + + consoleSpy.mockRestore(); + }); + + it('should verify fallback suggestion mentions t.shallow()', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.objectShallow() }); + + useTestHook(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('t.shallowRef()')); + + consoleSpy.mockRestore(); + }); + }); + + describe('Medium Priority Fallbacks', () => { + it('should handle type changes during execution', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const source = ref({ name: 'Alice' }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isReactive(result)).toBe(true); + + // Change to primitive + source.value = 42; + hookManager.processHookAt(0); + await nextTick(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Type changed')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('object')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('primitive')); + + consoleSpy.mockRestore(); + }); + + it('should adapt behavior to new type after change', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const source = ref([1, 2, 3]); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { tracking: t.array() }); + + const result = useTestHook(source); + + expect(isReactive(result)).toBe(true); + expect(Array.isArray(result)).toBe(true); + + // Change to object + source.value = { key: 'value' }; + hookManager.processHookAt(0); + await nextTick(); + + expect(consoleSpy).toHaveBeenCalled(); + // Should fallback to ref with reactive object + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.toRefs() on function to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('toRefs')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('function')); + expect(isRef(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should warn on multiple type changes', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const source = ref({ name: 'Alice' }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + useTestHook(source); + + // First change: object -> primitive + source.value = 42; + hookManager.processHookAt(0); + await nextTick(); + + const firstCallCount = consoleSpy.mock.calls.length; + expect(firstCallCount).toBeGreaterThan(0); + + // Second change: primitive -> array + source.value = [1, 2, 3]; + hookManager.processHookAt(0); + await nextTick(); + + expect(consoleSpy.mock.calls.length).toBeGreaterThan(firstCallCount); + + consoleSpy.mockRestore(); + }); + }); + + describe('Edge Case Fallbacks', () => { + it('should NOT warn on t.object() with function (compatible)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + fn.customProp = 'value'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isReactive(result)).toBe(true); + expect(result.customProp).toBe('value'); + + consoleSpy.mockRestore(); + }); + + it('should NOT warn on t.objectShallow() with function (compatible)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + fn.prop1 = 'a'; + fn.prop2 = 'b'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.objectShallow() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should handle null with t.ref() without warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should handle undefined with t.ref() without warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(undefined); + + consoleSpy.mockRestore(); + }); + + it('should handle null with t.shallowRef() without warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.reactive() on null to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should fallback t.reactive() on undefined to ref() with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(undefined); + + consoleSpy.mockRestore(); + }); + + it('should handle empty object with any tracking type', () => { + const mockHook = vi.fn(() => ({})); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(Object.keys(result)).toEqual([]); + }); + + it('should handle empty array with any tracking type', () => { + const mockHook = vi.fn(() => []); + const useTestHook = toUnisonHook(mockHook, { tracking: t.array() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/features/compositions.spec.ts b/packages/vue/__tests__/hook-manager/features/compositions.spec.ts new file mode 100644 index 0000000..dab4d75 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/features/compositions.spec.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl, t } from '../../../src/hook-manager'; +import { isShallow } from '../../../src'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; + + +describe('Type Compositions', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + describe('Single-level valid compositions', () => { + it('should compose t.ref(t.reactive())', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.ref(t.reactive()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(false); + expect(result.value.name).toBe('Alice'); + }); + + it('should compose t.shallowRef(t.reactive())', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.shallowRef(t.reactive()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(false); + }); + + it('should compose t.ref(t.shallowReactive())', () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.ref(t.shallowReactive()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(true); + expect(isReactive(result.value.user)).toBe(false); + }); + + it('should compose t.ref(t.object())', () => { + const mockHook = vi.fn(() => ({ name: 'Alice' })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.ref(t.object()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(false); + }); + + it('should compose t.ref(t.objectShallow())', () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.ref(t.shallowObject()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(true); + expect(isReactive(result.value.user)).toBe(false); + }); + + it('should compose t.shallowRef(t.object())', () => { + const mockHook = vi.fn(() => ({ + nested: { value: 42 }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.shallowRef(t.object()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(false); + }); + + it('should compose t.shallowRef(t.objectShallow())', () => { + const mockHook = vi.fn(() => ({ + nested: { value: 42 }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.shallowRef(t.shallowObject()), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(isShallow(result.value)).toBe(true); + }); + + it('should maintain reactivity through composition', async () => { + const source = ref({ count: 1 }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.ref(t.reactive()), + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.value.count; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { count: 2 }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + }); + + describe('Multi-level compositions', () => { + + it('should compose with multiple nested fields', () => { + const mockHook = vi.fn(() => ({ + shallow: { value: 1 }, + deep: { nested: { value: 2 } }, + primitive: 42, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.toRefs({ + shallow: t.shallowReactive(), + deep: t.reactive(), + primitive: t.ref(), + }), + }); + + const result = useTestHook(); + + expect(isRef(result.shallow)).toBe(true); + expect(isReactive(result.shallow.value)).toBe(true); + expect(isShallow(result.shallow.value)).toBe(true); + + expect(isRef(result.deep)).toBe(true); + expect(isReactive(result.deep.value)).toBe(true); + expect(isShallow(result.deep.value)).toBe(false); + + expect(isRef(result.primitive)).toBe(true); + expect(result.primitive.value).toBe(42); + }); + + it('should compose t.shallowRef(t.array({ items: t.shallowReactive() }))', () => { + const mockHook = vi.fn(() => [{ items: [{ id: 1 }] }, { items: [{ id: 2 }] }]); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.shallowRef( + t.array({ + // Array items have specific config + 0: t.shallowReactive(), + 1: t.shallowReactive(), + }), + ), + }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + }); + + it('should handle deep nested config structures', () => { + const mockHook = vi.fn(() => ({ + data: { + user: { + profile: { + settings: { theme: 'dark' }, + }, + }, + }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: { + user: { + profile: { + settings: t.shallowReactive(), + }, + }, + }, + }, + }); + + const result = useTestHook(); + + expect(isRef(result.data)).toBe(true); + expect(isReactive(result.data.value)).toBe(true); + expect(isReactive(result.data.value.user)).toBe(true); + expect(isReactive(result.data.value.user.profile)).toBe(true); + expect(isReactive(result.data.value.user.profile.settings)).toBe(true); + expect(isShallow(result.data.value.user.profile.settings)).toBe(true); + }); + }); + + describe('Invalid compositions', () => { + it('should warn and ignore inner when composing t.reactive(t.ref())', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ name: 'Alice' })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.reactive(t.ref()), + }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('invalid')); + // Should just use t.reactive() and ignore the inner t.ref() + expect(isReactive(result)).toBe(true); + expect(isRef(result)).toBe(false); + + consoleSpy.mockRestore(); + }); + + it('should warn and ignore inner when composing t.shallowReactive(t.shallowRef())', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ name: 'Alice' })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.shallowReactive(t.shallowRef()), + }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + // Should just use t.shallowReactive() and ignore inner + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isRef(result)).toBe(false); + + consoleSpy.mockRestore(); + }); + + it('should warn on nested invalid composition t.reactive(t.ref(t.object()))', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ name: 'Alice' })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.reactive(t.ref(t.object())), + }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + // Should warn about the invalid inner composition + expect(isReactive(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should warn on t.object(t.reactive())', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ name: 'Alice' })); + const useTestHook = toUnisonHook(mockHook, { + tracking: t.object(t.reactive()), + }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + // Should warn but still work (t.object is like t.reactive anyway) + expect(isReactive(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-manager.spec.ts b/packages/vue/__tests__/hook-manager/tracking-manager.spec.ts new file mode 100755 index 0000000..e554a3f --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-manager.spec.ts @@ -0,0 +1,1225 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, watchSyncEffect, nextTick, watchEffect, computed, shallowRef } from '../../src'; +import { t, toUnisonHook as toUnisonHookImpl } from '../../src/hook-manager'; +import { HookManager } from '../../src/hook-manager/hook-manager'; + +describe('HookManager - Tracking Requirements', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + describe('Object Reference Tracking', () => { + it('should not track object reference changes', async () => { + // Create a data source that can be changed + const dataSource = ref({ name: 'John', age: 30 }); + + // Mock hook that reads from the data source + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + + let effectRuns = 0; + const result = useTestHook(dataSource); + + watchEffect(() => { + result.name; // Access property to trigger tracking + effectRuns++; + }); + + // Change to different object with same content + dataSource.value = { name: 'John', age: 30 }; // Different reference, same data + hookManager.processHookAt(0); // This will re-run the hook with new data + + expect(effectRuns).toBe(1); // Should not retrigger for reference change + }); + + it('should track object property changes at various depths', async () => { + const dataSource = ref({ + user: { + profile: { + name: 'John', + settings: { + theme: 'dark', + }, + }, + }, + }); + + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + + let effectRuns = 0; + const result = useTestHook(dataSource); + + watchEffect(() => { + result.user.value.profile.settings.theme; // Deep access + effectRuns++; + }); + + // Change deep property value + dataSource.value = { + user: { + profile: { + name: 'John', + settings: { + theme: 'light', // Changed value + }, + }, + }, + }; + + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); // Should retrigger for value change + }); + }); + + describe('Path Tracking Specificity', () => { + it('should not track non-accessed paths', async () => { + const dataSource = ref({ + trackedField: 'initial', + untrackedField: 'initial', + }); + + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + + let effectRuns = 0; + const result = useTestHook(dataSource); + + watchEffect(() => { + result.trackedField.value; // Only access this field + effectRuns++; + }); + + // Change only the untracked field + dataSource.value = { + trackedField: 'initial', // Same + untrackedField: 'changed', // Different, but not tracked + }; + + hookManager.processHookAt(0); + await nextTick(); + expect(effectRuns).toBe(1); // Should not retrigger + + // Now change the tracked field + dataSource.value = { + trackedField: 'changed', // Different and tracked + untrackedField: 'changed', + }; + + console.log('trackedField :', result.trackedField.value); + console.log('untrackedField :', result.untrackedField.value); + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); // Should retrigger + }); + + it('should only track requested paths within effects', async () => { + const dataSource = ref({ + a: { value: 1 }, + b: { value: 2 }, + c: { value: 3 }, + }); + + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + let effect1Runs = 0; + let effect2Runs = 0; + + // Effect 1 only tracks 'a' + watchEffect(() => { + result.a.value.value; + effect1Runs++; + }); + + // Effect 2 only tracks 'b' + watchEffect(() => { + result.b.value.value; + effect2Runs++; + }); + + // Change 'a' only + dataSource.value = { + a: { value: 10 }, // Changed + b: { value: 2 }, // Same + c: { value: 3 }, // Same + }; + + hookManager.processHookAt(0); + await nextTick(); + + expect(effect1Runs).toBe(2); // Should retrigger + expect(effect2Runs).toBe(1); // Should not retrigger + }); + }); + + describe('Reactive Parameters', () => { + it('should re-run hook when reactive parameters change', async () => { + const query = ref('initial-query'); + let hookCallCount = 0; + + // Mock hook that depends on reactive parameter + const mockHook = vi.fn((queryParam) => { + hookCallCount++; + return { data: `result-for-${queryParam}`, query: queryParam }; + }); + + const useTestHook = toUnisonHook(mockHook); + let result; + watchEffect(() => { + result = useTestHook(query); + }); + expect(hookCallCount).toBe(1); + expect(result.data.value).toBe('result-for-initial-query'); + + // Change reactive parameter + query.value = 'updated-query'; + hookManager.processHookAt(0); // This should re-run the hook + await nextTick(); + + expect(hookCallCount).toBe(2); + expect(result.data.value).toBe('result-for-updated-query'); + }); + + it('should support multiple reactive parameters', async () => { + const param1 = ref('a'); + const param2 = ref(1); + let hookCallCount = 0; + + const mockHook = vi.fn((p1, p2) => { + hookCallCount++; + return { combined: `${p1}-${p2}` }; + }); + + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(param1, param2); + + expect(hookCallCount).toBe(1); + expect(result.combined.value).toBe('a-1'); + + // Change first parameter + param1.value = 'b'; + hookManager.processHookAt(0); + await nextTick(); + + expect(hookCallCount).toBe(2); + expect(result.combined.value).toBe('b-1'); + + // Change second parameter + param2.value = 2; + hookManager.processHookAt(0); + await nextTick(); + + expect(hookCallCount).toBe(3); + expect(result.combined.value).toBe('b-2'); + }); + }); + + describe('Function Result Handling', () => { + it('should return a function that points to up-to-date result when hook returns function', async () => { + const counter = ref(0); + + // Hook returns a function that captures current counter value + const mockHook = vi.fn((currentCount) => { + return () => `count-is-${currentCount}`; + }); + + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(counter); + + expect(typeof result).toBe('function'); + expect(result()).toBe('count-is-0'); + + // Update counter + counter.value = 5; + hookManager.processHookAt(0); + await nextTick(); + + // Should use the updated function + expect(result()).toBe('count-is-5'); + }); + + it('should not create refs for function results', () => { + const mockHook = vi.fn(() => () => 'test'); + const useTestHook = toUnisonHook(mockHook); + + const result = useTestHook(); + + expect(typeof result).toBe('function'); + expect(result.value).toBeUndefined(); // Should not be a ref + }); + }); + + describe('Array Result Handling', () => { + it('should create a ref with array as value, not refs for each field', () => { + const items = ref(['a', 'b', 'c']); + const mockHook = vi.fn((itemsParam) => itemsParam); + + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(items); + + // Should be able to access array directly + console.log(result.value.map((value) => value + '1')); + expect(Array.isArray(result.value)).toBe(true); + expect(result.value).toEqual(['a', 'b', 'c']); + + // Should not have individual refs for indices + expect(result[0]).toBeUndefined(); + expect(result[1]).toBeUndefined(); + expect(result[2]).toBeUndefined(); + }); + + it('should allow deep tracking on arrays while ignoring reference changes', async () => { + const data = ref([ + { id: 1, name: 'John' }, + { id: 2, name: 'Jane' }, + ]); + + const mockHook = vi.fn((dataParam) => dataParam); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + let effectRuns = 0; + const result = useTestHook(data); + + watchEffect(() => { + result.value[0].name; // Deep access into array + effectRuns++; + }); + + // Change array content (same structure, different values) + data.value = [ + { id: 1, name: 'Johnny' }, // Changed value + { id: 2, name: 'Jane' }, + ]; + + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); // Should track deep changes + }); + }); + + describe('Primitive Result Handling', () => { + it('should create a ref for primitive results', async () => { + const mockHook = vi.fn(() => 'test string'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(result.value).toBe('test string'); + }); + + it('should create a ref for null results', () => { + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(result.value).toBe(null); + }); + + it('should update ref value when primitive result changes', async () => { + const input = ref(42); + const mockHook = vi.fn((inputParam) => inputParam * 2); + + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(input); + + expect(result.value).toBe(84); + + input.value = 50; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.value).toBe(100); + }); + }); + + describe('Synchronous Effect Updates', () => { + it('should provide up-to-date results in sync effects after hook execution', () => { + const count = ref(0); + const mockHook = vi.fn((countParam) => ({ count: countParam })); + + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(count); + + let capturedValue; + + watchSyncEffect(() => { + capturedValue = result.count.value; + }); + + expect(capturedValue).toBe(0); + + // Update input + count.value = 5; + hookManager.processHookAt(0); + + // Sync effect should see the updated value immediately + expect(capturedValue).toBe(5); + }); + }); + + describe('Edge Cases', () => { + it('should handle undefined/null hook results', async () => { + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook); + + const result = useTestHook(); + expect(result.value).toBeUndefined(); + }); + + it('should handle hooks that throw errors', () => { + const shouldThrow = ref(false); + const mockHook = vi.fn((throwFlag) => { + if (throwFlag) throw new Error('Hook error'); + return { success: true }; + }); + + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(shouldThrow); + + expect(result.success.value).toBe(true); + + // This should be handled gracefully by your error boundaries + shouldThrow.value = true; + expect(() => hookManager.processHookAt(0)).toThrow('Hook error'); + }); + }); +}); + +describe('HookManager - Iteration-Based Operations', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + describe('for...of iteration with nested access', () => { + it('should track both structure changes and nested property access', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + const names = []; + + watchEffect(() => { + names.length = 0; + for (const item of result.value) { + names.push(item.name); // Nested property access + } + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(names).toEqual(['Alice', 'Bob']); + + // Change nested property - should trigger + dataSource.value = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, // name changed + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(names).toEqual(['Alice', 'Bobby']); + + // Add item - should trigger (structure change) + dataSource.value = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + { id: 3, name: 'Charlie' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(3); + expect(names).toEqual(['Alice', 'Bobby', 'Charlie']); + + // Remove item - should trigger (structure change) + dataSource.value = [{ id: 1, name: 'Alice' }]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(4); + expect(names).toEqual(['Alice']); + }); + + it('should not trigger when non-accessed properties change', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice', age: 25 }, + { id: 2, name: 'Bob', age: 30 }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + + watchEffect(() => { + for (const item of result.value) { + item.name; // Only access name, not age + } + fn(); + }); + + // Change age (not tracked) - should NOT trigger + dataSource.value = [ + { id: 1, name: 'Alice', age: 26 }, // age changed + { id: 2, name: 'Bob', age: 31 }, // age changed + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(1); // Should NOT retrigger + + // Change name (tracked) - should trigger + dataSource.value = [ + { id: 1, name: 'Alicia', age: 26 }, // name changed + { id: 2, name: 'Bob', age: 31 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); // Should retrigger once + }); + + it('should track iteration without nested access', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + + watchEffect(() => { + for (const item of result.value) { + // Just iterate, don't access properties + } + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + + // Change property - should NOT trigger (not accessed) + dataSource.value = [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bobby' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(1); // Should NOT retrigger + + // Add item - should trigger (structure change) + dataSource.value = [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bobby' }, + { id: 3, name: 'Charlie' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); // Should retrigger + }); + }); + + describe('for...in iteration', () => { + it('should track object key iteration with nested access', async () => { + const dataSource = shallowRef({ + user1: { name: 'Alice', role: 'admin' }, + user2: { name: 'Bob', role: 'user' }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + const names = []; + + watchEffect(() => { + names.length = 0; + for (const key in result.value) { + names.push(result.value[key].name); + } + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(names).toEqual(['Alice', 'Bob']); + + // Change nested property - should trigger + dataSource.value = { + user1: { name: 'Alicia', role: 'admin' }, + user2: { name: 'Bob', role: 'user' }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(names).toEqual(['Alicia', 'Bob']); + + // Add new key - should trigger (structure change) + dataSource.value = { + user1: { name: 'Alicia', role: 'admin' }, + user2: { name: 'Bob', role: 'user' }, + user3: { name: 'Charlie', role: 'guest' }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(3); + expect(names).toEqual(['Alicia', 'Bob', 'Charlie']); + + // Remove key - should trigger (structure change) + dataSource.value = { + user1: { name: 'Alicia', role: 'admin' }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(4); + expect(names).toEqual(['Alicia']); + }); + + it('should not trigger when non-accessed nested properties change', async () => { + const dataSource = ref({ + user1: { name: 'Alice', role: 'admin', age: 25 }, + user2: { name: 'Bob', role: 'user', age: 30 }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + + watchEffect(() => { + for (const key in result.value) { + result.value[key].name; // Only access name + } + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + + // Change age (not tracked) - should NOT trigger + dataSource.value = { + user1: { name: 'Alice', role: 'admin', age: 26 }, + user2: { name: 'Bob', role: 'user', age: 31 }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(1); // Should NOT retrigger + }); + }); + + describe('Array.prototype.forEach with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 85 }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + let total = 0; + + watchEffect(() => { + total = 0; + result.value.forEach((item) => { + total += item.score; + }); + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(total).toBe(185); + + // Change score - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', score: 95 }, + { id: 2, name: 'Bob', score: 85 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(total).toBe(180); + + // Add item - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', score: 95 }, + { id: 2, name: 'Bob', score: 85 }, + { id: 3, name: 'Charlie', score: 90 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(3); + expect(total).toBe(270); + }); + }); + + describe('Array.prototype.map with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: false }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const mapped = computed(() => result.value.map((item) => item.active)); + + expect(mapped.value).toEqual([true, false]); + + // Change active property - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: true }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(mapped.value).toEqual([true, true]); + + // Add item - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', active: true }, + { id: 2, name: 'Bob', active: true }, + { id: 3, name: 'Charlie', active: false }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(mapped.value).toEqual([true, true, false]); + }); + + it('should not trigger when non-accessed properties change', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice', active: true, age: 25 }, + { id: 2, name: 'Bob', active: false, age: 30 }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + let mapped; + + watchEffect(() => { + mapped = result.value.map((item) => item.active); // Only access 'active' + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(mapped).toEqual([true, false]); + + // Change age (not tracked) - should NOT trigger + dataSource.value = [ + { id: 1, name: 'Alice', active: true, age: 26 }, + { id: 2, name: 'Bob', active: false, age: 31 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(1); // Should NOT retrigger + }); + }); + + describe('Array.prototype.filter with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = shallowRef([ + { id: 1, name: 'Alice', age: 25 }, + { id: 2, name: 'Bob', age: 17 }, + { id: 3, name: 'Charlie', age: 30 }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const adults = computed(() => result.value.filter((item) => item.age >= 18)); + + expect(adults.value.map((u) => u.name)).toEqual(['Alice', 'Charlie']); + + // Change age to make Bob an adult - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', age: 25 }, + { id: 2, name: 'Bob', age: 18 }, + { id: 3, name: 'Charlie', age: 30 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(adults.value.map((u) => u.name)).toEqual(['Alice', 'Bob', 'Charlie']); + + // Remove item - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', age: 25 }, + { id: 2, name: 'Bob', age: 18 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(adults.value.map((u) => u.name)).toEqual(['Alice', 'Bob']); + }); + }); + + describe('Array.prototype.find with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice', status: 'active' }, + { id: 2, name: 'Bob', status: 'inactive' }, + { id: 3, name: 'Charlie', status: 'active' }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const activeUser = computed(() => result.value.find((item) => item.status === 'active')); + + expect(activeUser.value?.name).toBe('Alice'); + + // Change Alice's status - should trigger + dataSource.value = [ + { id: 1, name: 'Alice', status: 'inactive' }, + { id: 2, name: 'Bob', status: 'inactive' }, + { id: 3, name: 'Charlie', status: 'active' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(activeUser.value?.name).toBe('Charlie'); + }); + }); + + describe('Array.prototype.some/every with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = ref([ + { id: 1, completed: true }, + { id: 2, completed: false }, + { id: 3, completed: true }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const hasIncomplete = computed(() => result.value.some((item) => !item.completed)); + const allComplete = computed(() => result.value.every((item) => item.completed)); + + expect(hasIncomplete.value).toBe(true); + expect(allComplete.value).toBe(false); + + // Complete all items - should trigger + dataSource.value = [ + { id: 1, completed: true }, + { id: 2, completed: true }, + { id: 3, completed: true }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(hasIncomplete.value).toBe(false); + expect(allComplete.value).toBe(true); + }); + }); + + describe('Array.prototype.reduce with nested access', () => { + it('should track both iteration and nested properties', async () => { + const dataSource = ref([ + { id: 1, price: 10, quantity: 2 }, + { id: 2, price: 5, quantity: 3 }, + { id: 3, price: 8, quantity: 1 }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const total = computed(() => result.value.reduce((sum, item) => sum + item.price * item.quantity, 0)); + + expect(total.value).toBe(43); // (10*2) + (5*3) + (8*1) + + // Change quantity - should trigger + dataSource.value = [ + { id: 1, price: 10, quantity: 3 }, // Changed + { id: 2, price: 5, quantity: 3 }, + { id: 3, price: 8, quantity: 1 }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(total.value).toBe(53); // (10*3) + (5*3) + (8*1) + }); + }); + + describe('Object.keys/values/entries with nested access', () => { + it('Object.keys should track structure changes', async () => { + const dataSource = shallowRef({ + data: { + user1: { name: 'Alice' }, + user2: { name: 'Bob' }, + }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + let keys; + + watchEffect(() => { + keys = Object.keys(result.data.value); + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(keys).toEqual(['user1', 'user2']); + + // Add key - should trigger + dataSource.value = { + data: { + user1: { name: 'Alice' }, + user2: { name: 'Bob' }, + user3: { name: 'Charlie' }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(keys).toEqual(['user1', 'user2', 'user3']); + + // Change nested value (keys unchanged) - should NOT trigger + dataSource.value = { + data: { + user1: { name: 'Alicia' }, + user2: { name: 'Bobby' }, + user3: { name: 'Charles' }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); // Should NOT retrigger + }); + + it('Object.values with nested access should track both', async () => { + const dataSource = ref({ + data: { + user1: { name: 'Alice', age: 25 }, + user2: { name: 'Bob', age: 30 }, + }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + const names = computed(() => Object.values(result.data.value).map((user) => user.name)); + + expect(names.value).toEqual(['Alice', 'Bob']); + + // Change nested property - should trigger + dataSource.value = { + data: { + user1: { name: 'Alicia', age: 25 }, + user2: { name: 'Bob', age: 30 }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(names.value).toEqual(['Alicia', 'Bob']); + + // Add entry - should trigger + dataSource.value = { + data: { + user1: { name: 'Alicia', age: 25 }, + user2: { name: 'Bob', age: 30 }, + user3: { name: 'Charlie', age: 35 }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(names.value).toEqual(['Alicia', 'Bob', 'Charlie']); + }); + + it('Object.entries with nested access should track both', async () => { + const dataSource = ref({ + data: { + alice: { score: 100 }, + bob: { score: 85 }, + }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + const leaderboard = computed(() => + Object.entries(result.data.value).map(([name, data]) => ({ + name, + score: data.score, + })), + ); + + expect(leaderboard.value).toEqual([ + { name: 'alice', score: 100 }, + { name: 'bob', score: 85 }, + ]); + + // Change score - should trigger + dataSource.value = { + data: { + alice: { score: 95 }, + bob: { score: 90 }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(leaderboard.value).toEqual([ + { name: 'alice', score: 95 }, + { name: 'bob', score: 90 }, + ]); + }); + }); + + describe('Spread operator', () => { + it('array spread should track structure and nested access', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + let spread; + + watchEffect(() => { + spread = [...result.value]; + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(spread.length).toBe(2); + + // Add item - should trigger + dataSource.value = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(spread.length).toBe(3); + }); + + it('object spread should track structure changes', async () => { + const dataSource = ref({ + data: { + user1: { name: 'Alice' }, + user2: { name: 'Bob' }, + }, + }); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook); + const result = useTestHook(dataSource); + + const fn = vi.fn(); + let spread; + + watchEffect(() => { + spread = { ...result.data.value }; + fn(); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(Object.keys(spread)).toEqual(['user1', 'user2']); + + // Add key - should trigger + dataSource.value = { + data: { + user1: { name: 'Alice' }, + user2: { name: 'Bob' }, + user3: { name: 'Charlie' }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn).toHaveBeenCalledTimes(2); + expect(Object.keys(spread)).toEqual(['user1', 'user2', 'user3']); + }); + }); + + describe('Deeply nested iterations', () => { + it('should track nested iterations with property access', async () => { + const dataSource = ref([ + { + category: 'Fruits', + items: [ + { name: 'Apple', price: 1 }, + { name: 'Banana', price: 2 }, + ], + }, + { + category: 'Vegetables', + items: [{ name: 'Carrot', price: 1.5 }], + }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const allItems = computed(() => { + const items = []; + for (const category of result.value) { + for (const item of category.items) { + items.push(item.name); + } + } + return items; + }); + + expect(allItems.value).toEqual(['Apple', 'Banana', 'Carrot']); + + // Change nested item name - should trigger + dataSource.value = [ + { + category: 'Fruits', + items: [ + { name: 'Green Apple', price: 1 }, // Changed + { name: 'Banana', price: 2 }, + ], + }, + { + category: 'Vegetables', + items: [{ name: 'Carrot', price: 1.5 }], + }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(allItems.value).toEqual(['Green Apple', 'Banana', 'Carrot']); + + // Add nested item - should trigger + dataSource.value = [ + { + category: 'Fruits', + items: [ + { name: 'Green Apple', price: 1 }, + { name: 'Banana', price: 2 }, + { name: 'Orange', price: 1.5 }, // Added + ], + }, + { + category: 'Vegetables', + items: [{ name: 'Carrot', price: 1.5 }], + }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(allItems.value).toEqual(['Green Apple', 'Banana', 'Orange', 'Carrot']); + }); + }); + + describe('Mixed iteration and direct access', () => { + it('should track both iteration and direct index access independently', async () => { + const dataSource = ref([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]); + const mockHook = vi.fn((source) => source); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + const result = useTestHook(dataSource); + + const fn1 = vi.fn(); + const fn2 = vi.fn(); + + // Effect 1: Iterate over all + watchEffect(() => { + result.value.forEach((item) => item.name); + fn1(); + }); + + // Effect 2: Direct access to first item + watchEffect(() => { + result.value[0].name; + fn2(); + }); + + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + + // Change first item - should trigger both + dataSource.value = [ + { id: 1, name: 'Alicia' }, // Changed + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn1).toHaveBeenCalledTimes(2); + expect(fn2).toHaveBeenCalledTimes(2); + + // Change third item - should trigger only iteration effect + dataSource.value = [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charles' }, // Changed + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn1).toHaveBeenCalledTimes(3); + expect(fn2).toHaveBeenCalledTimes(2); // Should NOT retrigger + + // Add item - should trigger only iteration effect + dataSource.value = [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charles' }, + { id: 4, name: 'David' }, // Added + ]; + hookManager.processHookAt(0); + await nextTick(); + + expect(fn1).toHaveBeenCalledTimes(4); + expect(fn2).toHaveBeenCalledTimes(2); // Should NOT retrigger + }); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/function.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/function.spec.ts new file mode 100644 index 0000000..11cda3d --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/function.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, nextTick } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + +describe('t.function()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should return a callable function', () => { + const fn = () => 'test result'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(typeof result).toBe('function'); + expect(result()).toBe('test result'); + }); + + it('should call the up-to-date function after re-execution', async () => { + const source = ref(1); + const mockHook = vi.fn((multiplier) => { + return (x) => x * multiplier; + }); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(source); + + expect(result(10)).toBe(10); // 10 * 1 + + source.value = 5; + hookManager.processHookAt(0); + await nextTick(); + + expect(result(10)).toBe(50); // 10 * 5 (updated function) + }); + + it('should handle function with properties', () => { + const fn = () => 'test'; + fn.customProp = 'value'; + fn.count = 42; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(typeof result).toBe('function'); + expect(result()).toBe('test'); + expect(result.customProp).toBe('value'); + expect(result.count).toBe(42); + }); + + it('should fallback to ref on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on object with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => ({ key: 'value' })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toEqual({ key: 'value' }); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on array with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toEqual([1, 2, 3]); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should verify fallback structure is correct', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 'string'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(typeof result.value).toBe('string'); + + consoleSpy.mockRestore(); + }); + + it('should handle async functions', async () => { + const asyncFn = async () => 'async result'; + const mockHook = vi.fn(() => asyncFn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.function() }); + + const result = useTestHook(); + + expect(typeof result).toBe('function'); + const value = await result(); + expect(value).toBe('async result'); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/object.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/object.spec.ts new file mode 100644 index 0000000..88062d7 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/object.spec.ts @@ -0,0 +1,536 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, isShallow, shallowRef } from '../../../src'; + +import { toUnisonHook as toUnisonHookImpl, t } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +describe('t.object()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should make object deeply reactive', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(result.name).toBe('Alice'); + }); + + it('should enforce object type on valid object', () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + count: 42, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result.user)).toBe(true); + }); + + // REMOVED: Invalid .track() test that just accessed result + // .track() would need custom implementation to track object reference itself + + it('should NOT track reference changes without .track()', async () => { + const source = ref({ name: 'Alice' }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.name; // Access property, not reference + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Same data, different reference + source.value = { name: 'Alice' }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger + }); + + it('should work on function (functions are objects)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + fn.customProp = 'value'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isReactive(result)).toBe(true); + expect(result.customProp).toBe('value'); + + consoleSpy.mockRestore(); + }); + + it('should track top-level function property changes deeply', async () => { + const fn1 = () => 'v1'; + fn1.version = 1; + fn1.config = { setting: 'a', nested: { deep: 'value1' } }; + + const fn2 = () => 'v2'; + fn2.version = 2; + fn2.config = { setting: 'b', nested: { deep: 'value2' } }; + + const source = shallowRef(fn1); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.version; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.version).toBe(1); + + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.version).toBe(2); + }); + + it('should track deeply nested property changes in function properties', async () => { + const fn = () => 'test'; + fn.config = { setting: 'a', nested: { deep: 'value1' } }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.config.nested.deep; // Access deeply nested property + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.config.nested.deep).toBe('value1'); + + // Change deep property + const fn2 = () => 'test'; + fn2.config = { setting: 'a', nested: { deep: 'value2' } }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.config.nested.deep).toBe('value2'); + }); + + it('should track nested object property changes', async () => { + const fn = () => 'test'; + fn.metadata = { author: 'Alice', tags: { primary: 'test' } }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.metadata.tags.primary; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.metadata.tags.primary).toBe('test'); + + // Change nested property + const fn2 = () => 'test'; + fn2.metadata = { author: 'Alice', tags: { primary: 'updated' } }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.metadata.tags.primary).toBe('updated'); + }); + + it('should allow function to be called with deep reactive properties', () => { + const fn = vi.fn(() => 'test result'); + fn.metadata = { + name: 'testFn', + config: { + options: { + verbose: true, + }, + }, + }; + + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + + // Function should still be callable + expect(typeof result).toBe('function'); + expect(result()).toBe('test result'); + expect(fn).toHaveBeenCalledTimes(1); + + // Nested properties should be reactive + expect(isReactive(result.metadata)).toBe(true); + expect(isReactive(result.metadata.config)).toBe(true); + expect(result.metadata.config.options.verbose).toBe(true); + }); + + it('should track multiple nested function properties independently', async () => { + const fn = () => 'test'; + fn.settings = { theme: 'dark', layout: { mode: 'grid' } }; + fn.user = { name: 'Alice', preferences: { lang: 'en' } }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let settingsRuns = 0; + let userRuns = 0; + + watchEffect(() => { + result.settings.layout.mode; + settingsRuns++; + }); + + watchEffect(() => { + result.user.preferences.lang; + userRuns++; + }); + + expect(settingsRuns).toBe(1); + expect(userRuns).toBe(1); + + // Change only settings + const fn2 = () => 'test'; + fn2.settings = { theme: 'dark', layout: { mode: 'list' } }; + fn2.user = { name: 'Alice', preferences: { lang: 'en' } }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(settingsRuns).toBe(2); + expect(userRuns).toBe(1); // Should not retrigger + + // Change only user + const fn3 = () => 'test'; + fn3.settings = { theme: 'dark', layout: { mode: 'list' } }; + fn3.user = { name: 'Alice', preferences: { lang: 'fr' } }; + source.value = fn3; + hookManager.processHookAt(0); + await nextTick(); + + expect(settingsRuns).toBe(2); // Should not retrigger + expect(userRuns).toBe(2); + }); + + it('should handle function with deeply nested array property', async () => { + const fn = () => 'test'; + fn.data = { + items: [ + { id: 1, nested: { value: 'a' } }, + { id: 2, nested: { value: 'b' } }, + ], + }; + + const source = ref(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.data.items[0].nested.value; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.data.items[0].nested.value).toBe('a'); + + // Change nested array item + const fn2 = () => 'test'; + fn2.data = { + items: [ + { id: 1, nested: { value: 'changed' } }, + { id: 2, nested: { value: 'b' } }, + ], + }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.data.items[0].nested.value).toBe('changed'); + }); + + it('should maintain deep reactivity across nested objects', async () => { + const fn = () => 'test'; + fn.complex = { + level1: { + level2: { + level3: { + value: 'deep', + }, + }, + }, + }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.complex.level1.level2.level3.value; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.complex.level1.level2.level3.value).toBe('deep'); + + // All nested levels should be reactive + expect(isReactive(result.complex)).toBe(true); + expect(isReactive(result.complex.level1)).toBe(true); + expect(isReactive(result.complex.level1.level2)).toBe(true); + expect(isReactive(result.complex.level1.level2.level3)).toBe(true); + + // Change deeply nested value + const fn2 = () => 'test'; + fn2.complex = { + level1: { + level2: { + level3: { + value: 'updated', + }, + }, + }, + }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.complex.level1.level2.level3.value).toBe('updated'); + }); + + it('should preserve function identity across updates with deep changes', async () => { + const fn = () => 'test'; + fn.state = { count: 0, meta: { timestamp: 100 } }; + + const source = ref(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + const initialRef = result; + expect(typeof result).toBe('function'); + expect(result.state.count).toBe(0); + + // Update nested property + fn.state = { count: 1, meta: { timestamp: 200 } }; + source.value = fn; + hookManager.processHookAt(0); + await nextTick(); + + // Should still be the same reactive proxy + expect(result).toBe(initialRef); + expect(result.state.count).toBe(1); + expect(result.state.meta.timestamp).toBe(200); + }); + + it('should track array mutations in function properties deeply', async () => { + const fn = () => 'test'; + fn.list = { + items: [1, 2, 3], + }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.list.items.length; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.list.items.length).toBe(3); + + // Change array length + const fn2 = () => 'test'; + fn2.list = { items: [1, 2, 3, 4, 5] }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.list.items.length).toBe(5); + }); + + it('should handle mixed deep structures in function properties', async () => { + const fn = () => 'test'; + fn.data = { + primitive: 42, + nested: { + array: [{ id: 1 }, { id: 2 }], + object: { key: 'value' }, + }, + }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + // All should be deeply reactive + expect(isReactive(result.data)).toBe(true); + expect(isReactive(result.data.nested)).toBe(true); + expect(isReactive(result.data.nested.array)).toBe(true); + expect(isReactive(result.data.nested.array[0])).toBe(true); + expect(isReactive(result.data.nested.object)).toBe(true); + + let effectRuns = 0; + watchEffect(() => { + result.data.nested.array[0].id; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + const fn2 = () => 'test'; + fn2.data = { + primitive: 42, + nested: { + array: [{ id: 10 }, { id: 2 }], + object: { key: 'value' }, + }, + }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should fallback to ref on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on array with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toEqual([1, 2, 3]); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should maintain deep reactivity', async () => { + const source = ref({ + user: { + profile: { name: 'Alice' }, + }, + }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.user.profile.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { + user: { + profile: { name: 'Bob' }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should verify fallback structure is correct', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 'string'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.object() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(typeof result.value).toBe('string'); + expect(result.value).toBe('string'); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/reactive.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/reactive.spec.ts new file mode 100644 index 0000000..e845dc0 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/reactive.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect } from '../../../src'; + +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + +describe('t.reactive()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should make object deeply reactive', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(result.name).toBe('Alice'); + }); + + it('should make array deeply reactive', () => { + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(Array.from(result)).toEqual([1, 2, 3]); + }); + + it('should auto-detect object and make reactive', () => { + const mockHook = vi.fn(() => ({ user: { name: 'Alice' } })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result.user)).toBe(true); + }); + + it('should auto-detect array and make reactive', () => { + const mockHook = vi.fn(() => [{ id: 1 }, { id: 2 }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(Array.isArray(result)).toBe(true); + }); + + it('should maintain nested reactivity in objects', async () => { + const mockHook = vi.fn(() => ({ + user: { + profile: { name: 'Alice' }, + }, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result.user)).toBe(true); + expect(isReactive(result.user.profile)).toBe(true); + }); + + it('should maintain nested reactivity in arrays', async () => { + const mockHook = vi.fn(() => [{ items: [{ id: 1 }] }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result[0])).toBe(true); + expect(isReactive(result[0].items)).toBe(true); + }); + + it('should trigger effect on nested property change', async () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result.user.name = 'Bob'; + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should trigger effect on array item change', async () => { + const mockHook = vi.fn(() => [{ id: 1 }, { id: 2 }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result[0].id; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result[0].id = 10; + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should fallback to ref on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on function with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/ref.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/ref.spec.ts new file mode 100644 index 0000000..c713b14 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/ref.spec.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl, t } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; + +describe('t.ref()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should wrap primitive in ref', () => { + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + }); + + it('should wrap string primitive in ref', () => { + const mockHook = vi.fn(() => 'hello'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('should wrap boolean primitive in ref', () => { + const mockHook = vi.fn(() => true); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(result.value).toBe(true); + }); + + it('should wrap null in ref', () => { + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + }); + + it('should wrap undefined in ref', () => { + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(result.value).toBe(undefined); + }); + + it('should wrap object in ref with reactive value', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(result.value.name).toBe('Alice'); + }); + + it('should wrap array in ref with reactive value', () => { + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isReactive(result.value)).toBe(true); + expect(result.value).toEqual([1, 2, 3]); + }); + + it('should wrap function in ref', () => { + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(typeof result.value).toBe('function'); + expect(result.value()).toBe('test'); + }); + + it('should update ref value when primitive changes on re-execution', async () => { + const source = ref(10); + const mockHook = vi.fn((val) => val * 2); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(source); + expect(result.value).toBe(20); + + source.value = 15; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.value).toBe(30); + }); + + it('should update ref when object changes on re-execution', async () => { + const source = ref({ count: 1 }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(source); + expect(result.value.count).toBe(1); + + source.value = { count: 5 }; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.value.count).toBe(5); + }); + + it('should update ref when array changes on re-execution', async () => { + const source = ref([1, 2]); + const mockHook = vi.fn((arr) => arr); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(source); + expect(result.value).toEqual([1, 2]); + + source.value = [3, 4, 5]; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.value).toEqual([3, 4, 5]); + }); + + it('should maintain reactivity in nested object within ref', async () => { + const mockHook = vi.fn(() => ({ user: { name: 'Alice' } })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.ref() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result.value.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // This should trigger if deep reactivity is working + result.value.user.name = 'Bob'; + await nextTick(); + + expect(effectRuns).toBe(2); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/shallow-object.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/shallow-object.spec.ts new file mode 100644 index 0000000..7783c05 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/shallow-object.spec.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, isShallow, shallowRef } from '../../../src'; + +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + +describe('t.shallowObject()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should make object shallow reactive', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.name).toBe('Alice'); + }); + + it('should enforce object type on valid object', () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + count: 42, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.user)).toBe(false); + }); + + it('should trigger effect on top-level property change', async () => { + const source = shallowRef({ count: 1, name: 'Alice' }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.count; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { count: 2, name: 'Alice' }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should NOT trigger effect on nested property change', async () => { + const source = shallowRef({ + user: { name: 'Alice' }, + }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change nested property value (but user reference is same) + const sameUser = source.value.user; + source.value.user = sameUser; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger + }); + + it('should trigger effect when replacing nested object reference', async () => { + const source = shallowRef({ + user: { name: 'Alice' }, + }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.user; // Track the user reference + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Replace entire user object (different reference) + source.value = { + user: { name: 'Bob' }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should work on function (functions are objects)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + fn.customProp = 'value'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.customProp).toBe('value'); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on array with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toEqual([1, 2, 3]); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should verify fallback structure is correct', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 'string'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(typeof result.value).toBe('string'); + + consoleSpy.mockRestore(); + }); + + it('should track top-level function property changes', async () => { + const fn1 = () => 'v1'; + fn1.version = 1; + fn1.config = { setting: 'a' }; + + const fn2 = () => 'v2'; + fn2.version = 2; + fn2.config = { setting: 'b' }; + + const source = shallowRef(fn1); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.version; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.version).toBe(1); + + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.version).toBe(2); + }); + + it('should NOT track nested property changes in function properties', async () => { + const fn = () => 'test'; + fn.config = { setting: 'a', nested: { deep: 'value' } }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.config.nested.deep; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Mutate deep property but keep same config reference + const sameConfig = fn.config; + sameConfig.nested.deep = 'changed'; + source.value = fn; // Same function, same config ref + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger (shallow) + }); + + it('should trigger when function property object reference changes', async () => { + const fn = () => 'test'; + fn.config = { setting: 'a' }; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.config; // Track config reference + effectRuns++; + }); + + expect(effectRuns).toBe(1); + expect(result.config.setting).toBe('a'); + + // Replace config with new object + const fn2 = () => 'test'; + fn2.config = { setting: 'b' }; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + expect(result.config.setting).toBe('b'); + }); + + it('should allow function to be called', () => { + const fn = vi.fn(() => 'test result'); + fn.metadata = { name: 'testFn' }; + + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + + // Function should still be callable + expect(typeof result).toBe('function'); + expect(result()).toBe('test result'); + expect(fn).toHaveBeenCalledTimes(1); + + // Properties should be accessible + expect(result.metadata.name).toBe('testFn'); + }); + + it('should track multiple function properties independently', async () => { + const fn = () => 'test'; + fn.prop1 = 'a'; + fn.prop2 = 'x'; + + const source = shallowRef(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let prop1Runs = 0; + let prop2Runs = 0; + + watchEffect(() => { + result.prop1; + prop1Runs++; + }); + + watchEffect(() => { + result.prop2; + prop2Runs++; + }); + + // Change only prop1 + const fn2 = () => 'test'; + fn2.prop1 = 'b'; + fn2.prop2 = 'x'; + + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(prop1Runs).toBe(2); + expect(prop2Runs).toBe(1); // Should not retrigger + + // Change only prop2 + const fn3 = () => 'test'; + fn3.prop1 = 'b'; + fn3.prop2 = 'y'; + source.value = fn3; + hookManager.processHookAt(0); + await nextTick(); + + expect(prop1Runs).toBe(2); // Should not retrigger + expect(prop2Runs).toBe(2); + }); + + it('should handle function with array property shallowly', async () => { + const fn = () => 'test'; + fn.items = [{ id: 1 }, { id: 2 }]; + + const source = ref(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.items[0].id; // Access deep in array + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Mutate array item deeply but keep same array reference + const sameItems = fn.items; + sameItems[0].id = 10; + source.value = fn; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger (shallow) + }); + + it('should trigger when function array property reference changes', async () => { + const fn = () => 'test'; + fn.items = [{ id: 1 }, { id: 2 }]; + + const source = ref(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.items; // Track items reference + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Replace with new array + const fn2 = () => 'test'; + fn2.items = [{ id: 3 }, { id: 4 }]; + source.value = fn2; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should preserve function identity across updates', async () => { + const fn = () => 'test'; + fn.count = 0; + + const source = ref(fn); + const mockHook = vi.fn((f) => f); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowObject() }); + + const result = useTestHook(source); + + const initialRef = result; + expect(typeof result).toBe('function'); + + // Update property + fn.count = 1; + source.value = fn; + hookManager.processHookAt(0); + await nextTick(); + + // Should still be the same reactive proxy + expect(result).toBe(initialRef); + expect(result.count).toBe(1); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/shallow-reactive.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/shallow-reactive.spec.ts new file mode 100644 index 0000000..6df9ad6 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/shallow-reactive.spec.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, isShallow, toRaw } from '../../../src'; + +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + + +describe('t.shallowReactive()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should make object shallow reactive', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.name).toBe('Alice'); + }); + + it('should make array shallow reactive', () => { + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result).toEqual([1, 2, 3]); // Use helper instead + }); + + it('should NOT make nested objects reactive', () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result.user)).toBe(false); + }); + + it('should NOT make nested arrays reactive', () => { + const mockHook = vi.fn(() => [{ items: [{ id: 1 }] }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(isReactive(result[0])).toBe(false); + }); + + it('should trigger effect on top-level property change', async () => { + const source = ref({ count: 1, name: 'Alice' }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.count; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { count: 2, name: 'Alice' }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should NOT trigger effect on nested property change', async () => { + const source = ref({ user: { name: 'Alice' } }); + const mockHook = vi.fn((obj) => toRaw(obj)); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + const sameUser = source.value.user; + sameUser.name = 'Bob'; + source.value.user = sameUser; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger (same user reference) + }); + + it('should trigger effect when replacing nested object reference', async () => { + const source = ref({ user: { name: 'Alice' } }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.user; // Track the user reference + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { user: { name: 'Bob' } }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); // Should trigger + }); + + it('should NOT trigger effect on array item mutation with same reference', async () => { + const item1 = { id: 1 }; + const item2 = { id: 2 }; + const source = ref([item1, item2]); + const mockHook = vi.fn((arr) => arr); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result[0].id; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Mutate item but keep same reference + item1.id = 10; + source.value = [item1, item2]; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(1); // Should NOT retrigger (same item reference) + }); + + it('should trigger effect on array length change', async () => { + const source = ref([1, 2, 3]); + const mockHook = vi.fn((arr) => arr); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.length; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change array length via source + source.value = [1, 2, 3, 4]; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should fallback to shallowRef on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on string with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 'hello'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe('hello'); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on function with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); + + it('should fallback to shallowRef on undefined with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(undefined); + + consoleSpy.mockRestore(); + }); + + it('should handle object with mixed value types', () => { + const mockHook = vi.fn(() => ({ + primitive: 42, + nested: { value: 'Alice' }, + array: [1, 2, 3], + func: () => 'test', + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowReactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.primitive).toBe(42); + expect(isReactive(result.nested)).toBe(false); + expect(isReactive(result.array)).toBe(false); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/shallow-ref.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/shallow-ref.spec.ts new file mode 100644 index 0000000..5152de7 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/shallow-ref.spec.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, isShallow } from '../../../src'; + +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + +describe('t.shallowRef()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should wrap primitive in shallowRef', () => { + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(42); + }); + + it('should wrap string primitive in shallowRef', () => { + const mockHook = vi.fn(() => 'hello'); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe('hello'); + }); + + it('should wrap object in shallowRef without deep reactivity', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value.name).toBe('Alice'); + expect(isReactive(result.value)).toBe(false); + }); + + it('should wrap array in shallowRef without deep reactivity', () => { + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toEqual([1, 2, 3]); + expect(isReactive(result.value)).toBe(false); + }); + + it('should trigger effect on reference change', async () => { + const source = ref({ count: 1 }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.value; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { count: 2 }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should NOT trigger effect on deep property change', async () => { + const mockHook = vi.fn(() => ({ user: { name: 'Alice' } })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result.value.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result.value.user.name = 'Bob'; + await nextTick(); + + expect(effectRuns).toBe(1); + }); + + it('should NOT trigger effect on array item change', async () => { + const mockHook = vi.fn(() => [{ id: 1 }, { id: 2 }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result.value[0].id; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result.value[0].id = 10; + await nextTick(); + + expect(effectRuns).toBe(1); + }); + + it('should update shallowRef on re-execution', async () => { + const source = ref(10); + const mockHook = vi.fn((val) => val * 2); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(source); + expect(result.value).toBe(20); + + source.value = 20; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.value).toBe(40); + }); + + it('should handle null in shallowRef', () => { + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(null); + }); + + it('should handle undefined in shallowRef', () => { + const mockHook = vi.fn(() => undefined); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(result.value).toBe(undefined); + }); + + it('should wrap function in shallowRef', () => { + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.shallowRef() }); + + const result = useTestHook(); + + expect(isRef(result)).toBe(true); + expect(isShallow(result)).toBe(true); + expect(typeof result.value).toBe('function'); + expect(result.value()).toBe('test'); + }); +}); + +describe('t.reactive()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should make object deeply reactive', () => { + const mockHook = vi.fn(() => ({ name: 'Alice', age: 30 })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(result.name).toBe('Alice'); + }); + + it('should make array deeply reactive', () => { + const mockHook = vi.fn(() => [1, 2, 3]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(Array.from(result)).toEqual([1, 2, 3]); + }); + + it('should auto-detect object and make reactive', () => { + const mockHook = vi.fn(() => ({ user: { name: 'Alice' } })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(isReactive(result.user)).toBe(true); + }); + + it('should auto-detect array and make reactive', () => { + const mockHook = vi.fn(() => [{ id: 1 }, { id: 2 }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(Array.isArray(result)).toBe(true); + }); + + it('should maintain nested reactivity in objects', async () => { + const mockHook = vi.fn(() => ({ + user: { + profile: { name: 'Alice' }, + }, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result.user)).toBe(true); + expect(isReactive(result.user.profile)).toBe(true); + expect(isShallow(result)).toBe(false); + expect(isShallow(result.user)).toBe(false); + }); + + it('should maintain nested reactivity in arrays', async () => { + const mockHook = vi.fn(() => [{ items: [{ id: 1 }] }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(isReactive(result)).toBe(true); + expect(isReactive(result[0])).toBe(true); + expect(isReactive(result[0].items)).toBe(true); + expect(isShallow(result)).toBe(false); + }); + + it('should trigger effect on nested property change', async () => { + const mockHook = vi.fn(() => ({ + user: { name: 'Alice' }, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result.user.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result.user.name = 'Bob'; + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should trigger effect on array item change', async () => { + const mockHook = vi.fn(() => [{ id: 1 }, { id: 2 }]); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + let effectRuns = 0; + watchEffect(() => { + result[0].id; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + result[0].id = 10; + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should fallback to ref on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on function with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on null with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => null); + const useTestHook = toUnisonHook(mockHook, { tracking: t.reactive() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(null); + + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/shallow.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/shallow.spec.ts new file mode 100644 index 0000000..4fd9d54 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/shallow.spec.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, toRaw, shallowRef } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + +describe('t.shallow()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should track entire field as atomic unit', () => { + const mockHook = vi.fn(() => ({ + data: { + users: [{ id: 1, name: 'Alice' }], + meta: { count: 1 }, + }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: t.shallow(), // Field-level config + }, + }); + + const result = useTestHook(); + + // data field should be ref with non-reactive value + expect(isRef(result.data)).toBe(true); + expect(isReactive(result.data.value)).toBe(false); + expect(result.data.value.users[0].name).toBe('Alice'); + }); + + it('should trigger effect when ANY nested property is accessed and data changes', async () => { + const source = ref({ + data: { + users: [{ id: 1, name: 'Alice' }], + meta: { count: 1 }, + }, + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: t.shallow(), // Field-level: treat data as atomic + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + // Access deeply nested property + result.data.value.users[0].name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change different part of data (meta, not users) + source.value = { + data: { + users: [{ id: 1, name: 'Alice' }], // Same + meta: { count: 2 }, // Changed + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + // Should still trigger (whole data field treated as unit) + expect(effectRuns).toBe(2); + }); + + it('should trigger on any nested change when atomic unit is touched', async () => { + const source = ref({ + payload: { + level1: { + level2: { + level3: { value: 'deep' }, + }, + }, + }, + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + payload: t.shallow(), // payload is atomic + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + // Access any part marks whole payload as tracked + result.payload.value.level1.level2.level3.value; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change anywhere in the structure + source.value = { + payload: { + level1: { + level2: { + level3: { value: 'changed' }, + }, + }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should NOT create nested reactive proxies', () => { + const mockHook = vi.fn(() => ({ + data: { nested: { deep: 'value' } }, + other: { field: 'test' }, + })); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: t.shallow(), // data is atomic + other: t.reactive(), // other is deep reactive + }, + }); + + const result = useTestHook(); + + // data field - not reactive internally + expect(isRef(result.data)).toBe(true); + expect(isReactive(result.data.value)).toBe(false); + expect(isReactive(result.data.value.nested)).toBe(false); + + // other field - deep reactive + expect(isRef(result.other)).toBe(true); + expect(isReactive(result.other.value)).toBe(true); + }); + + it('should work with TanStack Query pattern', async () => { + const source = ref({ + data: { + users: [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ], + meta: { total: 2, page: 1 }, + }, + isLoading: false, + error: null, + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: t.shallow(), // Network data as atomic unit + isLoading: t.ref(), + error: t.ref(), + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + // Access specific user + result.data.value?.users.find((u) => u.id === 1)?.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Simulate network refetch with new data + source.value = { + data: { + users: [ + { id: 1, name: 'Alice', email: 'newemail@example.com' }, + { id: 2, name: 'Bobby', email: 'bobby@example.com' }, + { id: 3, name: 'Charlie', email: 'charlie@example.com' }, + ], + meta: { total: 3, page: 1 }, + }, + isLoading: false, + error: null, + }; + hookManager.processHookAt(0); + await nextTick(); + + // Should trigger (entire data changed) + expect(effectRuns).toBe(2); + }); + + it('should track array field as atomic unit', async () => { + const source = ref({ + items: [ + { id: 1, completed: false }, + { id: 2, completed: true }, + ], + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + items: t.shallow(), // items array as atomic + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.items.value.filter((item) => item.completed).length; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change array + source.value = { + items: [ + { id: 1, completed: true }, + { id: 2, completed: true }, + ], + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should handle null field as atomic value', async () => { + const source = ref({ + data: null, + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + data: t.shallow(), + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.data.value; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { + data: { users: [{ name: 'Alice' }] }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should work in nested field config', async () => { + const source = shallowRef({ + response: { + data: { + users: [{ id: 1, name: 'Alice' }], + }, + }, + }); + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + response: { + data: t.shallow(), // Nested atomic field + }, + }, + }); + + const result = useTestHook(source); + + let effectRuns = 0; + watchEffect(() => { + result.response.value.data.users[0].name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + // Change data + source.value = { + response: { + data: { + users: [{ id: 1, name: 'Alice' }], + }, + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); +}); + +describe('t.shallow() vs t.shallowReactive() comparison', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should show different behavior for nested changes', async () => { + const source = shallowRef({ + shallowReactiveField: { user: { name: 'Alice', age: 30 } }, + shallowField: { user: { name: 'Alice', age: 30 } }, + }); + + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { + tracking: { + shallowReactiveField: t.shallowReactive(), + shallowField: t.shallow(), + }, + }); + const result = useTestHook(source); + + let shallowReactiveRuns = 0; + let shallowRuns = 0; + + watchEffect(() => { + result.shallowReactiveField.value.user.name; + shallowReactiveRuns++; + }); + + watchEffect(() => { + result.shallowField.value.user.name; + shallowRuns++; + }); + + expect(shallowReactiveRuns).toBe(1); + expect(shallowRuns).toBe(1); + + // Change nested property (keep user reference same) + source.value = { + shallowReactiveField: { + user: { name: 'Bob', age: 30 }, // Different reference + }, + shallowField: { + user: { name: 'Bob', age: 30 }, // Different reference + }, + }; + hookManager.processHookAt(0); + await nextTick(); + + // t.shallowReactive: Triggers (user reference changed) + expect(shallowReactiveRuns).toBe(2); + + // t.shallow: Also triggers (whole field treated as unit) + expect(shallowRuns).toBe(2); + + // Now test with same reference but property change + const sameShallowReactive = toRaw(source.value.shallowReactiveField); + const sameUserShallowField = toRaw(source.value.shallowField.user); + + source.value = { + shallowReactiveField: sameShallowReactive, + shallowField: { user: sameUserShallowField }, + }; + hookManager.processHookAt(0); + await nextTick(); + + // t.shallowReactive: Does NOT trigger (user reference unchanged) + expect(shallowReactiveRuns).toBe(2); + + // t.shallow: DOES trigger (entire field changed, even if content same) + expect(shallowRuns).toBe(3); + }); +}); diff --git a/packages/vue/__tests__/hook-manager/tracking-types/to-refs.spec.ts b/packages/vue/__tests__/hook-manager/tracking-types/to-refs.spec.ts new file mode 100644 index 0000000..5dc6fa6 --- /dev/null +++ b/packages/vue/__tests__/hook-manager/tracking-types/to-refs.spec.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, isRef, isReactive, nextTick, watchEffect, shallowRef } from '../../../src'; +import { toUnisonHook as toUnisonHookImpl } from '../../../src/hook-manager'; +import { HookManager } from '../../../src/hook-manager/hook-manager'; +import { t } from '../../../src/hook-manager/tracking-types'; + + +describe('t.toRefs()', () => { + let hookManager: HookManager; + let toUnisonHook: typeof toUnisonHookImpl; + + beforeEach(() => { + hookManager = new HookManager(); + toUnisonHook = (hook, options) => toUnisonHookImpl(hook, options, hookManager); + vi.clearAllMocks(); + }); + + it('should spread object properties into refs', () => { + const mockHook = vi.fn(() => ({ + name: 'Alice', + age: 30, + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(isRef(result.name)).toBe(true); + expect(isRef(result.age)).toBe(true); + expect(result.name.value).toBe('Alice'); + expect(result.age.value).toBe(30); + }); + + it('should handle mixed field types', () => { + const mockHook = vi.fn(() => ({ + primitive: 42, + object: { nested: 'value' }, + array: [1, 2, 3], + func: () => 'test', + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(isRef(result.primitive)).toBe(true); + expect(result.primitive.value).toBe(42); + + expect(isRef(result.object)).toBe(true); + expect(isReactive(result.object.value)).toBe(true); + + expect(isRef(result.array)).toBe(true); + expect(isReactive(result.array.value)).toBe(true); + + expect(typeof result.func).toBe('function'); + expect(result.func()).toBe('test'); + }); + + it('should unwrap functions (not behind .value)', () => { + const mockHook = vi.fn(() => ({ + data: { value: 42 }, + refetch: () => 'refetched', + reset: () => 'reset', + })); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(isRef(result.data)).toBe(true); + expect(typeof result.refetch).toBe('function'); + expect(typeof result.reset).toBe('function'); + expect(result.refetch()).toBe('refetched'); + expect(result.reset()).toBe('reset'); + }); + + it('should update primitive refs on hook re-execution', async () => { + const source = ref({ count: 1, name: 'Alice' }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(source); + + expect(result.count.value).toBe(1); + expect(result.name.value).toBe('Alice'); + + source.value = { count: 5, name: 'Bob' }; + hookManager.processHookAt(0); + await nextTick(); + + expect(result.count.value).toBe(5); + expect(result.name.value).toBe('Bob'); + }); + + it('should maintain reactivity in nested object refs', async () => { + const source = shallowRef({ + user: { name: 'Alice' }, + }) + const mockHook = vi.fn((val) => val); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(source); + + expect(isRef(result.user)).toBe(true); + expect(isReactive(result.user.value)).toBe(true); + + let effectRuns = 0; + watchEffect(() => { + result.user.value.name; + effectRuns++; + }); + + expect(effectRuns).toBe(1); + + source.value = { + user: { name: 'Bob' }, + } + hookManager.processHookAt(0); + await nextTick(); + + expect(effectRuns).toBe(2); + }); + + it('should fallback to ref on primitive with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mockHook = vi.fn(() => 42); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + expect(result.value).toBe(42); + + consoleSpy.mockRestore(); + }); + + it('should fallback to ref on function with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fn = () => 'test'; + const mockHook = vi.fn(() => fn); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(consoleSpy).toHaveBeenCalled(); + expect(isRef(result)).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should handle array by spreading indices into refs', () => { + const mockHook = vi.fn(() => ['a', 'b', 'c']); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(); + + expect(isRef(result[0])).toBe(true); + expect(isRef(result[1])).toBe(true); + expect(isRef(result[2])).toBe(true); + expect(result[0].value).toBe('a'); + expect(result[1].value).toBe('b'); + expect(result[2].value).toBe('c'); + }); + + it('should handle new fields appearing', async () => { + const source = ref({ name: 'Alice' }); + const mockHook = vi.fn((obj) => obj); + const useTestHook = toUnisonHook(mockHook, { tracking: t.toRefs() }); + + const result = useTestHook(source); + + expect(isRef(result.name)).toBe(true); + expect(result.age).toBeUndefined(); + + source.value = { name: 'Alice', age: 30 }; + hookManager.processHookAt(0); + await nextTick(); + + expect(isRef(result.age)).toBe(true); + expect(result.age.value).toBe(30); + }); +}); From e7a7c6e978f8b5e9a25302e4c6c1806b9d5d6f77 Mon Sep 17 00:00:00 2001 From: NGBAMA William Date: Sat, 31 Jan 2026 03:52:40 +0100 Subject: [PATCH 2/2] add wrapper toUnisonHook toconvert react hook to Vue composables --- .../src/hook-manager/arrayInstrumentations.ts | 298 +++++++++ packages/vue/src/hook-manager/hook-manager.ts | 570 ++++++++++++++++++ packages/vue/src/hook-manager/index.d.ts | 17 - packages/vue/src/hook-manager/index.js | 7 - packages/vue/src/hook-manager/index.ts | 60 ++ packages/vue/src/hook-manager/reactive.ts | 429 +++++++++++++ packages/vue/src/hook-manager/ref.ts | 64 ++ .../vue/src/hook-manager/tracking-types.ts | 169 ++++++ packages/vue/src/hook-manager/utils.ts | 313 ++++++++++ 9 files changed, 1903 insertions(+), 24 deletions(-) create mode 100755 packages/vue/src/hook-manager/arrayInstrumentations.ts create mode 100644 packages/vue/src/hook-manager/hook-manager.ts delete mode 100644 packages/vue/src/hook-manager/index.d.ts delete mode 100644 packages/vue/src/hook-manager/index.js create mode 100755 packages/vue/src/hook-manager/index.ts create mode 100644 packages/vue/src/hook-manager/reactive.ts create mode 100644 packages/vue/src/hook-manager/ref.ts create mode 100644 packages/vue/src/hook-manager/tracking-types.ts create mode 100644 packages/vue/src/hook-manager/utils.ts diff --git a/packages/vue/src/hook-manager/arrayInstrumentations.ts b/packages/vue/src/hook-manager/arrayInstrumentations.ts new file mode 100755 index 0000000..49c8015 --- /dev/null +++ b/packages/vue/src/hook-manager/arrayInstrumentations.ts @@ -0,0 +1,298 @@ +import { TrackOpTypes } from '../reactivity/constants'; +import { endBatch, pauseTracking, resetTracking, startBatch } from '@unisonjs/core'; +import { isProxy, isShallow, toRaw } from '../reactivity'; +import { ARRAY_ITERATE_KEY } from '@unisonjs/core'; +import { isArray } from '@vue/shared'; +import { type ReactiveContext, toReactive, track } from './reactive'; + +function untrack any>(exec: T): ReturnType { + pauseTracking(); + startBatch(); + const res = exec(); + endBatch(); + resetTracking(); + return res; +} + +/** + * Track array iteration and return: + * - if input is reactive: a cloned raw array with reactive values + * - if input is non-reactive or shallowReactive: the original raw array + */ +export function reactiveReadArray(array: T[] & ReactiveContext): T[] { + track(array, { type: TrackOpTypes.ITERATE, key: ARRAY_ITERATE_KEY }); + return isShallow(array) ? toRaw(array) : untrack(() => array.map((v) => v)); +} + +/** + * Track array iteration and return raw array + */ +export function shallowReadArray(arr: T[] & ReactiveContext): T[] { + track(arr, { type: TrackOpTypes.ITERATE, key: ARRAY_ITERATE_KEY }); + return toRaw(arr); +} + +export const arrayInstrumentations: Record = { + __proto__: null, + + [Symbol.iterator]() { + return iterator(this, Symbol.iterator, toReactive); + }, + + concat(...args: unknown[]) { + return reactiveReadArray(this).concat(...args.map((x) => (isArray(x) ? reactiveReadArray(x) : x))); + }, + + entries() { + return iterator(this, 'entries', (value: [number, unknown]) => { + value[1] = toReactive(value[1]); + return value; + }); + }, + + every(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { + return apply(this, 'every', fn, thisArg, undefined, arguments); + }, + + filter(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { + return apply(this, 'filter', fn, thisArg, (v) => v.map(toReactive), arguments); + }, + + find(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { + return apply(this, 'find', fn, thisArg, toReactive, arguments); + }, + + findIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { + return apply(this, 'findIndex', fn, thisArg, undefined, arguments); + }, + + findLast(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { + return apply(this, 'findLast', fn, thisArg, toReactive, arguments); + }, + + findLastIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { + return apply(this, 'findLastIndex', fn, thisArg, undefined, arguments); + }, + + // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement + + forEach(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { + return apply(this, 'forEach', fn, thisArg, undefined, arguments); + }, + + includes(...args: unknown[]) { + return searchProxy(this, 'includes', args); + }, + + indexOf(...args: unknown[]) { + return searchProxy(this, 'indexOf', args); + }, + + join(separator?: string) { + return reactiveReadArray(this).join(separator); + }, + + // keys() iterator only reads `length`, no optimisation required + + lastIndexOf(...args: unknown[]) { + return searchProxy(this, 'lastIndexOf', args); + }, + + map(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { + return apply(this, 'map', fn, thisArg, undefined, arguments); + }, + + pop() { + return noTracking(this, 'pop'); + }, + + push(...args: unknown[]) { + return noTracking(this, 'push', args); + }, + + reduce(fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, ...args: unknown[]) { + return reduce(this, 'reduce', fn, args); + }, + + reduceRight(fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, ...args: unknown[]) { + return reduce(this, 'reduceRight', fn, args); + }, + + shift() { + return noTracking(this, 'shift'); + }, + + // slice could use ARRAY_ITERATE but also seems to beg for range tracking + + some(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { + return apply(this, 'some', fn, thisArg, undefined, arguments); + }, + + splice(...args: unknown[]) { + return noTracking(this, 'splice', args); + }, + + toReversed() { + // @ts-expect-error user code may run in es2016+ + return reactiveReadArray(this).toReversed(); + }, + + toSorted(comparer?: (a: unknown, b: unknown) => number) { + // @ts-expect-error user code may run in es2016+ + return reactiveReadArray(this).toSorted(comparer); + }, + + toSpliced(...args: unknown[]) { + // @ts-expect-error user code may run in es2016+ + return (reactiveReadArray(this).toSpliced as any)(...args); + }, + + unshift(...args: unknown[]) { + return noTracking(this, 'unshift', args); + }, + + values() { + return iterator(this, 'values', toReactive); + }, +}; + +// instrument iterators to take ARRAY_ITERATE dependency +function iterator(self: unknown[] & ReactiveContext, method: keyof Array, wrapValue: (context: ReactiveContext, value: any) => unknown) { + // note that taking ARRAY_ITERATE dependency here is not strictly equivalent + // to calling iterate on the proxified array. + // creating the iterator does not access any array property: + // it is only when .next() is called that length and indexes are accessed. + // pushed to the extreme, an iterator could be created in one effect scope, + // partially iterated in another, then iterated more in yet another. + // given that JS iterator can only be read once, this doesn't seem like + // a plausible use-case, so this tracking simplification seems ok. + const arr = shallowReadArray(self); + const iter = (arr[method] as any)() as IterableIterator & { + _next: IterableIterator['next']; + }; + if (arr !== self && !isShallow(self)) { + iter._next = iter.next; + let i = 0; + iter.next = () => { + const result = iter._next(); + if (result.value) { + result.value = untrack(() => self[i]); + } + i++; + return result; + }; + } + return iter; +} + +// in the codebase we enforce es2016, but user code may run in environments +// higher than that +type ArrayMethods = keyof Array | 'findLast' | 'findLastIndex'; + +const arrayProto = Array.prototype; +// instrument functions that read (potentially) all items +// to take ARRAY_ITERATE dependency +function apply( + self: unknown[] & ReactiveContext, + method: ArrayMethods, + fn: (item: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + wrappedRetFn?: (context: ReactiveContext, result: any) => unknown, + args?: IArguments, +) { + const arr = shallowReadArray(self); + const needsWrap = arr !== self && !isShallow(self); + // @ts-expect-error our code is limited to es2016 but user code is not + const methodFn = arr[method]; + + // #11759 + // If the method being called is from a user-extended Array, the arguments will be unknown + // (unknown order and unknown parameter types). In this case, we skip the shallowReadArray + // handling and directly call apply with self. + if (methodFn !== arrayProto[method as any]) { + const result = methodFn.apply(self, args); + return needsWrap ? toReactive(result) : result; + } + + let wrappedFn = fn; + if (arr !== self) { + if (needsWrap) { + wrappedFn = function (this: unknown, item, index) { + return fn.call( + this, + untrack(() => self[index]), + index, + self, + ); + }; + } else if (fn.length > 2) { + wrappedFn = function (this: unknown, item, index) { + return fn.call(this, item, index, self); + }; + } + } + const result = methodFn.call(arr, wrappedFn, thisArg); + return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; +} + +// instrument reduce and reduceRight to take ARRAY_ITERATE dependency +function reduce( + self: unknown[] & ReactiveContext, + method: keyof Array, + fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, + args: unknown[], +) { + const arr = shallowReadArray(self); + let wrappedFn = fn; + if (arr !== self) { + if (!isShallow(self)) { + wrappedFn = function (this: unknown, acc, item, index) { + return fn.call( + this, + acc, + untrack(() => self[index]), + index, + self, + ); + }; + } else if (fn.length > 3) { + wrappedFn = function (this: unknown, acc, item, index) { + return fn.call( + this, + acc, + untrack(() => self[index]), + index, + self, + ); + }; + } + } + return (arr[method] as any)(wrappedFn, ...args); +} + +// instrument identity-sensitive methods to account for reactive proxies +function searchProxy(self: unknown[] & ReactiveContext, method: keyof Array, args: unknown[]) { + const arr = toRaw(self) as any; + track(self, { type: TrackOpTypes.ITERATE, key: ARRAY_ITERATE_KEY }); + // we run the method using the original args first (which may be reactive) + const res = arr[method](...args); + + // if that didn't work, run it again using raw values. + if ((res === -1 || res === false) && isProxy(args[0])) { + args[0] = toRaw(args[0]); + return arr[method](...args); + } + + return res; +} + +// instrument length-altering mutation methods to avoid length being tracked +// which leads to infinite loops in some cases (#2137) +function noTracking(self: unknown[], method: keyof Array, args: unknown[] = []) { + pauseTracking(); + startBatch(); + const res = (toRaw(self) as any)[method].apply(self, args); + endBatch(); + resetTracking(); + return res; +} diff --git a/packages/vue/src/hook-manager/hook-manager.ts b/packages/vue/src/hook-manager/hook-manager.ts new file mode 100644 index 0000000..6bb444b --- /dev/null +++ b/packages/vue/src/hook-manager/hook-manager.ts @@ -0,0 +1,570 @@ +import { + ARRAY_ITERATE_KEY, + ComponentInternalInstance, + Dep, + isFastRefresh, + ITERATE_KEY, + MAP_KEY_ITERATE_KEY, + ReactiveEffect, + type UnisonPlugin, +} from '@unisonjs/core'; +import { isFunction, isObject, NOOP } from '@vue/shared'; +import { isRef, isShallow, shallowRef, toRaw, unref } from '../reactivity'; +import { Events } from '@unisonjs/core'; +import { getAccessor } from './utils'; +import { + checkCompatibility, + DetectedType, + detectType, + getDefaultTracking, + getFallbackConfig, + getTrackingType, + isTrackSelf, + t, +} from './tracking-types'; +import type { ReactHook, TrackingConfig, TrackingConfigInput } from './tracking-types'; +import { ref } from './ref'; +import { reactive, shallowReactive } from './reactive'; + +interface HookOptions { + hook: ReactHook; + params: any; + tracking?: TrackingConfigInput; +} + +interface HookRegistration extends HookOptions { + index: number; +} + +export class HookManager implements UnisonPlugin { + #currentValues: any[] = []; + #previousValues: any[] = []; + #trackingConfigs: TrackingConfig[] = []; + #storeCursor = 0; + #hooks: HookRegistration[] = []; + #signals: any[] = []; + #effect = new ReactiveEffect(NOOP); + #scheduler = NOOP; + #trackedPaths: Map>[] = []; + #i = 0; + + constructor() { + this.#effect.scheduler = () => this.#scheduler(); + } + + get hookEffect() { + return this.#effect; + } + + trackPath(hookIndex: number, rootKey: string, basePath: string, key: unknown) { + let registar = this.#trackedPaths[hookIndex]; + let trackedPaths = registar.get(rootKey); + if (!trackedPaths) registar.set(rootKey, (trackedPaths = new Map())); + let trackedKeys = trackedPaths.get(basePath); + if (!trackedKeys) trackedPaths.set(basePath, (trackedKeys = [])); + trackedKeys.push(key); + } + #targetMaps: Map>>[] = []; + + createTargetDeps(hookIndex: number, rootKey: string, targetPath: string): Map { + let rootMaps = this.#targetMaps[hookIndex]; + if (!rootMaps) { + this.#targetMaps[hookIndex] = rootMaps = new Map(); + } + + let rootMap = rootMaps.get(rootKey); + if (!rootMap) { + rootMaps.set(rootKey, (rootMap = new Map())); + } + + let depsMap; + rootMap.set(targetPath, (depsMap = new Map())); + + return depsMap; + } + + getTargetDeps(hookIndex: any, rootKey: string, targetPath: string) { + const rootMaps = this.#targetMaps[hookIndex]; + if (!rootMaps) return; + + const rootMap = rootMaps.get(rootKey); + if (!rootMap) return; + + const depsMap = rootMap.get(targetPath); + + return depsMap; + } + + onInstanceCreated(instance: ComponentInternalInstance): void { + this.#scheduler = () => instance.triggerRendering(); + + instance.addEventListener(Events.BEFORE_FLUSHING_PRE_EFFECT, ({ job }) => { + if (!instance.isExecuted() || isFastRefresh()) return; + if (job) { + const position = job.position || 0; + while (this.#i < position) { + this.processHookAt(this.#i); + this.#i++; + } + } + }); + + instance.addEventListener(Events.AFTER_FLUSHING_ALL_PRE_EFFECT, () => { + if (!instance.isExecuted() || isFastRefresh()) return; + while (this.#i < this.#hooks.length && !instance.hasPendingPreEffects()) { + this.processHookAt(this.#i); + this.#i++; + } + + if (instance.hasPendingPreEffects()) { + instance.flushPreEffects(); + } + + this.#i = 0; + }); + } + reset() { + this.#signals.length = 0; + this.#hooks.length = 0; + this.#effect = new ReactiveEffect(NOOP); + } + onInstanceFastRefresh(instance: ComponentInternalInstance): void { + this.reset(); + } + onInstanceDisposed(): void {} + + getHookResultAt(index: number) { + return this.#currentValues[index]; + } + + getHookPreviousResultAt(index: number) { + return this.#previousValues[index]; + } + + addToStore(value: any) { + this.#currentValues[this.#storeCursor] = value; + const index = this.#storeCursor; + + this.#storeCursor++; + + return index; + } + + setStoreValueAt(index: number, value: any) { + this.#currentValues[index] = value; + } + + get store() { + return this.#currentValues; + } + + getStoreNextIndex() { + return this.#storeCursor++; + } + + getTrackingConfigAt(hookIndex: number) { + return this.#trackingConfigs[hookIndex]; + } + + processHookAt(index: number) { + const hook = this.#hooks[index]; + if (!hook) return; + + const args = isFunction(hook.params) ? hook.params() : (hook.params ?? []); + this.#effect.fn = () => hook.hook(...args.map((arg: any) => toRaw(unref(arg)))); + const newResult = this.#effect.run(); + + this.#previousValues[hook.index] = this.#currentValues[hook.index]; + this.#currentValues[hook.index] = newResult; + + const actualType = detectType(newResult); + + const tracking = this.#resolveTracking(hook, args, actualType); + + this.#trackingConfigs[hook.index] = tracking; + + const signal = this.#applyTracking(hook.index, newResult, tracking, actualType); + + this.#updateTrackedPaths(hook.index, newResult, signal, tracking); + + return signal; + } + + #resolveTracking(hook: HookRegistration, params: any[], actualType: DetectedType): TrackingConfig { + let specified: TrackingConfig | Record | undefined; + + if (isFunction(hook.tracking)) { + try { + specified = hook.tracking(...params); + } catch (error) { + if (__DEV__) { + console.error('[UnisonJS] Tracking function threw error:', error); + } + specified = undefined; + } + } else { + specified = hook.tracking as TrackingConfig | Record | undefined; + } + + if (!specified) { + return getDefaultTracking(actualType); + } + + // Check if it's a root-level tracking config + const trackingType = getTrackingType(specified); + if (trackingType) { + const config = specified as TrackingConfig; + + const compatible = checkCompatibility(trackingType, actualType); + + if (compatible) { + return config; + } + + const fallback = getFallbackConfig(trackingType, actualType); + + if (__DEV__) { + const hookName = hook.hook.name || 'anonymous'; + console.warn( + `[UnisonJS] Type incompatibility in ${hookName}:\n` + + ` Specified: ${trackingType}\n` + + ` Actual: ${actualType}\n` + + ` Falling back to: ${getTrackingType(fallback)}\n` + + ` Suggestion: Use t.ref() or t.shallowRef() for flexible types.`, + ); + } + + return fallback; + } + + return t.toRefs(specified); + } + + #applyTracking(hookIndex: number, result: any, tracking: TrackingConfig, actualType: DetectedType): any { + const existingSignal = this.#signals[hookIndex]; + + const trackingType = getTrackingType(tracking); + switch (trackingType) { + case 'ref': + return this.#applyRef(hookIndex, result, existingSignal); + + case 'shallowRef': + return this.#applyShallowRef(hookIndex, result, existingSignal); + + case 'toRefs': + case 'refs': + return this.#applyToRefs(hookIndex, result, tracking, existingSignal); + case 'reactive': + return this.#applyReactive(hookIndex, existingSignal); + case 'object': + return this.#applyObject(hookIndex, existingSignal); + case 'shallowReactive': + return this.#applyShallowReactive(hookIndex, existingSignal); + + case 'shallowObject': + return this.#applyShallowObject(hookIndex, existingSignal); + + case 'function': + return this.#applyFunction(hookIndex, result); + + default: + if (__DEV__) { + console.warn(`[UnisonJS] Unknown tracking type: ${tracking}`); + } + return this.#applyRef(hookIndex, result, existingSignal); + } + } + + #applyRef(hookIndex: number, result: any, existingSignal: any): any { + if (isObject(result)) { + if (!existingSignal) { + this.#signals[hookIndex] = ref( + { + hookIndex, + hookManager: this, + }, + result, + ); + } + } else { + if (!existingSignal) { + this.#signals[hookIndex] = ref( + { + hookIndex, + hookManager: this, + }, + result, + ); + } else { + existingSignal.value = result; + } + } + + return this.#signals[hookIndex]; + } + + #applyShallowRef(hookIndex: number, result: any, existingSignal: any): any { + if (!existingSignal) { + this.#signals[hookIndex] = shallowRef(result); + } else { + existingSignal.value = result; + } + + return this.#signals[hookIndex]; + } + + #applyReactive(hookIndex: number, existingSignal: any): any { + if (!existingSignal) { + this.#signals[hookIndex] = reactive({ + hookIndex, + hookManager: this, + }); + } + + return this.#signals[hookIndex]; + } + + #applyShallowReactive(hookIndex: number, existingSignal: any): any { + if (!existingSignal) { + this.#signals[hookIndex] = shallowReactive({ + hookIndex, + hookManager: this, + }); + } + // Note: reactive objects are updated in updateTrackedPaths + return this.#signals[hookIndex]; + } + + #applyToRefs( + hookIndex: number, + result: any, + tracking: Record | undefined, + existingSignal: any, + ): any { + if (!isObject(result)) { + if (__DEV__) { + console.warn('[UnisonJS] toRefs() applied to non-object, using ref instead'); + } + return this.#applyRef(hookIndex, result, existingSignal); + } + + let signal = existingSignal; + + if (!signal) { + signal = this.#signals[hookIndex] = {}; + + for (const [key, value] of Object.entries(result)) { + const fieldTracking = tracking?.[key] || this.#inferFieldTracking(value); + signal[key] = this.#createFieldSignal(hookIndex, key, value, fieldTracking); + } + } else { + // Update existing signals + for (const [key, value] of Object.entries(result)) { + if (signal[key]) { + this.#updateFieldSignal({ signal: signal[key], value, hookIndex, tracking: tracking?.[key] }); + } else { + // New field appeared + const fieldTracking = tracking?.[key] || this.#inferFieldTracking(value); + signal[key] = this.#createFieldSignal(hookIndex, key, value, fieldTracking); + } + } + } + + return signal; + } + + #applyObject(hookIndex: number, existingSignal: any): any { + return this.#applyReactive(hookIndex, existingSignal); + } + + #applyShallowObject(hookIndex: number, existingSignal: any): any { + return this.#applyShallowReactive(hookIndex, existingSignal); + } + + #applyFunction(hookIndex: number, result: any): any { + if (!isFunction(result)) { + if (__DEV__) { + console.warn('[UnisonJS] t.function() applied to non-function'); + } + return this.#applyRef(hookIndex, result, this.#signals[hookIndex]); + } + + // Return wrapper that always calls current value + this.#signals[hookIndex] = result; // Store for reference + + const hookManager = this; + return new Proxy((...args: any[]) => this.#currentValues[hookIndex]?.(...args), { + get(_, internalKey) { + const currentValue = hookManager.#currentValues[hookIndex]; + return Reflect.get(currentValue, internalKey); + }, + }); + } + + #inferFieldTracking(value: any): TrackingConfig { + if (isFunction(value)) { + return t.function(); + } + if (isObject(value)) { + return t.reactive(); + } + return t.ref(); + } + + #createFieldSignal(hookIndex: number, key: string, value: any, tracking: TrackingConfig): any { + switch (getTrackingType(tracking)) { + case 'function': + const hookManager = this; + return new Proxy((...args: any[]) => this.#currentValues[hookIndex][key](...args), { + get(_, internalKey) { + const currentValue = hookManager.#currentValues[hookIndex][key]; + return Reflect.get(currentValue, internalKey); + }, + }); + + case 'shallowRef': + case 'shallow': + return shallowRef(value); + + case 'object': + case 'reactive': { + const reactiveResult = reactive({ + hookIndex, + hookManager: this, + rootKey: key, + }); + return shallowRef(reactiveResult); + } + + case 'shallowObject': + case 'shallowReactive': { + const shallowResult = shallowReactive({ + hookIndex, + hookManager: this, + rootKey: key, + }); + return shallowRef(shallowResult); + } + + case 'ref': + default: + return ref( + { + hookIndex, + hookManager: this, + rootKey: key, + }, + value, + ); + } + } + + #updateFieldSignal({ + signal, + value, + tracking, + hookIndex, + }: { + signal: any; + value: any; + tracking?: TrackingConfig; + hookIndex: number; + }) { + if (isRef(signal)) { + const trackingType = getTrackingType(tracking); + if ((!isObject(value) && !isFunction(value)) || trackingType === 'shallow') { + signal.value = value; + } + + if (isTrackSelf(tracking)) { + if (!isShallow(signal)) signal.value = value; + if (isShallow(signal) && !Object.is(toRaw(signal.value), value)) { + if (trackingType === 'shallowObject' || trackingType === 'shallowReactive') { + signal.value = shallowReactive({ + hookIndex, + hookManager: this, + }); + } + + if (trackingType === 'object' || trackingType === 'reactive') { + signal.value = reactive({ + hookIndex, + hookManager: this, + }); + } + } + } + } + } + + #updateTrackedPaths(hookIndex: number, newResult: any, signal: any, tracking: TrackingConfig) { + // if (!["object", "shallowObject", "reactive", "shallowReactive", "toRefs"].includes(tracking[TYPE])) return; + + for (const [rootKey, pathEntries] of this.#trackedPaths[hookIndex]) { + const root = rootKey !== '' ? newResult[rootKey] : newResult; + const rootFromSignal = this.#getRootFromSignal(signal, rootKey); + + for (const [basePath, keys] of pathEntries) { + const accessor = getAccessor(basePath); + + const newTarget = accessor.getValue(root); + const oldTarget = accessor.getValue(rootFromSignal); + for (const key of keys) { + this.#updateTarget(oldTarget, newTarget, key); + } + } + } + } + + #getRootFromSignal(signal: any, rootKey: string) { + if (rootKey === '') { + return isRef(signal) ? signal.value : signal; + } + + return signal[rootKey]?.value ?? signal[rootKey]; + } + + #updateTarget(oldTarget: any, newTarget: any, key: any) { + if (!oldTarget || !newTarget) return; + + switch (key) { + case ITERATE_KEY: { + const allKeys = new Set([...Object.keys(newTarget), ...Object.keys(oldTarget)]); + + for (const k of allKeys) { + if (k in newTarget) { + oldTarget[k] = newTarget[k]; + } else if (k in oldTarget) { + delete oldTarget[k]; + } + } + break; + } + + case ARRAY_ITERATE_KEY: { + for (let i = 0; i < newTarget.length; i++) { + oldTarget[i] = newTarget[i]; + } + oldTarget.length = newTarget.length; + break; + } + + case MAP_KEY_ITERATE_KEY: + // TODO: Handle Map iteration + break; + + default: + if (key in oldTarget && key in newTarget) { + oldTarget[key] = newTarget[key]; + } + break; + } + } + registerHook(params: HookOptions) { + const index = this.#hooks.length; + this.#hooks.push({ ...params, index }); + this.#trackedPaths.push(new Map()); + + return this.#hooks.length - 1; + } +} diff --git a/packages/vue/src/hook-manager/index.d.ts b/packages/vue/src/hook-manager/index.d.ts deleted file mode 100644 index a9792c2..0000000 --- a/packages/vue/src/hook-manager/index.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ToRef } from '../reactivity/ref'; -interface UnisonHookOptions { - paths?: any | ((...args: any[]) => any); - shallow?: boolean; -} - -type AnyFunction = (...args: any[]) => any; -type ToRefs = T extends object ? { [P in keyof T]: T[P] extends Function ? T[P] : ToRef } : T; - -export declare function toUnisonHook( - hook: T, - shallow: UnisonHookOptions & { shallow: true }, -): (...args: Parameters) => ToRef> ; -export declare function toUnisonHook( - hook: T, - options?: UnisonHookOptions & { shallow: false }, -): (...args: Parameters) => ToRefs>; diff --git a/packages/vue/src/hook-manager/index.js b/packages/vue/src/hook-manager/index.js deleted file mode 100644 index 2d068b1..0000000 --- a/packages/vue/src/hook-manager/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import { HookManager, usePlugin } from "@unisonjs/core"; -import HookRef from "../react-hook/hookRef"; -import { unref } from "../reactivity/ref"; - -usePlugin(HookManager, { signalClass: HookRef, unsignal: unref }); - -export { toUnisonHook } from "@unisonjs/core"; diff --git a/packages/vue/src/hook-manager/index.ts b/packages/vue/src/hook-manager/index.ts new file mode 100755 index 0000000..3f4459f --- /dev/null +++ b/packages/vue/src/hook-manager/index.ts @@ -0,0 +1,60 @@ +import { + getCurrentInstance, + mustBeUnisonComponent, + usePlugin, +} from '@unisonjs/core'; +import { NOOP } from '@vue/shared'; +import { HookManager } from './hook-manager'; +import type { ReactHook, TrackingConfigInput } from './tracking-types'; + +function getHookManager() { + mustBeUnisonComponent(); + + const instance = getCurrentInstance(); + + const hookManager = instance?.getPlugin(HookManager); + if (!hookManager) { + if (__DEV__) { + throw new Error('HookManager not found'); + } else { + return; + } + } + + return hookManager; +} + +export interface UnisonHookOptions { + paths?: any | ((...args: any[]) => any); + shallow?: boolean; +} + + +export interface UnisonHookOptions { + tracking?: TrackingConfigInput; +} + +usePlugin(HookManager); + +export { t } from "./tracking-types"; +export function toUnisonHook( + hook: T, + options: UnisonHookOptions = {}, + hookManager = getHookManager(), +) { + return (...params: any[]) => { + mustBeUnisonComponent(); + + if (!hookManager) return NOOP; + + const hookIndex = hookManager.registerHook({ + hook, + params, + tracking: options.tracking, + }); + + const result = hookManager.processHookAt(hookIndex); + + return result; + }; +} diff --git a/packages/vue/src/hook-manager/reactive.ts b/packages/vue/src/hook-manager/reactive.ts new file mode 100644 index 0000000..ab23fc0 --- /dev/null +++ b/packages/vue/src/hook-manager/reactive.ts @@ -0,0 +1,429 @@ +import { + activeSub, + ARRAY_ITERATE_KEY, + Dep, + endBatch, + globalVersion, + ITERATE_KEY, + MAP_KEY_ITERATE_KEY, + shouldTrack, + startBatch, +} from '@unisonjs/core'; +import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from '../reactivity/constants'; +import { hasChanged, hasOwn, isArray, isIntegerKey, isMap, isObject, isSymbol } from '@vue/shared'; +import { arrayInstrumentations } from './arrayInstrumentations'; +import { buildPath, createProxy, getAccessor } from './utils'; +import type { HookManager } from './hook-manager'; +import { getTrackingType, isTrackSelf, TrackingConfig } from './tracking-types'; + +export interface ReactiveContextConfig { + basePath?: string; + hookManager: HookManager; + rootKey?: string; + trackSelf?: boolean; + hookIndex: number; + isShallow: boolean; +} + + +const TRACK = Symbol('track'); +const TRIGGER = Symbol('trigger'); + +function get(target: any, key: any) { + if (key === '') return target; + + return Reflect.get(target, key); +} + +export function track(context: ReactiveContext, params: Parameters[0]) { + context[TRACK](params); +} +export function trigger(context: ReactiveContext, params: Parameters[0]) { + context[TRIGGER](params); +} + +const builtInSymbols = new Set( + /*@__PURE__*/ + Object.getOwnPropertyNames(Symbol) + // ios10.x Object.getOwnPropertyNames(Symbol) can enumerate 'arguments' and 'caller' + // but accessing them on Symbol leads to TypeError because Symbol is a strict mode + // function + .filter((key) => key !== 'arguments' && key !== 'caller') + .map((key) => Symbol[key as keyof SymbolConstructor]) + .filter(isSymbol), +); + +const handler: ProxyHandler = { + get(context, key) { + if (key === TRACK || key === TRIGGER) { + return Reflect.get(context, key).bind(context); + } + + const target = context.getValue(); + const value = target[key]; + + // if (key === ReactiveFlags.SKIP) return target[ReactiveFlags.SKIP] + + const isReadonly = false, + isShallow = context.isShallow; + if (key === ReactiveFlags.IS_REACTIVE) { + return !isReadonly; + } else if (key === ReactiveFlags.IS_READONLY) { + return isReadonly; + } else if (key === ReactiveFlags.IS_SHALLOW) { + return isShallow; + } else if (key === ReactiveFlags.RAW) { + return target; + } + + const targetIsArray = isArray(target); + + let fn: Function | undefined; + if (targetIsArray && (fn = arrayInstrumentations[key])) { + return fn; + } + + if ( + isShallow || + !isObject(value) || + isTrackSelf(context.getTrackingConfig()?.[key]) || + getTrackingType(context.getTrackingConfig()?.[key]) === 'shallow' + ) { + track(context, { + type: TrackOpTypes.GET, + key, + }); + } + + if (isShallow || getTrackingType(context.getTrackingConfig()?.[key]) === 'shallow') { + return value; + } + + // If the value is an object, wrap it in another proxy + if (isObject(value)) { + return reactive(context.forkWithKey(key)); + } + + return value; + }, + set(context, key, value, receiver) { + const target = context.getPreviousValue() || context.getValue(); + + const oldValue = target[key]; + + if ( + !context.isShallow && + getTrackingType(context.getTrackingConfig()?.[key]) !== 'shallow' && + isObject(value) && + isObject(oldValue) + ) + return true; + + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + // don't trigger if target is something up in the prototype chain of original + + if (!hadKey) { + trigger(context, { + target, + type: TriggerOpTypes.ADD, + key, + oldValue: value, + }); + } else if (hasChanged(value, oldValue)) { + trigger(context, { + target, + type: TriggerOpTypes.SET, + key, + oldValue, + newValue: value, + }); + } + + return true; + }, + + has(context, key) { + const target = context.getValue(); + + const result = Reflect.has(target, key); + + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(context, { + type: TrackOpTypes.HAS, + key, + }); + } + return result; + }, + + getOwnPropertyDescriptor(context, prop) { + const target = context.getValue(); + return Reflect.getOwnPropertyDescriptor(target, prop); + }, + + ownKeys(context) { + const target = context.getValue(); + + track(context, { type: TrackOpTypes.ITERATE, key: isArray(target) ? 'length' : ITERATE_KEY }); + + return Reflect.ownKeys(target); + }, +}; + +export function toReactive(context: ReactiveContext | Omit, value: any) { + return isObject(value) ? reactive(context) : value; +} + +class ReactiveContext { + #basePath: string; + #hookManager: HookManager; + #rootKey: string; + #hookIndex: number; + #isShallow: boolean; + #trackSelf?: boolean; + + constructor(config: ReactiveContextConfig) { + this.#basePath = config.basePath || ''; + this.#hookIndex = config.hookIndex; + this.#rootKey = config.rootKey || ''; + this.#hookManager = config.hookManager; + this.#isShallow = config.isShallow; + this.#trackSelf = config.trackSelf; + } + + forkWithKey(key: string | symbol, isShallow = false) { + return new ReactiveContext({ + basePath: buildPath(this.#basePath, key), + hookIndex: this.#hookIndex, + rootKey: this.#rootKey, + hookManager: this.#hookManager, + isShallow: isShallow, + }); + } + + getPreviousResult(): any { + return this.#hookManager.getHookPreviousResultAt(this.#hookIndex); + } + + getResult(): any { + return this.#hookManager.getHookResultAt(this.#hookIndex); + } + + getRootValue(): any { + const rootKey = this.#rootKey; + + return get(this.getResult(), rootKey); + } + + getPreviousRootValue(): any { + const rootKey = this.#rootKey; + + return get(this.getPreviousResult(), rootKey); + } + + getValue(): any { + const targetAccessor = getAccessor(this.#basePath); + const target = targetAccessor.getValue(this.getRootValue()); + + return target; + } + + getPreviousValue(): any { + const targetAccessor = getAccessor(this.#basePath); + const target = targetAccessor.getValue(this.getPreviousRootValue()); + + return target; + } + + get basePath(): string { + return this.#basePath; + } + + get trackSelf(): boolean { + return !!this.#trackSelf; + } + get hookIndex(): number { + return this.#hookIndex; + } + + get rootKey(): string { + return this.#rootKey; + } + + get hookManager(): HookManager { + return this.#hookManager; + } + + get isShallow(): boolean { + return this.#isShallow; + } + + trackKey(key: unknown) { + const hookManager = this.#hookManager; + const hookIndex = this.#hookIndex; + const rootKey = this.#rootKey; + const targetPath = this.#basePath; + + hookManager.trackPath(hookIndex, rootKey, targetPath, key); + } + + getTrackingConfig() { + const resolvedConfig = get(this.#hookManager.getTrackingConfigAt(this.#hookIndex), this.#rootKey); + + const targetAccessor = getAccessor(this.#basePath); + return targetAccessor.getValue(resolvedConfig) as TrackingConfig; + } + + getDeps() { + const hookManager = this.#hookManager; + const hookIndex = this.#hookIndex; + const rootKey = this.#rootKey; + const targetPath = this.#basePath; + + const deps = hookManager.getTargetDeps(hookIndex, rootKey, targetPath); + if (!deps) return hookManager.createTargetDeps(hookIndex, rootKey, targetPath); + return deps; + } + + [TRACK]({ type, key }: { type: TrackOpTypes; key: unknown }): void { + if (!shouldTrack || !activeSub) { + return; + } + + const target = this.getValue(); + + this.trackKey(key); + + const depsMap = this.getDeps(); + + let dep = depsMap.get(key); + if (!dep) { + dep = new Dep(); + dep.map = depsMap; + dep.key = key; + depsMap.set(key, dep); + } + + if (__DEV__) { + dep.track({ target, type, key }); + } else { + dep.track(); + } + } + + [TRIGGER]({ + target, + type, + key, + newValue, + oldValue, + oldTarget, + }: { + target: any; + type: TriggerOpTypes; + key?: unknown; + newValue?: unknown; + oldValue?: unknown; + oldTarget?: Map | Set; + }): void { + const depsMap = this.getDeps(); + + if (!depsMap) { + globalVersion++; + return; + } + + const run = (dep: Dep | undefined): void => { + if (!dep) return; + + if (__DEV__) { + dep.trigger({ target, type, key, newValue, oldValue, oldTarget }); + } else { + dep.trigger(); + } + }; + + startBatch(); + + if (type === TriggerOpTypes.CLEAR) { + depsMap.forEach(run); + } else { + const targetIsArray = isArray(target); + const isArrayIndex = targetIsArray && isIntegerKey(key); + + if (targetIsArray && key === 'length') { + const newLength = Number(newValue); + depsMap.forEach((dep, key) => { + if (key === 'length' || key === ARRAY_ITERATE_KEY || (!isSymbol(key) && key >= newLength)) { + run(dep); + } + }); + } else { + if (key !== void 0 || depsMap.has(void 0)) { + run(depsMap.get(key)); + } + + if (isArrayIndex) { + run(depsMap.get(ARRAY_ITERATE_KEY)); + } + + switch (type) { + case TriggerOpTypes.ADD: + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isArrayIndex) { + run(depsMap.get('length')); + } + break; + + case TriggerOpTypes.DELETE: + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + + case TriggerOpTypes.SET: + if (isMap(target)) { + run(depsMap.get(ITERATE_KEY)); + } + break; + } + } + } + + endBatch(); + } +} + +export type { ReactiveContext }; +type ReactiveObject = {} & ReactiveContext; +type ReactiveArray = any[] & ReactiveContext; + +export function reactive(config: Omit | ReactiveContext): ReactiveObject | ReactiveArray { + let context; + if (config instanceof ReactiveContext) { + context = config; + } else { + context = new ReactiveContext({ ...config, isShallow: false }); + } + const target = context.getValue(); + + const proxied = createProxy(target, context); + + return new Proxy(proxied, handler); +} + +export function shallowReactive(config: Omit): ReactiveObject | ReactiveArray { + const context = new ReactiveContext({ ...config, isShallow: true }); + const target = context.getValue(); + + const proxied = createProxy(target, context); + + return new Proxy(proxied, handler); +} diff --git a/packages/vue/src/hook-manager/ref.ts b/packages/vue/src/hook-manager/ref.ts new file mode 100644 index 0000000..80d4982 --- /dev/null +++ b/packages/vue/src/hook-manager/ref.ts @@ -0,0 +1,64 @@ +import { + Dep, +} from '@unisonjs/core'; +import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from '../reactivity/constants'; +import { hasChanged } from '@vue/shared'; +import { toRaw } from '../reactivity'; +import { toReactive, type ReactiveContextConfig } from './reactive'; + +export function ref(context: Omit, value: any) { + return new RefImpl(context, value, false); +} + +/** + * @internal + */ +class RefImpl { + #value: T; + #rawValue: T; + #context: Omit; + + dep: Dep = new Dep(); + + public readonly [ReactiveFlags.IS_REF] = true; + public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false; + + constructor(context: Omit, value: T, isShallow: boolean) { + this.#rawValue = toRaw(value); + this.#value = toReactive(context, value); + this.#context = context; + this[ReactiveFlags.IS_SHALLOW] = isShallow; + } + + get value() { + if (__DEV__) { + this.dep.track({ + target: this, + type: TrackOpTypes.GET, + key: 'value', + }); + } else { + this.dep.track(); + } + return this.#value; + } + + set value(newValue) { + const oldValue = this.#rawValue; + if (hasChanged(newValue, oldValue)) { + this.#rawValue = newValue; + this.#value = toReactive(this.#context, newValue); + if (__DEV__) { + this.dep.trigger({ + target: this, + type: TriggerOpTypes.SET, + key: 'value', + newValue, + oldValue, + }); + } else { + this.dep.trigger(); + } + } + } +} diff --git a/packages/vue/src/hook-manager/tracking-types.ts b/packages/vue/src/hook-manager/tracking-types.ts new file mode 100644 index 0000000..8f27799 --- /dev/null +++ b/packages/vue/src/hook-manager/tracking-types.ts @@ -0,0 +1,169 @@ +import { isFunction, isObject } from "@vue/shared"; + +type TrackingType = + | 'ref' + | 'shallowRef' + | 'reactive' + | 'shallowReactive' + | 'shallow' + | 'toRefs' + | 'refs' + | 'object' + | 'shallowObject' + | 'function'; + +const TYPE = Symbol('type'); +const TRACK_SELF = Symbol('track_self'); +interface BaseTrackingConfig { + [TYPE]: TrackingType; + inner?: TrackingConfig; + [TRACK_SELF]?: boolean; +} + + export interface TrackingConfig extends BaseTrackingConfig { + [key: string]: TrackingConfig | TrackingType | boolean | undefined; +} + +export interface NestedTrackingConfig { + [key: string]: TrackingConfig | NestedTrackingConfig; +} + +export type TrackingConfigInput = + | TrackingConfig + | NestedTrackingConfig + | ((...params: any[]) => TrackingConfig | NestedTrackingConfig | undefined); + +export type ReactHook = (...args: any[]) => any; + +interface TrackableBuilder { + reactive: (children?: NestedTrackingConfig) => TrackingConfig; + shallowReactive: (children?: NestedTrackingConfig) => TrackingConfig; + object: (children?: NestedTrackingConfig) => TrackingConfig; + shallowObject: (children?: NestedTrackingConfig) => TrackingConfig; +} + +function createTrackableBuilder(shouldTrack: boolean): TrackableBuilder { + const createConfig = (type: TrackingType, children?: NestedTrackingConfig): TrackingConfig => { + if (children !== undefined && __DEV__) { + console.warn( + `[UnisonJS] Invalid composition: ${type}() cannot contain inner tracking config. ` + + `Inner config will be ignored.`, + ); + } + + const config: TrackingConfig = { + [TYPE]: type, + ...children, + }; + + if (shouldTrack) { + config[TRACK_SELF] = true; + } + + return config; + }; + + return { + reactive: (children) => createConfig('reactive', children), + shallowReactive: (children) => createConfig('shallowReactive', children), + object: (children) => createConfig('object', children), + shallowObject: (children) => createConfig('shallowObject', children), + }; +} + +const normalBuilder = createTrackableBuilder(false); +const trackedBuilder = createTrackableBuilder(true); + +export const t = { + ref: (inner?: TrackingConfig): TrackingConfig => ({ + [TYPE]: 'ref', + inner, + }), + + shallowRef: (inner?: TrackingConfig): TrackingConfig => ({ + [TYPE]: 'shallowRef', + inner, + }), + + shallow: (): TrackingConfig => ({ + [TYPE]: 'shallow', + }), + + toRefs: (children?: NestedTrackingConfig): TrackingConfig => ({ + [TYPE]: 'toRefs', + ...children, + }), + + refs: (children?: NestedTrackingConfig): TrackingConfig => ({ + [TYPE]: 'refs', + ...children, + }), + + function: (): TrackingConfig => ({ + [TYPE]: 'function', + }), + + reactive: normalBuilder.reactive, + shallowReactive: normalBuilder.shallowReactive, + object: normalBuilder.object, + shallowObject: normalBuilder.shallowObject, + + track: () => trackedBuilder, +}; + + +export type DetectedType = 'primitive' | 'object' | 'function'; + +export function detectType(value: any): DetectedType { + if (isFunction(value)) return 'function'; + if (isObject(value)) return 'object'; + return 'primitive'; +} + +export function checkCompatibility(trackingType: TrackingType, actualType: DetectedType): boolean { + const compatMatrix: Record = { + ref: ['primitive', 'object', 'function'], + shallowRef: ['primitive', 'object', 'function'], + shallow: ['primitive', 'object', 'function'], + reactive: ['object'], + shallowReactive: ['object'], + toRefs: ['object', 'function'], + refs: ['object', 'function'], + object: ['object', 'function'], + shallowObject: ['object', 'function'], + function: ['function'], + }; + + return compatMatrix[trackingType]?.includes(actualType) ?? false; +} + +export function getFallbackConfig(specifiedType: TrackingType, actualType: DetectedType): TrackingConfig { + if (specifiedType === 'shallowReactive' || specifiedType === 'shallowObject') { + return { [TYPE]: 'shallowRef' }; + } + + return { [TYPE]: 'ref' }; +} + +export function getDefaultTracking(actualType: DetectedType): TrackingConfig { + switch (actualType) { + case 'primitive': + return { [TYPE]: 'ref' }; + case 'object': + return { [TYPE]: 'toRefs' }; + case 'function': + return { [TYPE]: 'function' }; + default: + return { [TYPE]: 'ref' }; + } +} + +export function getTrackingType(value?: any) { + if (!value || !isObject(value)) return undefined; + return Reflect.get(value, TYPE) as TrackingType; +} + +export function isTrackSelf(value: any) { + if (!value || !isObject(value)) return false; + return !!Reflect.get(value, TRACK_SELF); +} diff --git a/packages/vue/src/hook-manager/utils.ts b/packages/vue/src/hook-manager/utils.ts new file mode 100644 index 0000000..34515e8 --- /dev/null +++ b/packages/vue/src/hook-manager/utils.ts @@ -0,0 +1,313 @@ +type Accessor = { + getValue: (obj: object) => any; + setValue: (obj: object, value: any) => any; +}; + +type PathSegment = + | { type: 'string'; key: string } + | { type: 'symbol'; index: number }; + +const accessors = new Map(); + +// Symbol registry - bidirectional mapping +const symbolToIndex = new Map(); +const indexToSymbol = new Map(); +let symbolCounter = 0; + +function registerSymbol(sym: symbol): number { + let index = symbolToIndex.get(sym); + if (index === undefined) { + index = symbolCounter++; + symbolToIndex.set(sym, index); + indexToSymbol.set(index, sym); + } + return index; +} + +export function buildPath(basePath: string, key: string | symbol): string { + const keyPart = typeof key === 'symbol' + ? `@@sym:${registerSymbol(key)}` + : key; + + return basePath ? `${basePath}.${keyPart}` : keyPart; +} + +function parsePathToSegments(path: string): PathSegment[] { + if (path === '') return []; + + return path.split('.').map(segment => { + // Check if segment starts with '@@sym:' (symbol notation) + if (segment.startsWith('@@sym:')) { + const indexStr = segment.slice(6); // Remove '@@sym:' prefix + const index = parseInt(indexStr, 10); + return { type: 'symbol', index }; + } + return { type: 'string', key: segment }; + }); +} + +export function getAccessor(path: string) { + let accessor = accessors.get(path); + if (!accessor) { + accessors.set( + path, + (accessor = { + getValue: createGetter(path), + setValue: createSetter(path), + }), + ); + } + return accessor; +} + +// Detect if unsafe-eval is allowed +let canUseUnsafeEval: boolean | undefined = undefined; +let getterCache = new Map(); +let setterCache = new Map(); + +function detectUnsafeEval() { + if (canUseUnsafeEval !== undefined) return canUseUnsafeEval; + try { + new Function('return 1')(); + canUseUnsafeEval = true; + } catch (e) { + canUseUnsafeEval = false; + } + return canUseUnsafeEval; +} + +export function createGetter(path: string): Accessor['getValue'] { + if (getterCache.has(path)) { + return getterCache.get(path); + } + + if (path === '') { + return (obj) => obj; + } + + const segments = parsePathToSegments(path); + + if (detectUnsafeEval()) { + // Generate code that accesses symbol registry for symbol keys + const accessors = segments.map((segment) => { + if (segment.type === 'symbol') { + return `?.[indexToSymbol.get(${segment.index})]`; + } else { + return `?.[${JSON.stringify(segment.key)}]`; + } + }).join(''); + + const getter = new Function( + 'obj', + 'indexToSymbol', + `return obj${accessors}` + ); + + const boundGetter = (obj: any) => getter(obj, indexToSymbol); + getterCache.set(path, boundGetter); + return boundGetter as Accessor['getValue']; + } else { + // Fallback: use actual symbols from registry + const getter = (obj: any) => { + let current = obj; + for (let i = 0; i < segments.length; i++) { + if (current == null) return undefined; + + const segment = segments[i]; + const key = segment.type === 'symbol' + ? indexToSymbol.get(segment.index)! + : segment.key; + + current = current[key]; + } + return current; + }; + getterCache.set(path, getter); + return getter; + } +} + +export function createSetter(path: string): Accessor['setValue'] { + if (setterCache.has(path)) { + return setterCache.get(path); + } + + if (path === '') { + return (obj) => obj; + } + + const segments = parsePathToSegments(path); + + if (detectUnsafeEval()) { + if (segments.length === 1) { + // Simple case: obj[key] = value + const segment = segments[0]; + + if (segment.type === 'symbol') { + const setter = new Function( + 'obj', + 'value', + 'indexToSymbol', + `if (obj != null) obj[indexToSymbol.get(${segment.index})] = value` + ); + const boundSetter = (obj: object, value: any) => setter(obj, value, indexToSymbol); + setterCache.set(path, boundSetter); + return boundSetter; + } else { + const setter = new Function( + 'obj', + 'value', + `if (obj != null) obj[${JSON.stringify(segment.key)}] = value` + ); + setterCache.set(path, setter); + return setter as (obj: object, value: any) => any; + } + } else { + // Complex case: ensure path exists, then set + const parentSegments = segments.slice(0, -1); + const lastSegment = segments[segments.length - 1]; + + const parentCode = parentSegments.map((segment, i) => { + if (segment.type === 'symbol') { + return ` + let key${i} = indexToSymbol.get(${segment.index}); + if (current[key${i}] == null) current[key${i}] = {}; + current = current[key${i}]; + `; + } else { + return ` + if (current[${JSON.stringify(segment.key)}] == null) current[${JSON.stringify(segment.key)}] = {}; + current = current[${JSON.stringify(segment.key)}]; + `; + } + }).join('\n'); + + const lastKeyCode = lastSegment.type === 'symbol' + ? `indexToSymbol.get(${lastSegment.index})` + : JSON.stringify(lastSegment.key); + + const setter = new Function( + 'obj', + 'value', + 'indexToSymbol', + ` + if (obj == null) return; + let current = obj; + ${parentCode} + current[${lastKeyCode}] = value; + ` + ); + + const boundSetter = (obj: object, value: any) => setter(obj, value, indexToSymbol); + setterCache.set(path, boundSetter); + return boundSetter; + } + } else { + // Loop fallback + const setter = (obj: any, value: any) => { + if (obj == null) return; + let current = obj; + + // Navigate to parent, creating objects as needed + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + const key = segment.type === 'symbol' + ? indexToSymbol.get(segment.index)! + : segment.key; + + if (current[key] == null) { + current[key] = {}; + } + current = current[key]; + } + + // Set the final value + const lastSegment = segments[segments.length - 1]; + const lastKey = lastSegment.type === 'symbol' + ? indexToSymbol.get(lastSegment.index)! + : lastSegment.key; + + current[lastKey] = value; + }; + setterCache.set(path, setter); + return setter; + } +} + + +// ============================================================================ +// Utility: Attach class instance via Proxy +// ============================================================================ + +const INSTANCE = Symbol('@@instance'); + +type ConstructorParams = T extends new (...args: infer P) => any ? P : never; +type InstanceType = T extends new (...args: any[]) => infer R ? R : never; + +/** + * Create a proxy that forwards to a class instance + */ +export function createProxy( + target: TTarget, + instance: TInstance +): TTarget & TInstance { + // Store instance reference + // (target as any)[INSTANCE] = instance; + + return new Proxy(target, { + get(target, prop, receiver) { + // Check if property exists on target first + if (prop in target) { + return Reflect.get(target, prop, receiver); + } + + // Otherwise get from instance + const value = (instance as any)[prop]; + + // Bind methods to instance + if (typeof value === 'function') { + return value.bind(instance); + } + + return value; + }, + + set(target, prop, value, receiver) { + // If property exists on target, set it there + if (prop in target) { + return Reflect.set(target, prop, value, receiver); + } + + // Otherwise set on instance + (instance as any)[prop] = value; + return true; + }, + + has(target, prop) { + return prop in target || prop in (instance as any); + }, + + ownKeys(target) { + const targetKeys = Reflect.ownKeys(target); + const instanceKeys = Reflect.ownKeys(instance as any); + return [...new Set([...targetKeys, ...instanceKeys])]; + }, + + getOwnPropertyDescriptor(target, prop) { + const targetDesc = Reflect.getOwnPropertyDescriptor(target, prop); + if (targetDesc) { + return targetDesc; + } + + const instanceDesc = Reflect.getOwnPropertyDescriptor(instance as any, prop); + return instanceDesc; + } + }) as TTarget & TInstance; +} + +/** + * Helper to get the instance from a proxied object + */ +export function getInstance(obj: any): T | undefined { + return obj[INSTANCE]; +}