Skip to content

Commit

Permalink
Added watchEffect function in Stock (#163)
Browse files Browse the repository at this point in the history
* Added watchEffect function in Stock

* Added more tests

* Added more tests

* Deleted "watchEffectAll"

* Changeset

---------

Co-authored-by: sirse <[email protected]>
  • Loading branch information
AlexShukel and ArtiomTr authored Apr 4, 2024
1 parent c35fbf1 commit d3884eb
Show file tree
Hide file tree
Showing 11 changed files with 211 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-owls-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'stocked': minor
---

Create watchEffect function, deprecate watch & watchAll
26 changes: 22 additions & 4 deletions src/hooks/useObservers.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,10 +8,18 @@ import { ObserverArray, ObserverKey } from '../utils/ObserverArray';
import { useLazyRef } from '../utils/useLazyRef';

export type ObserversControl<T> = {
/** Watch stock value. Returns cleanup function. */
/**
* Watch stock value. Returns cleanup function.
* @deprecated - use watchEffect instead
*/
watch: <V>(path: Pxth<V>, observer: Observer<V>) => () => void;
/** Watch all stock values. Returns cleanup function. */
/**
* Watch all stock values. Returns cleanup function.
* @deprecated - use watchEffect instead
*/
watchAll: (observer: Observer<T>) => () => void;
/** Watch stock value. Returns cleanup function. Calls observer instantly. */
watchEffect: <V>(path: Pxth<V>, observer: Observer<V>) => () => void;
/** Check if value is observed or not. */
isObserved: <V>(path: Pxth<V>) => boolean;
/** Notify all observers, which are children of specified path */
Expand All @@ -23,7 +31,7 @@ export type ObserversControl<T> = {
};

/** Hook, wraps functionality of observers storage (add, remove, notify tree of observers, etc.) */
export const useObservers = <T>(): ObserversControl<T> => {
export const useObservers = <T>(values: MutableRefObject<T>): ObserversControl<T> => {
const observersMap = useRef<PxthMap<ObserverArray<unknown>>>(new PxthMap());
const batchUpdateObservers = useLazyRef<ObserverArray<BatchUpdate<T>>>(() => new ObserverArray());

Expand Down Expand Up @@ -74,6 +82,15 @@ export const useObservers = <T>(): ObserversControl<T> => {

const watchAll = useCallback((observer: Observer<T>) => watch(createPxth<T>([]), observer), [watch]);

const watchEffect = useCallback(
<V>(path: Pxth<V>, observer: Observer<V>) => {
observer(deepGet(values.current, path));
const key = observe(path, observer);
return () => stopObserving(path, key);
},
[observe, stopObserving, values],
);

const watchBatchUpdates = useCallback(
(observer: Observer<BatchUpdate<T>>) => {
const key = observeBatchUpdates(observer);
Expand Down Expand Up @@ -123,6 +140,7 @@ export const useObservers = <T>(): ObserversControl<T> => {
return {
watch,
watchAll,
watchEffect,
watchBatchUpdates,
isObserved,
notifySubTree,
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useStock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export type StockConfig<T extends object> = {
*/
export const useStock = <T extends object>({ initialValues, debugName }: StockConfig<T>): Stock<T> => {
const values = useLazyRef<T>(() => cloneDeep(initialValues));
const { notifySubTree, notifyAll, watch, watchAll, watchBatchUpdates, isObserved } = useObservers<T>();
const { notifySubTree, notifyAll, watch, watchAll, watchEffect, watchBatchUpdates, isObserved } =
useObservers<T>(values);

const setValue = useCallback(
<V>(path: Pxth<V>, action: SetStateAction<V>) => {
Expand Down Expand Up @@ -78,6 +79,7 @@ export const useStock = <T extends object>({ initialValues, debugName }: StockCo
resetValues,
watch,
watchAll,
watchEffect,
watchBatchUpdates,
isObserved,
debugName,
Expand All @@ -86,14 +88,15 @@ export const useStock = <T extends object>({ initialValues, debugName }: StockCo
[
getValue,
getValues,
debugName,
setValue,
setValues,
resetValues,
watch,
watchAll,
watchEffect,
watchBatchUpdates,
isObserved,
debugName,
],
);

Expand Down
6 changes: 3 additions & 3 deletions src/hooks/useStockValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ export const useStockValue = <V, T extends object = object>(
): V => {
const stock = useStockContext(customStock, proxy);

const { watch, getValue } = stock;
const { watchEffect, getValue } = stock;

const [, forceUpdate] = useReducer((val) => val + 1, 0);

const value = useLazyRef<V>(() => getValue(path));

useEffect(
() =>
watch(path, (newValue) => {
watchEffect(path, (newValue) => {
value.current = newValue;
forceUpdate();
}),
[path, watch, value],
[path, watchEffect, value],
);

return value.current;
Expand Down
9 changes: 9 additions & 0 deletions src/typings/MappingProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export class MappingProxy<T> extends StockProxy<T> {
return defaultWatch(normalPath, (value) => observer(this.mapValue(value, path, normalPath) as V));
};

public watchEffect = <V>(
path: Pxth<V>,
observer: Observer<V>,
defaultWatchEffect: <U>(path: Pxth<U>, observer: Observer<U>) => () => void,
) => {
const normalPath = this.getNormalPath(path);
return defaultWatchEffect(normalPath, (value) => observer(this.mapValue(value, path, normalPath) as V));
};

public getValue = <V>(path: Pxth<V>, defaultGetValue: <U>(path: Pxth<U>) => U): V => {
const normalPath = this.getNormalPath(path);
return this.mapValue(defaultGetValue(normalPath), path, normalPath) as V;
Expand Down
12 changes: 11 additions & 1 deletion src/typings/StockProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@ export abstract class StockProxy<T> {
defaultGetValue: <U>(path: Pxth<U>) => U,
) => void;

/** Function for watching proxied value. Should return cleanup. */
/**
* Function for watching proxied value. Should return cleanup.
* @deprecated
*/
public abstract watch: <V>(
path: Pxth<V>,
observer: Observer<V>,
defaultWatch: <U>(path: Pxth<U>, observer: Observer<U>) => () => void,
) => () => void;

/** Function for watching proxied value. Should return cleanup. Calls observer instantly. */
public abstract watchEffect: <V>(
path: Pxth<V>,
observer: Observer<V>,
defaultWatchEffect: <U>(path: Pxth<U>, observer: Observer<U>) => () => void,
) => () => void;

/** Function to access proxied value. */
public abstract getValue: <V>(path: Pxth<V>, defaultGetValue: <U>(path: Pxth<U>) => U) => V;

Expand Down
15 changes: 14 additions & 1 deletion src/utils/useInterceptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const intercept = <T extends (...args: any[]) => any>(

/** Intercepts stock's `observe`, `stopObserving` and `setValue` functions, if proxy is provided. */
export const useInterceptors = <T extends object>(stock: Stock<T>, proxy?: StockProxy<unknown>): Stock<T> => {
const { watch, setValue, getValue, setValues, getValues } = stock;
const { watch, watchEffect, setValue, getValue, setValues, getValues } = stock;

useEffect(
() =>
Expand All @@ -57,6 +57,18 @@ export const useInterceptors = <T extends object>(stock: Stock<T>, proxy?: Stock
[watch, proxy],
);

const interceptedWatchEffect = useCallback(
<V>(path: Pxth<V>, observer: Observer<V>) =>
intercept(
proxy,
path as Pxth<unknown>,
watchEffect,
(path: Pxth<V>, observer: Observer<V>) => proxy!.watchEffect<V>(path, observer, watchEffect),
[path, observer],
),
[watchEffect, proxy],
);

const interceptedSetValue = useCallback(
<V>(path: Pxth<V>, value: SetStateAction<V>) =>
intercept(
Expand Down Expand Up @@ -114,6 +126,7 @@ export const useInterceptors = <T extends object>(stock: Stock<T>, proxy?: Stock
return {
...stock,
watch: interceptedWatch,
watchEffect: interceptedWatchEffect,
setValue: interceptedSetValue,
getValue: interceptedGetValue,
getValues: interceptedGetValues,
Expand Down
1 change: 1 addition & 0 deletions test/DummyProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export class DummyProxy extends StockProxy<unknown> {
public getNormalPath = <V>(path: Pxth<V>) => path;
public setValue = () => {};
public watch = () => () => {};
public watchEffect = () => () => {};
public getValue = <V>(path: Pxth<V>, defaultGetValue: <U>(path: Pxth<U>) => U) => defaultGetValue(path) as V;
}
21 changes: 20 additions & 1 deletion test/hooks/useObservers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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();

Expand Down
85 changes: 85 additions & 0 deletions test/typings/MappingProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']));

Expand Down Expand Up @@ -150,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<RegisteredUser>(getUserMapSource(), createPxth(['registeredUser']));

const observers: Observer<unknown>[] = [];

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: {
Expand Down
36 changes: 36 additions & 0 deletions test/utils/useInterceptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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<any, any> = 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']));

Expand Down

0 comments on commit d3884eb

Please sign in to comment.