From f44953a17524659f07da185124bb7c41bf753c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandras=20=C5=A0ukelovi=C4=8D?= Date: Mon, 22 Jan 2024 16:52:32 +0200 Subject: [PATCH 1/5] Added watchEffect function in Stock --- .vscode/settings.json | 2 +- src/hooks/useObservers.ts | 34 +++++++++++++++++++++++++++++---- src/hooks/useStock.ts | 9 +++++++-- src/hooks/useStockValue.ts | 6 +++--- src/typings/MappingProxy.ts | 9 +++++++++ src/typings/StockProxy.ts | 12 +++++++++++- src/utils/useInterceptors.ts | 15 ++++++++++++++- test/DummyProxy.ts | 1 + test/hooks/useObservers.test.ts | 21 +++++++++++++++++++- 9 files changed, 96 insertions(+), 13 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 49963d6..0b07a4d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "typescript.tsdk": "node_modules\\typescript\\lib", "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" } } diff --git a/src/hooks/useObservers.ts b/src/hooks/useObservers.ts index ce02dbf..5e96e63 100644 --- a/src/hooks/useObservers.ts +++ b/src/hooks/useObservers.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { MutableRefObject, useCallback, useRef } from 'react'; import { createPxth, deepGet, isInnerPxth, Pxth, samePxth } from 'pxth'; import invariant from 'tiny-invariant'; @@ -8,10 +8,20 @@ import { ObserverArray, ObserverKey } from '../utils/ObserverArray'; import { useLazyRef } from '../utils/useLazyRef'; export type ObserversControl = { - /** Watch stock value. Returns cleanup function. */ + /** + * Watch stock value. Returns cleanup function. + * @deprecated - use watchEffect instead + */ watch: (path: Pxth, observer: Observer) => () => void; - /** Watch all stock values. Returns cleanup function. */ + /** + * Watch all stock values. Returns cleanup function. + * @deprecated - use watchAllEffect instead + */ watchAll: (observer: Observer) => () => void; + /** Watch stock value. Returns cleanup function. Calls observer instantly. */ + watchEffect: (path: Pxth, observer: Observer) => () => void; + /** Watch all stock values. Returns cleanup function. Calls observer instantly. */ + watchAllEffect: (observer: Observer) => () => void; /** Check if value is observed or not. */ isObserved: (path: Pxth) => boolean; /** Notify all observers, which are children of specified path */ @@ -23,7 +33,7 @@ export type ObserversControl = { }; /** Hook, wraps functionality of observers storage (add, remove, notify tree of observers, etc.) */ -export const useObservers = (): ObserversControl => { +export const useObservers = (values: MutableRefObject): ObserversControl => { const observersMap = useRef>>(new PxthMap()); const batchUpdateObservers = useLazyRef>>(() => new ObserverArray()); @@ -74,6 +84,20 @@ export const useObservers = (): ObserversControl => { const watchAll = useCallback((observer: Observer) => watch(createPxth([]), observer), [watch]); + const watchEffect = useCallback( + (path: Pxth, observer: Observer) => { + observer(deepGet(values.current, path)); + const key = observe(path, observer); + return () => stopObserving(path, key); + }, + [observe, stopObserving, values], + ); + + const watchAllEffect = useCallback( + (observer: Observer) => watchEffect(createPxth([]), observer), + [watchEffect], + ); + const watchBatchUpdates = useCallback( (observer: Observer>) => { const key = observeBatchUpdates(observer); @@ -121,6 +145,8 @@ export const useObservers = (): ObserversControl => { return { watch, watchAll, + watchEffect, + watchAllEffect, watchBatchUpdates, isObserved, notifySubTree, diff --git a/src/hooks/useStock.ts b/src/hooks/useStock.ts index 726532b..25cd8ed 100644 --- a/src/hooks/useStock.ts +++ b/src/hooks/useStock.ts @@ -40,7 +40,8 @@ export type StockConfig = { */ export const useStock = ({ initialValues, debugName }: StockConfig): Stock => { const values = useLazyRef(() => cloneDeep(initialValues)); - const { notifySubTree, notifyAll, watch, watchAll, watchBatchUpdates, isObserved } = useObservers(); + const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchAllEffect, watchBatchUpdates, isObserved } = + useObservers(values); const setValue = useCallback( (path: Pxth, action: SetStateAction) => { @@ -78,6 +79,8 @@ export const useStock = ({ initialValues, debugName }: StockCo resetValues, watch, watchAll, + watchEffect, + watchAllEffect, watchBatchUpdates, isObserved, debugName, @@ -86,14 +89,16 @@ export const useStock = ({ initialValues, debugName }: StockCo [ getValue, getValues, - debugName, setValue, setValues, resetValues, watch, watchAll, + watchEffect, + watchAllEffect, watchBatchUpdates, isObserved, + debugName, ], ); diff --git a/src/hooks/useStockValue.ts b/src/hooks/useStockValue.ts index b62a507..856fa85 100644 --- a/src/hooks/useStockValue.ts +++ b/src/hooks/useStockValue.ts @@ -20,7 +20,7 @@ export const useStockValue = ( ): V => { const stock = useStockContext(customStock, proxy); - const { watch, getValue } = stock; + const { watchEffect, getValue } = stock; const [, forceUpdate] = useReducer((val) => val + 1, 0); @@ -28,11 +28,11 @@ export const useStockValue = ( useEffect( () => - watch(path, (newValue) => { + watchEffect(path, (newValue) => { value.current = newValue; forceUpdate(); }), - [path, watch, value], + [path, watchEffect, value], ); return value.current; diff --git a/src/typings/MappingProxy.ts b/src/typings/MappingProxy.ts index 4cdd1f5..1f72e8b 100644 --- a/src/typings/MappingProxy.ts +++ b/src/typings/MappingProxy.ts @@ -67,6 +67,15 @@ export class MappingProxy extends StockProxy { return defaultWatch(normalPath, (value) => observer(this.mapValue(value, path, normalPath) as V)); }; + public watchEffect = ( + path: Pxth, + observer: Observer, + defaultWatchEffect: (path: Pxth, observer: Observer) => () => void, + ) => { + const normalPath = this.getNormalPath(path); + return defaultWatchEffect(normalPath, (value) => observer(this.mapValue(value, path, normalPath) as V)); + }; + public getValue = (path: Pxth, defaultGetValue: (path: Pxth) => U): V => { const normalPath = this.getNormalPath(path); return this.mapValue(defaultGetValue(normalPath), path, normalPath) as V; diff --git a/src/typings/StockProxy.ts b/src/typings/StockProxy.ts index e788b02..01196b3 100644 --- a/src/typings/StockProxy.ts +++ b/src/typings/StockProxy.ts @@ -23,13 +23,23 @@ export abstract class StockProxy { defaultGetValue: (path: Pxth) => U, ) => void; - /** Function for watching proxied value. Should return cleanup. */ + /** + * Function for watching proxied value. Should return cleanup. + * @deprecated + */ public abstract watch: ( path: Pxth, observer: Observer, defaultWatch: (path: Pxth, observer: Observer) => () => void, ) => () => void; + /** Function for watching proxied value. Should return cleanup. Calls observer instantly. */ + public abstract watchEffect: ( + path: Pxth, + observer: Observer, + defaultWatchEffect: (path: Pxth, observer: Observer) => () => void, + ) => () => void; + /** Function to access proxied value. */ public abstract getValue: (path: Pxth, defaultGetValue: (path: Pxth) => U) => V; diff --git a/src/utils/useInterceptors.ts b/src/utils/useInterceptors.ts index eca9741..bd4bd25 100644 --- a/src/utils/useInterceptors.ts +++ b/src/utils/useInterceptors.ts @@ -34,7 +34,7 @@ export const intercept = any>( /** Intercepts stock's `observe`, `stopObserving` and `setValue` functions, if proxy is provided. */ export const useInterceptors = (stock: Stock, proxy?: StockProxy): Stock => { - const { watch, setValue, getValue, setValues, getValues } = stock; + const { watch, watchEffect, setValue, getValue, setValues, getValues } = stock; useEffect( () => @@ -57,6 +57,18 @@ export const useInterceptors = (stock: Stock, proxy?: Stock [watch, proxy], ); + const interceptedWatchEffect = useCallback( + (path: Pxth, observer: Observer) => + intercept( + proxy, + path as Pxth, + watchEffect, + (path: Pxth, observer: Observer) => proxy!.watchEffect(path, observer, watchEffect), + [path, observer], + ), + [watchEffect, proxy], + ); + const interceptedSetValue = useCallback( (path: Pxth, value: SetStateAction) => intercept( @@ -114,6 +126,7 @@ export const useInterceptors = (stock: Stock, proxy?: Stock return { ...stock, watch: interceptedWatch, + watchEffect: interceptedWatchEffect, setValue: interceptedSetValue, getValue: interceptedGetValue, getValues: interceptedGetValues, diff --git a/test/DummyProxy.ts b/test/DummyProxy.ts index fae9297..47efc01 100644 --- a/test/DummyProxy.ts +++ b/test/DummyProxy.ts @@ -7,5 +7,6 @@ export class DummyProxy extends StockProxy { public getNormalPath = (path: Pxth) => path; public setValue = () => {}; public watch = () => () => {}; + public watchEffect = () => () => {}; public getValue = (path: Pxth, defaultGetValue: (path: Pxth) => U) => defaultGetValue(path) as V; } diff --git a/test/hooks/useObservers.test.ts b/test/hooks/useObservers.test.ts index d3d4d5c..e510b79 100644 --- a/test/hooks/useObservers.test.ts +++ b/test/hooks/useObservers.test.ts @@ -3,7 +3,12 @@ import { createPxth, getPxthSegments } from 'pxth'; import { useObservers } from '../../src'; -const renderUseObserversHook = () => renderHook(() => useObservers()); +const renderUseObserversHook = (values: object = {}) => + renderHook(() => + useObservers({ + current: values, + }), + ); describe('Observer tests', () => { it('should call value observer', () => { @@ -21,6 +26,20 @@ describe('Observer tests', () => { expect(observer).toBeCalled(); }); + it('should call observer instantly with "watchEffect"', () => { + const { result } = renderUseObserversHook({ + b: 'hello', + }); + + const observer = jest.fn(); + + act(() => { + result.current.watchEffect(createPxth(['b']), observer); + }); + + expect(observer).toBeCalled(); + }); + it('Should call all values observer', async () => { const { result } = renderUseObserversHook(); From 5311c547f7eec8472af50b1665746e140e564b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandras=20=C5=A0ukelovi=C4=8D?= Date: Mon, 22 Jan 2024 17:22:51 +0200 Subject: [PATCH 2/5] Added more tests --- src/hooks/useObservers.ts | 8 +++---- src/hooks/useStock.ts | 6 ++--- test/hooks/useObservers.test.ts | 14 ++++++++++++ test/typings/MappingProxy.test.ts | 21 +++++++++++++++++ test/utils/useInterceptors.test.ts | 36 ++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/hooks/useObservers.ts b/src/hooks/useObservers.ts index 5e96e63..5cda65a 100644 --- a/src/hooks/useObservers.ts +++ b/src/hooks/useObservers.ts @@ -15,13 +15,13 @@ export type ObserversControl = { watch: (path: Pxth, observer: Observer) => () => void; /** * Watch all stock values. Returns cleanup function. - * @deprecated - use watchAllEffect instead + * @deprecated - use watchEffectAll instead */ watchAll: (observer: Observer) => () => void; /** Watch stock value. Returns cleanup function. Calls observer instantly. */ watchEffect: (path: Pxth, observer: Observer) => () => void; /** Watch all stock values. Returns cleanup function. Calls observer instantly. */ - watchAllEffect: (observer: Observer) => () => void; + watchEffectAll: (observer: Observer) => () => void; /** Check if value is observed or not. */ isObserved: (path: Pxth) => boolean; /** Notify all observers, which are children of specified path */ @@ -93,7 +93,7 @@ export const useObservers = (values: MutableRefObject): ObserversControl) => watchEffect(createPxth([]), observer), [watchEffect], ); @@ -146,7 +146,7 @@ export const useObservers = (values: MutableRefObject): ObserversControl = { */ export const useStock = ({ initialValues, debugName }: StockConfig): Stock => { const values = useLazyRef(() => cloneDeep(initialValues)); - const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchAllEffect, watchBatchUpdates, isObserved } = + const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchEffectAll, watchBatchUpdates, isObserved } = useObservers(values); const setValue = useCallback( @@ -80,7 +80,7 @@ export const useStock = ({ initialValues, debugName }: StockCo watch, watchAll, watchEffect, - watchAllEffect, + watchEffectAll, watchBatchUpdates, isObserved, debugName, @@ -95,7 +95,7 @@ export const useStock = ({ initialValues, debugName }: StockCo watch, watchAll, watchEffect, - watchAllEffect, + watchEffectAll, watchBatchUpdates, isObserved, debugName, diff --git a/test/hooks/useObservers.test.ts b/test/hooks/useObservers.test.ts index e510b79..bbfe62e 100644 --- a/test/hooks/useObservers.test.ts +++ b/test/hooks/useObservers.test.ts @@ -40,6 +40,20 @@ describe('Observer tests', () => { expect(observer).toBeCalled(); }); + it('should call observer instantly with "watchEffectAll"', () => { + const { result } = renderUseObserversHook({ + b: 'hello', + }); + + const observer = jest.fn(); + + act(() => { + result.current.watchEffectAll(observer); + }); + + expect(observer).toBeCalled(); + }); + it('Should call all values observer', async () => { const { result } = renderUseObserversHook(); diff --git a/test/typings/MappingProxy.test.ts b/test/typings/MappingProxy.test.ts index ac8bf68..5ba4405 100644 --- a/test/typings/MappingProxy.test.ts +++ b/test/typings/MappingProxy.test.ts @@ -54,6 +54,27 @@ describe('Mapping proxy', () => { expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual(['a', 'b', 'd']); }); + it('observe/stopObserving value - with "watchEffect"', () => { + const proxy = new MappingProxy( + { hello: createPxth(['a', 'b', 'c']), bye: createPxth(['a', 'b', 'd']) }, + createPxth(['asdf']), + ); + + const defaultObserve = jest.fn(); + const observer = jest.fn(); + + defaultObserve.mockReturnValue(0); + + proxy.watchEffect(createPxth(['asdf', 'hello']), observer, defaultObserve); + + expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual(['a', 'b', 'c']); + + defaultObserve.mockClear(); + + proxy.watchEffect(createPxth(['asdf', 'bye']), observer, defaultObserve); + expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual(['a', 'b', 'd']); + }); + it('observe/stopObserving (empty mapping path)', () => { const proxy = new MappingProxy(createPxth(['a', 'd', 'c']), createPxth(['asdf'])); diff --git a/test/utils/useInterceptors.test.ts b/test/utils/useInterceptors.test.ts index 3b8d947..d90f596 100644 --- a/test/utils/useInterceptors.test.ts +++ b/test/utils/useInterceptors.test.ts @@ -35,6 +35,18 @@ describe('hit cases', () => { expect(observer).toBeCalledTimes(1); expect(observer).toBeCalledWith('asdf'); }); + it('no proxy - with "watchEffect"', () => { + const [{ result }] = renderUseInterceptorsHook(defaultInitialValues); + + const observer = jest.fn(); + act(() => { + const cleanup = result.current.watchEffect(createPxth(['dest', 'bye']), observer); + cleanup(); + }); + + expect(observer).toBeCalledTimes(1); + expect(observer).toBeCalledWith('asdf'); + }); it('non activated proxy', () => { expect(() => renderUseInterceptorsHook(defaultInitialValues, new DummyProxy(createPxth(['asdf'])))).toThrow(); }); @@ -111,6 +123,30 @@ describe('proxy', () => { expect(getValue).toBeCalledTimes(1); }); + it('should call proxy functions - with "watchEffect"', () => { + const proxy = new DummyProxy(createPxth(['dest'])); + + const watchEffect: jest.Mock = jest.fn(() => () => {}); + + proxy.watchEffect = watchEffect; + proxy.activate(); + const [{ result }] = renderUseInterceptorsHook(defaultInitialValues, proxy); + + const observer = jest.fn(); + + act(() => { + const cleanup = result.current.watchEffect(createPxth(['dest']), observer); + const cleanup2 = result.current.watchEffect(createPxth(['asdf']), observer); + cleanup(); + cleanup2(); + }); + + expect(getPxthSegments(watchEffect.mock.calls[0][0])).toStrictEqual(['dest']); + expect(watchEffect).toBeCalledWith(expect.anything(), observer, expect.any(Function)); + expect(watchEffect).toBeCalledTimes(1); + expect(observer).toBeCalled(); + }); + it('should handle setValues / getValues properly', async () => { const proxy = new DummyProxy(createPxth(['dest'])); From b391f02df7e17304338907a2aaac084e93fd195a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandras=20=C5=A0ukelovi=C4=8D?= Date: Mon, 22 Jan 2024 17:36:40 +0200 Subject: [PATCH 3/5] Added more tests --- test/typings/MappingProxy.test.ts | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/test/typings/MappingProxy.test.ts b/test/typings/MappingProxy.test.ts index 5ba4405..7295ab5 100644 --- a/test/typings/MappingProxy.test.ts +++ b/test/typings/MappingProxy.test.ts @@ -171,6 +171,70 @@ describe('Mapping proxy', () => { expect(observer).toBeCalledWith(fullUser.personalData); }); + it('calling observer fns - with watchEffect', () => { + const fullUser = { + personalData: { + name: { + firstName: 'Hello', + lastName: 'World', + }, + birthday: new Date('2020.12.26'), + }, + registrationDate: new Date('2020.12.31'), + notify: true, + }; + const rawData = { + registeredUser: { + name: fullUser.personalData.name.firstName, + surname: fullUser.personalData.name.lastName, + dates: { + registration: fullUser.registrationDate, + }, + }, + dateOfBirth: fullUser.personalData.birthday, + }; + + const proxy = new MappingProxy(getUserMapSource(), createPxth(['registeredUser'])); + + const observers: Observer[] = []; + + const defaultObserve = jest.fn((path, observer) => { + observer(deepGet(rawData, path)); + observers.push(observer); + return () => observers.splice(observers.indexOf(observer), 1); + }); + const observer = jest.fn(); + + proxy.watchEffect( + createPxth(['registeredUser', 'personalData', 'name', 'firstName']), + observer, + defaultObserve, + ); + + expect(observer).toBeCalledWith(fullUser.personalData.name.firstName); + observer.mockClear(); + + expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual(['registeredUser', 'name']); + + defaultObserve.mockClear(); + + proxy.watchEffect(createPxth(['registeredUser', 'personalData', 'name']), observer, defaultObserve); + + expect(observer).toBeCalledWith(fullUser.personalData.name); + observer.mockClear(); + + expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual(['registeredUser']); + + defaultObserve.mockClear(); + + proxy.watchEffect(createPxth(['registeredUser', 'personalData']), observer, defaultObserve); + + expect(observer).toBeCalledWith(fullUser.personalData); + observer.mockClear(); + + expect(getPxthSegments(defaultObserve.mock.calls[0][0])).toStrictEqual([]); + }); + it('calling observer fns (complex cases)', () => { const fullData = { truck: { From 5bcc225029c4bd7738a141542a7e718734f8bb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandras=20=C5=A0ukelovi=C4=8D?= Date: Fri, 23 Feb 2024 14:59:50 +0200 Subject: [PATCH 4/5] Deleted "watchEffectAll" --- src/hooks/useObservers.ts | 10 +--------- src/hooks/useStock.ts | 4 +--- test/hooks/useObservers.test.ts | 14 -------------- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/hooks/useObservers.ts b/src/hooks/useObservers.ts index 5cda65a..22460d8 100644 --- a/src/hooks/useObservers.ts +++ b/src/hooks/useObservers.ts @@ -15,13 +15,11 @@ export type ObserversControl = { watch: (path: Pxth, observer: Observer) => () => void; /** * Watch all stock values. Returns cleanup function. - * @deprecated - use watchEffectAll instead + * @deprecated - use watchEffect instead */ watchAll: (observer: Observer) => () => void; /** Watch stock value. Returns cleanup function. Calls observer instantly. */ watchEffect: (path: Pxth, observer: Observer) => () => void; - /** Watch all stock values. Returns cleanup function. Calls observer instantly. */ - watchEffectAll: (observer: Observer) => () => void; /** Check if value is observed or not. */ isObserved: (path: Pxth) => boolean; /** Notify all observers, which are children of specified path */ @@ -93,11 +91,6 @@ export const useObservers = (values: MutableRefObject): ObserversControl) => watchEffect(createPxth([]), observer), - [watchEffect], - ); - const watchBatchUpdates = useCallback( (observer: Observer>) => { const key = observeBatchUpdates(observer); @@ -146,7 +139,6 @@ export const useObservers = (values: MutableRefObject): ObserversControl = { */ export const useStock = ({ initialValues, debugName }: StockConfig): Stock => { const values = useLazyRef(() => cloneDeep(initialValues)); - const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchEffectAll, watchBatchUpdates, isObserved } = + const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchBatchUpdates, isObserved } = useObservers(values); const setValue = useCallback( @@ -80,7 +80,6 @@ export const useStock = ({ initialValues, debugName }: StockCo watch, watchAll, watchEffect, - watchEffectAll, watchBatchUpdates, isObserved, debugName, @@ -95,7 +94,6 @@ export const useStock = ({ initialValues, debugName }: StockCo watch, watchAll, watchEffect, - watchEffectAll, watchBatchUpdates, isObserved, debugName, diff --git a/test/hooks/useObservers.test.ts b/test/hooks/useObservers.test.ts index bbfe62e..e510b79 100644 --- a/test/hooks/useObservers.test.ts +++ b/test/hooks/useObservers.test.ts @@ -40,20 +40,6 @@ describe('Observer tests', () => { expect(observer).toBeCalled(); }); - it('should call observer instantly with "watchEffectAll"', () => { - const { result } = renderUseObserversHook({ - b: 'hello', - }); - - const observer = jest.fn(); - - act(() => { - result.current.watchEffectAll(observer); - }); - - expect(observer).toBeCalled(); - }); - it('Should call all values observer', async () => { const { result } = renderUseObserversHook(); From f7198813e924cabae406d244793bd69c3051bae4 Mon Sep 17 00:00:00 2001 From: sirse Date: Fri, 5 Apr 2024 00:47:43 +0300 Subject: [PATCH 5/5] Changeset --- .changeset/fresh-owls-explain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-owls-explain.md diff --git a/.changeset/fresh-owls-explain.md b/.changeset/fresh-owls-explain.md new file mode 100644 index 0000000..9fea6b4 --- /dev/null +++ b/.changeset/fresh-owls-explain.md @@ -0,0 +1,5 @@ +--- +'stocked': minor +--- + +Create watchEffect function, deprecate watch & watchAll