Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversion to useExternalStore #99

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@
"bundlewatch": "bundlewatch"
},
"peerDependencies": {
"react": ">= 16.8.0",
"teaful-devtools": ">= 0.4.0"
"@types/use-sync-external-store": "^0.0.3",
"react": ">= 17.0.0",
"teaful-devtools": ">= 0.4.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependenciesMeta": {
"teaful-devtools": {
Expand Down Expand Up @@ -96,6 +98,7 @@
"@types/jest": "27.4.1",
"@types/react": "17.0.40",
"@types/react-dom": "17.0.13",
"@types/use-sync-external-store": "^0.0.3",
"@typescript-eslint/eslint-plugin": "5.15.0",
"@typescript-eslint/parser": "5.15.0",
"babel-jest": "27.5.1",
Expand All @@ -112,7 +115,8 @@
"microbundle": "0.14.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-test-renderer": "17.0.2"
"react-test-renderer": "17.0.2",
"use-sync-external-store": "^1.2.0"
},
"bugs": "https://github.com/teafuljs/teaful/issues"
}
97 changes: 58 additions & 39 deletions package/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useEffect, useReducer, createElement, ComponentClass, FunctionComponent} from 'react';
import type { Args, ArgsHoc, ExtraFn, Hoc, Listener, ListenersObj, ReducerFn, Result, Store, Subscription, Validator } from './types';
import {useEffect, createElement, ComponentClass, FunctionComponent} from 'react';
import {useSyncExternalStore} from 'use-sync-external-store/shim';
import type { Args, ArgsHoc, ExtraFn, Hoc, Listener, ListenersObj, Params, ReducerFn, Result, Store, Subscription, Validator } from './types';

let MODE_GET = 1;
let MODE_USE = 2;
Expand All @@ -10,16 +11,25 @@ let extras: ExtraFn<Store>[] = [];

