From 5ea36c7349591c522992d06f324cfc55ba332fbe Mon Sep 17 00:00:00 2001 From: Kushan Joshi <0o3ko0@gmail.com> Date: Sun, 8 Oct 2023 16:52:30 -0400 Subject: [PATCH] feat: improve action api --- misc/__test__/base.test.ts | 12 +- packages/core/src/__tests__/actions.test.ts | 5 +- .../core/src/__tests__/store-state.test.ts | 24 +-- packages/core/src/__tests__/store.test.ts | 7 +- .../src/effect/__tests__/effect-run.test.ts | 10 +- .../core/src/effect/__tests__/effect.test.ts | 18 +-- .../core/src/effect/__tests__/ref.test.ts | 10 +- .../src/helpers/__tests__/validations.test.ts | 6 +- packages/core/src/helpers/validations.ts | 14 +- packages/core/src/index.ts | 8 + .../core/src/slice/__tests__/field.test.ts | 22 +-- packages/core/src/slice/__tests__/key.test.ts | 46 ++---- .../core/src/slice/__tests__/slice.test.ts | 138 +++++++++++++++--- packages/core/src/slice/key.ts | 21 ++- packages/core/src/slice/slice.ts | 87 ++++++++--- packages/core/src/store.ts | 2 +- packages/core/src/types.ts | 2 + packages/react/src/__tests__/store.test.tsx | 6 +- packages/react/src/__tests__/type.test.tsx | 93 ++++++++++++ packages/react/src/react.ts | 30 ++-- packages/react/src/types.ts | 9 ++ 21 files changed, 385 insertions(+), 185 deletions(-) create mode 100644 packages/react/src/__tests__/type.test.tsx create mode 100644 packages/react/src/types.ts diff --git a/misc/__test__/base.test.ts b/misc/__test__/base.test.ts index 54b022c..1ad36d2 100644 --- a/misc/__test__/base.test.ts +++ b/misc/__test__/base.test.ts @@ -8,22 +8,16 @@ import { } from '../type-helpers'; const dep1Key = createKey('dep1Key', []); -const dep1Slice = dep1Key.slice({ - fields: {}, -}); +const dep1Slice = dep1Key.slice({}); const dep2Key = createKey('dep2Key', []); -const dep2Slice = dep2Key.slice({ - fields: {}, -}); +const dep2Slice = dep2Key.slice({}); const key = createKey('myKey', [dep1Slice, dep2Slice]); const field1 = key.field(1); -const mySlice = key.slice({ - fields: {}, -}); +const mySlice = key.slice({}); const store = createStore({ slices: [dep1Slice, dep2Slice, mySlice], diff --git a/packages/core/src/__tests__/actions.test.ts b/packages/core/src/__tests__/actions.test.ts index a92f9fa..9382a95 100644 --- a/packages/core/src/__tests__/actions.test.ts +++ b/packages/core/src/__tests__/actions.test.ts @@ -22,7 +22,8 @@ describe('actions', () => { const counterNegative = key.field(-1); const counterSlice = key.slice({ - fields: { counter, counterNegative }, + counter, + counterNegative, }); function increment() { @@ -186,7 +187,7 @@ describe('actions', () => { }); } const baseSlice = key.slice({ - fields: { base }, + base, }); test('should work', () => { diff --git a/packages/core/src/__tests__/store-state.test.ts b/packages/core/src/__tests__/store-state.test.ts index 6c1924f..6ac9c19 100644 --- a/packages/core/src/__tests__/store-state.test.ts +++ b/packages/core/src/__tests__/store-state.test.ts @@ -14,18 +14,14 @@ const sliceOneKey = createKey('sliceOne', []); const keyOne = sliceOneKey.field('valueOne'); const sliceOne = sliceOneKey.slice({ - fields: { - keyOne, - }, + keyOne, }); const sliceTwoKey = createKey('sliceTwo', []); const keyTwo = sliceTwoKey.field('valueTwo'); const sliceTwo = sliceTwoKey.slice({ - fields: { - keyTwo, - }, + keyTwo, }); const updateKeyOneSliceOne = (val: string) => { @@ -104,9 +100,7 @@ describe('StoreState Slice and Transaction Operations', () => { const immutableField = immutableSliceKey.field(fixedState); const immutableSlice = immutableSliceKey.slice({ - fields: { - immutableField: immutableField, - }, + immutableField: immutableField, }); function nonMutatingAction(inputNumber: number) { @@ -138,9 +132,7 @@ describe('StoreState Slice and Transaction Operations', () => { const myField = mySliceKey.field(fixedState); const mySlice = mySliceKey.slice({ - fields: { - myField: myField, - }, + myField: myField, }); function nonMutatingAction(inputNumber: number) { @@ -177,18 +169,14 @@ describe('StoreState Slice and Transaction Operations', () => { const counterA = sliceAKey.field(1); const sliceA = sliceAKey.slice({ - fields: { - counter: counterA, - }, + counter: counterA, }); const sliceBKey = createKey('sliceB', [sliceA]); const counterB = sliceBKey.field(1); const sliceB = sliceBKey.slice({ - fields: { - counter: counterB, - }, + counter: counterB, }); function actionIncrementCounterA() { diff --git a/packages/core/src/__tests__/store.test.ts b/packages/core/src/__tests__/store.test.ts index d0808d7..36ba1b8 100644 --- a/packages/core/src/__tests__/store.test.ts +++ b/packages/core/src/__tests__/store.test.ts @@ -14,9 +14,10 @@ const key = createKey('mySliceName', []); const counter = key.field(0); const counterNegative = key.field(-1); -const counterSlice = key.slice({ - fields: { counter, counterNegative }, -}); +const increment = () => { + return counter.update((val) => val + 1); +}; +const counterSlice = key.slice({ counter, counterNegative, increment }); afterEach(() => { testCleanup(); diff --git a/packages/core/src/effect/__tests__/effect-run.test.ts b/packages/core/src/effect/__tests__/effect-run.test.ts index 20665d7..a1e62c2 100644 --- a/packages/core/src/effect/__tests__/effect-run.test.ts +++ b/packages/core/src/effect/__tests__/effect-run.test.ts @@ -8,19 +8,15 @@ const setup = () => { const sliceAKey = createKey('slice1', []); const fooField = sliceAKey.field('bar'); const sliceA = sliceAKey.slice({ - fields: { - foo: fooField, - }, + foo: fooField, }); const sliceBKey = createKey('slice2', []); const sliceBField = sliceBKey.field('bar'); const sliceBOtherField = sliceBKey.field('bizz'); const sliceB = sliceBKey.slice({ - fields: { - sliceBField: sliceBField, - sliceBOtherField: sliceBOtherField, - }, + sliceBField: sliceBField, + sliceBOtherField: sliceBOtherField, }); const store = createStore({ diff --git a/packages/core/src/effect/__tests__/effect.test.ts b/packages/core/src/effect/__tests__/effect.test.ts index 4148aef..8c6ac67 100644 --- a/packages/core/src/effect/__tests__/effect.test.ts +++ b/packages/core/src/effect/__tests__/effect.test.ts @@ -34,19 +34,15 @@ const sliceAField1 = sliceAKey.field('value:sliceAField1'); const sliceAField2 = sliceAKey.field('value:sliceAField2'); const sliceA = sliceAKey.slice({ - fields: { - sliceAField1, - sliceAField2, - }, + sliceAField1, + sliceAField2, }); const sliceBKey = createKey('sliceB', []); const sliceBField1 = sliceBKey.field('value:sliceBField1'); const sliceB = sliceBKey.slice({ - fields: { - sliceBField1, - }, + sliceBField1, }); const sliceCDepBKey = createKey('sliceCDepB', [sliceB]); @@ -61,11 +57,9 @@ const sliceCDepBSelector2 = sliceCDepBKey.derive((state) => { }); const sliceCDepB = sliceCDepBKey.slice({ - fields: { - sliceCDepBField, - sliceCDepBSelector1, - sliceCDepBSelector2, - }, + sliceCDepBField, + sliceCDepBSelector1, + sliceCDepBSelector2, }); describe('effect with store', () => { diff --git a/packages/core/src/effect/__tests__/ref.test.ts b/packages/core/src/effect/__tests__/ref.test.ts index cb76756..4645c68 100644 --- a/packages/core/src/effect/__tests__/ref.test.ts +++ b/packages/core/src/effect/__tests__/ref.test.ts @@ -18,19 +18,15 @@ const sliceAField1 = sliceAKey.field('value:sliceAField1'); const sliceAField2 = sliceAKey.field('value:sliceAField2'); const sliceA = sliceAKey.slice({ - fields: { - sliceAField1, - sliceAField2, - }, + sliceAField1, + sliceAField2, }); const sliceBKey = createKey('sliceB', []); const sliceBField1 = sliceBKey.field('value:sliceBField1'); const sliceB = sliceBKey.slice({ - fields: { - sliceBField1, - }, + sliceBField1, }); beforeEach(() => { diff --git a/packages/core/src/helpers/__tests__/validations.test.ts b/packages/core/src/helpers/__tests__/validations.test.ts index bd6484f..5bd5a35 100644 --- a/packages/core/src/helpers/__tests__/validations.test.ts +++ b/packages/core/src/helpers/__tests__/validations.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from '@jest/globals'; -import { AnySlice } from '../../slice/slice'; import { circularCheck, findDuplications, validateSlices, } from '../validations'; +import { Slice } from '../../slice/slice'; -const createSlice = ({ sliceId, dependencies }: any): AnySlice => { +const createSlice = ({ sliceId, dependencies }: any): Slice => { return { sliceId, dependencies, - } as AnySlice; + } as Slice; }; describe('Slice validation', () => { diff --git a/packages/core/src/helpers/validations.ts b/packages/core/src/helpers/validations.ts index 2baebeb..d2241ef 100644 --- a/packages/core/src/helpers/validations.ts +++ b/packages/core/src/helpers/validations.ts @@ -1,13 +1,13 @@ -import { AnySlice } from '../slice/slice'; +import { Slice } from '../slice/slice'; -export function validateSlices(slices: AnySlice[]) { +export function validateSlices(slices: Slice[]) { checkUniqDependency(slices); checkUniqueSliceId(slices); checkDependencyOrder(slices); circularCheck(slices); } -function checkUniqDependency(slices: AnySlice[]) { +function checkUniqDependency(slices: Slice[]) { for (const slice of slices) { const dependencies = slice.dependencies; @@ -23,7 +23,7 @@ function checkUniqDependency(slices: AnySlice[]) { } } -export function checkUniqueSliceId(slices: AnySlice[]) { +export function checkUniqueSliceId(slices: Slice[]) { const dups = checkUnique(slices.map((s) => s.sliceId)); if (dups) { @@ -56,11 +56,11 @@ export function findDuplications(arr: T[]): T[] { return [...dupes]; } -export function circularCheck(slices: AnySlice[]) { +export function circularCheck(slices: Slice[]) { const stack = new Set(); const visited = new Set(); - const checkCycle = (slice: AnySlice): boolean => { + const checkCycle = (slice: Slice): boolean => { const sliceId = slice.sliceId; if (stack.has(sliceId)) { @@ -100,7 +100,7 @@ export function circularCheck(slices: AnySlice[]) { } } -function checkDependencyOrder(slices: AnySlice[]) { +function checkDependencyOrder(slices: Slice[]) { let seenSliceIds = new Set(); for (const slice of slices) { const dependencies = slice.dependencies; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1871bec..c571d50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,9 +4,17 @@ export { createStore } from './store'; export { DerivedField, StateField } from './slice/field'; export { ref } from './effect/ref'; export { Slice } from './slice/slice'; + export type { BaseField } from './slice/field'; export type { Effect, EffectStore, EffectScheduler } from './effect/effect'; export type { IfSubset } from './types'; export type { Key } from './slice/key'; export type { Store } from './store'; export type { StoreState } from './store-state'; + +// for internal packages only +export type { + InferSliceFieldState as _InferSliceFieldState, + ExposedSliceFieldNames as _ExposedSliceFieldNames, +} from './slice/slice'; +export type { AnyExternal as _AnyExternal } from './slice/key'; diff --git a/packages/core/src/slice/__tests__/field.test.ts b/packages/core/src/slice/__tests__/field.test.ts index 8c637e3..0e75e8f 100644 --- a/packages/core/src/slice/__tests__/field.test.ts +++ b/packages/core/src/slice/__tests__/field.test.ts @@ -9,7 +9,7 @@ import { import { testCleanup } from '../../helpers/test-cleanup'; import { createKey } from '../key'; import { createStore } from '../../store'; -import { AnySlice, Slice } from '../slice'; +import { Slice } from '../slice'; import { StoreState } from '../../store-state'; import { Transaction } from '../../transaction'; import { expectType } from '../../types'; @@ -18,7 +18,7 @@ beforeEach(() => { testCleanup(); }); -type GetStoreStateFromSliceName = TSlice extends Slice< +type GetStoreStateFromSliceName = TSlice extends Slice< any, infer TSliceName, any @@ -30,9 +30,7 @@ describe('internal fields', () => { test('internal field should be updated', () => { const key = createKey('mySliceName', []); const counter = key.field(0); - const counterSlice = key.slice({ - fields: {}, - }); + const counterSlice = key.slice({}); function updateCounter(state: number) { return counter.update(state + 1); @@ -67,10 +65,8 @@ describe('internal fields', () => { }); const counterSlice = key.slice({ - fields: { - myName, - externalDerivedOnCounter, - }, + myName, + externalDerivedOnCounter, }); function updateCounter() { @@ -169,17 +165,13 @@ describe('internal fields', () => { const mySliceKey = createKey('mySlice', []); const aField = mySliceKey.field(1); const mySlice = mySliceKey.slice({ - fields: { - a: aField, - }, + a: aField, }); const mySlice2Key = createKey('mySlice2', [mySlice]); const aField2 = mySlice2Key.field(1); const mySlice2 = mySlice2Key.slice({ - fields: { - a: aField2, - }, + a: aField2, }); // type checks diff --git a/packages/core/src/slice/__tests__/key.test.ts b/packages/core/src/slice/__tests__/key.test.ts index c6d6246..bfc9814 100644 --- a/packages/core/src/slice/__tests__/key.test.ts +++ b/packages/core/src/slice/__tests__/key.test.ts @@ -18,9 +18,7 @@ describe('SliceKey Mechanism', () => { const fieldA = sliceKeyA.field(1); const sliceA = sliceKeyA.slice({ - fields: { - valueA: fieldA, - }, + valueA: fieldA, }); const initialState = StoreState.create({ @@ -48,27 +46,21 @@ describe('SliceKey Mechanism', () => { const sliceKeyA = createKey('SliceA', []); const fieldA = sliceKeyA.field(1); const sliceA = sliceKeyA.slice({ - fields: { - valueA: fieldA, - }, + valueA: fieldA, }); // SliceB const sliceKeyB = createKey('SliceB', []); const fieldB = sliceKeyB.field(1); const sliceB = sliceKeyB.slice({ - fields: { - valueB: fieldB, - }, + valueB: fieldB, }); // SliceC with dependency on SliceA const sliceKeyC = createKey('SliceC', [sliceA]); const fieldC = sliceKeyC.field(15); const sliceC = sliceKeyC.slice({ - fields: { - valueC: fieldC, - }, + valueC: fieldC, }); const storeState = StoreState.create({ @@ -137,14 +129,12 @@ describe('SliceKey Mechanism', () => { }, ); - const sliceA = sliceKeyA.slice({ fields: { derivedSelectorA } }); + const sliceA = sliceKeyA.slice({ derivedSelectorA }); const sliceKeyB = createKey('mySliceB', []); const fieldB = sliceKeyB.field(1); const sliceB = sliceKeyB.slice({ - fields: { - fieldB: fieldB, - }, + fieldB: fieldB, }); function updateFieldB() { @@ -173,7 +163,7 @@ describe('SliceKey Mechanism', () => { test('should create a new instance for separately created states', () => { const sliceKeyA = createKey('mySliceA', []); const fieldA = sliceKeyA.field(1); - const sliceA = sliceKeyA.slice({ fields: {} }); + const sliceA = sliceKeyA.slice({}); const initialState = StoreState.create({ slices: [sliceA], }); @@ -208,7 +198,7 @@ describe('SliceKey Mechanism', () => { test('should recognize when equality check returns false', () => { const sliceKeyA = createKey('mySliceA', []); const fieldA = sliceKeyA.field(1); - const sliceA = sliceKeyA.slice({ fields: {} }); + const sliceA = sliceKeyA.slice({}); const initialState = StoreState.create({ slices: [sliceA], }); @@ -247,9 +237,7 @@ describe('SliceKey Mechanism', () => { }); const mySliceA = mySliceKeyA.slice({ - fields: { - selectorA, - }, + selectorA, }); const storeState = StoreState.create({ @@ -280,10 +268,8 @@ describe('SliceKey Mechanism', () => { ); const mySliceA = mySliceKeyA.slice({ - fields: { - a: aField, - selectorA, - }, + a: aField, + selectorA, }); const mySliceKeyB = createKey('mySliceB', [mySliceA]); @@ -295,10 +281,8 @@ describe('SliceKey Mechanism', () => { }; }); const mySliceB = mySliceKeyB.slice({ - fields: { - b: bField, - selectorB, - }, + b: bField, + selectorB, }); const mySliceKeyC = createKey('mySliceC', [mySliceA, mySliceB]); @@ -314,9 +298,7 @@ describe('SliceKey Mechanism', () => { }; }); const mySliceC = mySliceKeyC.slice({ - fields: { - selectorC, - }, + selectorC, }); let incrementA: () => Transaction<'mySliceA', never>; diff --git a/packages/core/src/slice/__tests__/slice.test.ts b/packages/core/src/slice/__tests__/slice.test.ts index c961296..954491c 100644 --- a/packages/core/src/slice/__tests__/slice.test.ts +++ b/packages/core/src/slice/__tests__/slice.test.ts @@ -1,21 +1,18 @@ -import { beforeEach, describe, it, test } from '@jest/globals'; +import { beforeEach, describe, expect, it, test } from '@jest/globals'; import { createKey } from '../key'; import { IfEquals, expectType } from '../../types'; -import { - AnySlice, - InferDepNameFromSlice, - InferSliceNameFromSlice, - Slice, -} from '../slice'; +import { InferDepNameFromSlice, Slice } from '../slice'; import { StateField } from '../field'; import { testCleanup } from '../../helpers/test-cleanup'; import { StoreState } from '../../store-state'; +import { Transaction } from '../../transaction'; +import { EffectStore } from '../../effect/effect'; beforeEach(() => { testCleanup(); }); -type GetStoreStateFromSliceName = TSlice extends Slice< +type GetStoreStateFromSliceName = TSlice extends Slice< any, infer TSliceName, any @@ -27,26 +24,32 @@ describe('slice', () => { describe('types and setup', () => { const mySliceKey = createKey('mySlice', []); const aField = mySliceKey.field(1); + + function incrementAField(fakeInput: number) { + return aField.update((a) => a + 1); + } + + function decrementAField(fakeInput: string) { + return aField.update((a) => a - 1); + } + const mySlice = mySliceKey.slice({ - fields: { - a: aField, - }, + a: aField, + incrementAField, + decrementAField, }); const mySlice2Key = createKey('mySlice2', [mySlice]); const aField2 = mySlice2Key.field(1); const mySlice2 = mySlice2Key.slice({ - fields: { - a: aField2, - }, + a: aField2, }); describe('dependencies', () => { it('should have correct types', () => { - expectType< - Slice<{ a: StateField }, 'mySlice', never>, - typeof mySlice - >(mySlice); + expect(mySlice).toBeInstanceOf(Slice); + + mySlice satisfies Slice<{ a: StateField }, 'mySlice', never>; type DepName = InferDepNameFromSlice; type Match = IfEquals; @@ -58,10 +61,74 @@ describe('slice', () => { type Match = IfEquals<'mySlice', DepName, true, false>; let result: Match = true as const; + mySlice2 satisfies Slice< + { a: StateField }, + 'mySlice2', + 'mySlice' + >; + }); + }); + + describe('actions', () => { + test('actions work', () => { + expect(mySlice.incrementAField).toBeDefined(); + expect(mySlice.decrementAField).toBeDefined(); + + expect(mySlice.actions.decrementAField).toBeDefined(); + expect(mySlice.actions.incrementAField).toBeDefined(); + expectType< + (p: number) => Transaction<'mySlice', never>, + typeof mySlice.incrementAField + >(mySlice.incrementAField); + + expectType< + (p: number) => Transaction<'mySlice', never>, + typeof mySlice.actions.incrementAField + >(mySlice.actions.incrementAField); + + expectType< + (p: string) => Transaction<'mySlice', never>, + typeof mySlice.decrementAField + >(mySlice.decrementAField); + expectType< - Slice<{ a: StateField }, 'mySlice2', 'mySlice'>, - typeof mySlice2 - >(mySlice2); + (p: string) => Transaction<'mySlice', never>, + typeof mySlice.actions.decrementAField + >(mySlice.actions.decrementAField); + }); + + test('throws error if slice action overlaps with a known api', () => { + const mySliceKey = createKey('mySlice', []); + + const fieldA = mySliceKey.field(1); + + const get = () => fieldA.update((a) => a + 1); + + expect(() => { + mySliceKey.slice({ + fieldA, + get, + }); + }).toThrowError( + /Invalid action name "get" as at it conflicts with a known property with the same name on the slice./, + ); + }); + + test('throws error if slice action overlaps with a known api', () => { + const mySliceKey = createKey('mySlice', []); + + const fieldA = mySliceKey.field(1); + + const sliceId = () => fieldA.update((a) => a + 1); + + expect(() => { + mySliceKey.slice({ + fieldA, + sliceId, + }); + }).toThrowError( + /Invalid action name "sliceId" as at it conflicts with a known property with the same name on the slice./, + ); }); }); @@ -83,6 +150,35 @@ describe('slice', () => { let storeState: StoreState<'mySlice2' | 'mySlice'> = {} as any; let result = mySlice.get(storeState); }; + + () => { + let storeState: StoreState<'mySlice2' | 'mySlice'> = {} as any; + const result = mySlice.get(storeState); + type Keys = keyof typeof result; + + const a: Keys = 'a'; + + // @ts-expect-error - expected to fail as b is not a key + const b: Keys = 'b'; + }; + + () => { + let store: EffectStore = {} as any; + + let storeState: StoreState<'mySlice2' | 'mySlice'> = {} as any; + // @ts-expect-error - expected to fail incrementAField is an action + mySlice.get(storeState).incrementAField; + + mySlice.track(store).a; + + // @ts-expect-error - expected to fail incrementAField is an action + mySlice.track(store).incrementAField; + + mySlice.trackField(store, 'a'); + + // @ts-expect-error - expected to fail incrementAField is an action + mySlice.trackField(store, 'incrementAField'); + }; }); }); }); diff --git a/packages/core/src/slice/key.ts b/packages/core/src/slice/key.ts index aaebd65..c515aea 100644 --- a/packages/core/src/slice/key.ts +++ b/packages/core/src/slice/key.ts @@ -10,18 +10,21 @@ import type { StoreState } from '../store-state'; import { Transaction } from '../transaction'; import type { FieldId, NoInfer } from '../types'; import { Slice } from './slice'; -import type { AnySlice, InferSliceNameFromSlice } from './slice'; +import type { InferSliceActions, InferSliceNameFromSlice } from './slice'; /** * @param name - The name of the slice. * @param dependencies - An array of slices that this slice depends on. */ -export function createKey( +export function createKey( name: TName, dependencies: TDepSlice[], ) { return new Key>(name, dependencies); } +export type AnyAction = (...args: any) => Transaction; +export type AnyExternal = Record>; + export class Key { constructor( public readonly name: TName, @@ -58,22 +61,18 @@ export class Key { return this.registerField(new StateField(initialValue, this, options)); } - slice>>({ - fields, - actions, - }: { - fields: TFieldsSpec; - actions?: (...args: any) => Transaction; - }): Slice { + slice( + external: TExternal, + ): Slice & InferSliceActions { if (this._slice) { throwValidationError( `Slice "${this.name}" already exists. A key can only be used to create one slice.`, ); } - this._slice = new Slice(this.name, fields, this); + this._slice = Slice.create(this.name, external, this); - return this._slice; + return this._slice as any; } /** diff --git a/packages/core/src/slice/slice.ts b/packages/core/src/slice/slice.ts index 0553fa0..38ca68f 100644 --- a/packages/core/src/slice/slice.ts +++ b/packages/core/src/slice/slice.ts @@ -1,5 +1,5 @@ import type { EffectStore } from '../effect/effect'; -import { type BaseField, DerivedField } from './field'; +import { BaseField, DerivedField } from './field'; import { sliceIdCounters } from '../helpers/id-generation'; import { throwValidationError } from '../helpers/throw-error'; import type { StoreState } from '../store-state'; @@ -8,16 +8,26 @@ import type { FieldId, IfSubsetOfState, IfSubsetEffectStore, + Simplify, } from '../types'; -import type { Key } from './key'; +import type { AnyAction, AnyExternal, Key } from './key'; + +export type InferSliceFieldState = { + // key mapping + [K in keyof T as T[K] extends BaseField + ? K + : never]: T[K] extends BaseField ? T : never; + // ^^ value mapping +}; -type MapSliceState>> = { - [K in keyof TFieldsSpec]: TFieldsSpec[K] extends BaseField - ? T - : never; +export type InferSliceActions = { + // key mapping + [K in keyof T as T[K] extends AnyAction ? K : never]: T[K]; + // ^^ value mapping }; -export type AnySlice = Slice; +export type ExposedSliceFieldNames = + keyof InferSliceFieldState; export type InferSliceNameFromSlice = T extends Slice< any, @@ -32,7 +42,7 @@ export type InferDepNameFromSlice = T extends Slice : never; export class Slice< - TFieldsSpec extends Record> = any, + TExternal extends AnyExternal = any, TName extends string = any, TDep extends string = any, > { @@ -43,30 +53,59 @@ export class Slice< // @internal private fieldNameToField: Record> = {}; + public actions: InferSliceActions; + get dependencies(): Slice[] { return this._key.dependencies; } + static create< + TExternal extends AnyExternal, + TName extends string, + TDep extends string, + >( + name: TName, + external: TExternal, + key: Key, + ): Slice & InferSliceActions { + return new Slice(name, external, key) as any; + } + // @internal - constructor( + protected constructor( public readonly name: TName, - externalFieldSpec: TFieldsSpec, + external: TExternal, // @internal public readonly _key: Key, ) { this.sliceId = sliceIdCounters.generate(name); - for (const [fieldName, field] of Object.entries(externalFieldSpec)) { + this.actions = {} as any; + for (const [key, val] of Object.entries(external)) { + if (!(val instanceof BaseField)) { + if (key in this) { + throwValidationError( + `Invalid action name "${key}" as at it conflicts with a known property with the same name on the slice.`, + ); + } + + (this.actions as any)[key] = val; + continue; + } + + const field = val; if (!_key._fields.has(field)) { - throwValidationError(`Field "${fieldName}" was not found.`); + throwValidationError(`Field "${key}" was not found.`); } - field.name = fieldName; - this.fieldNameToField[fieldName] = field; + field.name = key; + this.fieldNameToField[key] = field; } + + Object.assign(this, this.actions); } get>( storeState: IfSubsetOfState, - ): MapSliceState { + ): Simplify> { const existing = this.getCache.get(storeState); if (existing) { @@ -128,30 +167,36 @@ export class Slice< /** * Get a field value from the slice state. Slightly faster than `get`. */ - getField>( + getField< + T extends keyof InferSliceFieldState, + TState extends StoreState, + >( storeState: IfSubsetOfState, fieldName: T, - ): MapSliceState[T] { + ): InferSliceFieldState[T] { return this._getFieldByName(fieldName as string).get(storeState) as any; } track( store: IfSubsetEffectStore, - ): MapSliceState { + ): Simplify> { return new Proxy(this.get(store.state), { get: (target, prop: string, receiver) => { return this._getFieldByName(prop).track(store); }, - }); + }) as any; } /** * Similar to `track`, but only tracks a single field. */ - trackField( + trackField< + T extends ExposedSliceFieldNames, + TEStore extends EffectStore, + >( store: IfSubsetEffectStore, fieldName: T, - ): MapSliceState[T] { + ): InferSliceFieldState[T] { return this._getFieldByName(fieldName as string).track(store) as any; } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 29fedda..cfd31ce 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -50,7 +50,7 @@ export function createStore( return new Store({ ...config, name: 'anonymous' }); } -export class Store extends BaseStore { +export class Store extends BaseStore { public readonly initialState: StoreState; // @internal diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6c9fe57..8b07933 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -47,3 +47,5 @@ export type InferStateSliceName> = export type InferEffectStoreSliceName> = T extends EffectStore ? Name : never; + +export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; diff --git a/packages/react/src/__tests__/store.test.tsx b/packages/react/src/__tests__/store.test.tsx index 72ea20c..dc94d4e 100644 --- a/packages/react/src/__tests__/store.test.tsx +++ b/packages/react/src/__tests__/store.test.tsx @@ -36,7 +36,8 @@ const setup = () => { const counterNegative = key.field(-1); const counterSlice = key.slice({ - fields: { counter, counterNegative }, + counter, + counterNegative, }); function increment() { @@ -52,13 +53,14 @@ const setup = () => { const StoreProvider = ({ children }: { children: React.ReactNode }) => { const storeRef = useRef>(); if (!storeRef.current) { - storeRef.current = createStore({ + const store = createStore({ slices: [counterSlice], name: 'test-store', overrides: { effectScheduler: zeroTimeoutScheduler, }, }); + storeRef.current = store; } return ( diff --git a/packages/react/src/__tests__/type.test.tsx b/packages/react/src/__tests__/type.test.tsx new file mode 100644 index 0000000..68420bc --- /dev/null +++ b/packages/react/src/__tests__/type.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, test } from '@jest/globals'; +import { Store, createKey } from '@nalanda/core'; +import { useTrack, useTrackField } from '../react'; +import { expectType } from '../types'; + +describe('hooks types', () => { + const mySliceKey = createKey('mySlice', []); + const aField = mySliceKey.field(1); + const bField = mySliceKey.field('string-val'); + + const derivedField = mySliceKey.derive(() => { + return { + hello: 'test', + }; + }); + + // NOTE: string search action names in this file source code if you want to rename them + function incrementAField(fakeInput: number) { + return aField.update((a) => a + 1); + } + + function decrementAField(fakeInput: string) { + return aField.update((a) => a - 1); + } + + const mySlice = mySliceKey.slice({ + a: aField, + b: bField, + derivedField, + incrementAField, + decrementAField, + }); + + const mySlice2Key = createKey('mySlice2', [mySlice]); + const aField2 = mySlice2Key.field(1); + const mySlice2 = mySlice2Key.slice({ + a: aField2, + }); + + test('useTrack', () => { + () => { + const result = useTrack(mySlice, {} as Store); + expectType< + { + a: number; + b: string; + derivedField: { + hello: string; + }; + }, + typeof result + >(result); + }; + + expect(1).toBe(1); + }); + + test('useTrackField', () => { + () => { + const a = useTrackField(mySlice, 'a', {} as Store); + expectType(a); + }; + + () => { + const b = useTrackField(mySlice, 'b', {} as Store); + expectType(b); + }; + + () => { + const der = useTrackField(mySlice, 'derivedField', {} as Store); + expectType< + { + hello: string; + }, + typeof der + >(der); + }; + + () => { + // @ts-expect-error action is not a field should fail + useTrackField(mySlice, 'incrementAField', {} as Store); + }; + + expect(1).toBe(1); + }); + + test('actions types', () => { + () => { + mySlice.actions.decrementAField('test'); + }; + expect(1).toBe(1); + }); +}); diff --git a/packages/react/src/react.ts b/packages/react/src/react.ts index 5709202..50d7220 100644 --- a/packages/react/src/react.ts +++ b/packages/react/src/react.ts @@ -1,24 +1,26 @@ -import { BaseField, Slice, Store } from '@nalanda/core'; +import { + Slice, + Store, + _AnyExternal, + _InferSliceFieldState, + _ExposedSliceFieldNames, +} from '@nalanda/core'; import { useCallback, useRef } from 'react'; import useSyncExternalStoreExports from 'use-sync-external-store/shim'; const { useSyncExternalStore } = useSyncExternalStoreExports; -type MapSliceState>> = { - [K in keyof TFieldsSpec]: TFieldsSpec[K] extends BaseField - ? T - : never; -}; +type Simplify = { [KeyType in keyof T]: T[KeyType] } & {}; export function useTrack< - TFieldsSpec extends Record> = any, + TExternal extends _AnyExternal = any, TName extends string = any, TDep extends string = any, >( - slice: Slice, + slice: Slice, store: Store, -): MapSliceState { - const ref = useRef>(); +): Simplify<_InferSliceFieldState> { + const ref = useRef(); const subscribe = useCallback( (onStoreChange: () => void) => { @@ -42,15 +44,15 @@ export function useTrack< } export function useTrackField< - TFieldsSpec extends Record> = any, + TExternal extends _AnyExternal = any, TName extends string = any, TDep extends string = any, - TFieldName extends keyof TFieldsSpec = any, + TFieldName extends _ExposedSliceFieldNames = any, >( - slice: Slice, + slice: Slice, fieldName: TFieldName, store: Store, -): MapSliceState[TFieldName] { +): _InferSliceFieldState[TFieldName] { const ref = useRef(); const subscribe = useCallback( diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 0000000..e73fe19 --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,9 @@ +export type IfEquals = (() => G extends T + ? 1 + : 2) extends () => G extends U ? 1 : 2 + ? Y + : N; + +export const expectType = ( + _actual: IfEquals, +) => void 0;