export default function createStore<S extends Store>(
initial: S = {} as S,
callback?: Listener<S>
storeCallback?: Listener<S>
) {
// For extra functions
let subscription = createSubscription<S>();

// For useSyncExternalStore
const listeners = new Set<() => void>();

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function notifyListeners() {
listeners.forEach((listener) => listener());
}

// Initialize the store and callbacks
let allStore = initial;

// Add callback subscription
subscription._subscribe(DOT, callback);

/**
* Proxy validator that implements:
* - useStore hook proxy
Expand Down Expand Up @@ -55,10 +65,10 @@ export default function createStore<S extends Store>(
this._path.push(path);
return path === 'prototype' ? {} : new Proxy(target, validator);
},
apply(getMode: () => number, _: unknown, args: Args<S> & ArgsHoc<S>) {
apply(getMode: () => number, _: unknown, args: Args<S> | ArgsHoc<S>) {
let mode = getMode();
let param = args[0];
let callback = args[1];
let proxyCallback = args[1] as Listener<S> | undefined;
let path = this._path.slice();
this._path = [];

Expand All @@ -74,8 +84,14 @@ export default function createStore<S extends Store>(
// MODE_USE: let [store, update] = getStore()
// MODE_SET: setStore({ newStore: true })
if (!path.length) {
let updateAll = updateField();
if (mode === MODE_USE) useSubscription(DOT, callback);
let updateAll = updateField('', proxyCallback);
if (mode === MODE_USE) {
const current = useSyncExternalStore(
subscribe,
() => allStore,
);
return [current, updateAll];
}
if (mode === MODE_SET) return updateAll(param);
return [allStore, updateAll];
}
Expand All @@ -84,7 +100,7 @@ export default function createStore<S extends Store>(
// FRAGMENTED STORE:
// .................
let prop = path.join(DOT);
let update = updateField(prop);
let update = updateField(prop, proxyCallback);
let value = getField(prop);
let initializeValue = param !== undefined && !existProperty(path);

Expand All @@ -93,15 +109,27 @@ export default function createStore<S extends Store>(

if (initializeValue) {
value = param;
const prevStore = allStore;
allStore = setField(allStore, path, value);

if (proxyCallback) {
proxyCallback({ prevStore, store: allStore });
}
if (storeCallback) {
storeCallback({ prevStore, store: allStore });
}
}

// subscribe to the fragmented store
if (mode === MODE_USE) {
value = useSyncExternalStore(
subscribe,
() => getField(prop),
);

useEffect(() => {
if (initializeValue) update(value);
}, []);
useSubscription(DOT+prop, callback);
}

// MODE_GET: let [price, setPrice] = useStore.cart.price()
Expand All @@ -115,33 +143,14 @@ export default function createStore<S extends Store>(
let withStore = createProxy(MODE_WITH);
let setStore = createProxy(MODE_SET);

/**
* Hook to register a listener to force a render when the
* subscribed field changes.
* @param {string} path
* @param {function} callback
*/
function useSubscription(path: string, callback?: Listener<S>) {
// @ts-expect-error - useReducer as forceRender without rest of args
let forceRender = useReducer(() => [])[1];

useEffect(() => {
subscription._subscribe(path, forceRender);
subscription._subscribe(DOT, callback);
return () => {
subscription._unsubscribe(path, forceRender);
subscription._unsubscribe(DOT, callback);
};
}, [path]);
}

/**
* 1. Updates any field of the store
* 2. Notifies to all the involved subscribers
* @param {string} path
* @param {function} after
* @return {function} update
*/
function updateField(path = '') {
function updateField(path = '', after?: (params: Params<S>) => void) {
let fieldPath = Array.isArray(path) ? path : path.split(DOT);

return (newValue: unknown) => {
Expand All @@ -153,16 +162,25 @@ export default function createStore<S extends Store>(
}

allStore = path ?
// Update a field
setField(allStore, fieldPath, value) :
// Update all the store
value;
// Update a field
setField(allStore, fieldPath, value) :
// Update all the store
value;

// Notifying to all subscribers
subscription._notify(DOT+path, {
// Notify extra functions
subscription._notify(DOT + path, {
prevStore,
store: allStore,
});

if (after) {
after({ prevStore, store: allStore });
}
if (storeCallback) {
storeCallback({ prevStore, store: allStore });
}

notifyListeners();
};
}

Expand Down Expand Up @@ -207,6 +225,7 @@ export default function createStore<S extends Store>(

createStore.ext = (extra: ExtraFn<Store>) => extras.push(extra);

// Exists just for extra functions
function createSubscription<S extends Store>(): Subscription<S> {
let listeners: ListenersObj<S> = {};

Expand Down
17 changes: 11 additions & 6 deletions package/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type Subscription<S extends Store> = {
_notify(path: string, params: Params<S>): void;
}

export type ExtraFn<S> = (
export type ExtraFn<S extends Store> = (
res: Result<S>,
subscription: Subscription<S>
) => Extra;
Expand All @@ -39,9 +39,11 @@ export type Extra = {
}
export type ValueOf<T> = T[keyof T];

export type Validator<S> = ProxyHandler<ValueOf<Result<S>>> & Extra
export type Validator<
S extends Store
> = ProxyHandler<ValueOf<Result<S>>> & Extra

export type Hook<S> = (
export type Hook<S extends Store> = (
initial?: S,
onAfterUpdate?: Listener<S>
) => HookReturn<S>;
Expand All @@ -50,18 +52,21 @@ export type HookDry<S> = (initial?: S) => HookReturn<S>;

export type Hoc<S> = { store: HookReturn<S> };

export type Args<S> = [
export type Args<S extends Store> = [
param: S | ComponentClass<Hoc<S>>,
callback: Listener<S> | undefined,
]

export type ArgsHoc<S> = [
export type ArgsHoc<S extends Store> = [
component: ComponentClass<Hoc<S>>,
param: S,
callback: Listener<S> | undefined
]

export type HocFunc<S, R extends ComponentClass<any> = ComponentClass<any>> = (
export type HocFunc<
S extends Store,
R extends ComponentClass<any> = ComponentClass<any>
> = (
component: R,
initial?: S,
onAfterUpdate?: Listener<S>
Expand Down
39 changes: 27 additions & 12 deletions tests/onAfterUpdate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import createStore from '../package/index';
describe('onAfterUpdate callback', () => {
it('should be possible to remove an onAfterUpdate event when a component with useStore.test is unmounted', () => {
const callback = jest.fn();
let numMockCalls;

type Store = {
test?: Record<string, unknown> | undefined;
Expand All @@ -34,25 +35,30 @@ describe('onAfterUpdate callback', () => {
const update = getStore.test()[1];

expect(callback).toHaveBeenCalledTimes(0);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => getStore.mount()[1](false));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

// Updating twice to confirm that updates don't call the callback when the
// component with the useStore is unmounted
act(() => update({}));
act(() => update({}));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
});

it('should be possible to remove an onAfterUpdate event when a component with useStore is unmounted', () => {
const callback = jest.fn();
let numMockCalls;

type Store = {
test?: Record<string, unknown>;
Expand All @@ -77,23 +83,28 @@ describe('onAfterUpdate callback', () => {
const update = getStore.test()[1];

expect(callback).toHaveBeenCalledTimes(0);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => getStore.mount()[1](false));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
act(() => update({}));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
});

it('should be possible to remove an onAfterUpdate event when a component with withStore is unmounted', () => {
const callback = jest.fn();
let numMockCalls;

type Store = {
test?: Record<string, unknown>;
Expand Down Expand Up @@ -121,19 +132,23 @@ describe('onAfterUpdate callback', () => {
const update = getStore.test()[1];

expect(callback).toHaveBeenCalledTimes(0);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => getStore.mount()[1](false));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
numMockCalls = callback.mock.calls.length;

act(() => update({}));
act(() => update({}));
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledTimes(numMockCalls + 1);
});

it('should work via createStore', () => {
Expand Down
Loading