diff --git a/API.md b/API.md index fafd03b..06acc16 100644 --- a/API.md +++ b/API.md @@ -21,7 +21,7 @@ const key = sliceKey([sliceA], { }, }); -const sel0 = key.selector( +const sel0 = key.derive( // will have sliceA (state) => { return key.get(state).z; @@ -31,7 +31,7 @@ const sel0 = key.selector( }, ); -const sel1 = key.selector( +const sel1 = key.derive( // will have sliceA (state) => { const otherSel = sel0(state); diff --git a/documentation/pages/docs/selectors.mdx b/documentation/pages/docs/selectors.mdx index b5549cc..ab22e07 100644 --- a/documentation/pages/docs/selectors.mdx +++ b/documentation/pages/docs/selectors.mdx @@ -14,7 +14,7 @@ const key = createKey( [loginSlice, userSlice], ); -const nameField = key.selector((state) => { +const nameField = key.derive((state) => { const { isLoggedIn } = loginSlice.get(state); const { userName } = userSlice.get(state); @@ -33,12 +33,12 @@ Selectors can be composed with other selectors. ```ts const counterField = key.field(0); -const doubleField = key.selector((state) => { +const doubleField = key.derive((state) => { const counter = counterField.get(state); return counter * 2; }); -const timesSixField = key.selector((state) => { +const timesSixField = key.derive((state) => { const counter = doubleField.get(state); return counter * 3; }); diff --git a/src/vanilla/__tests__/actions.test.ts b/src/vanilla/__tests__/actions.test.ts index 32581de..796d2e2 100644 --- a/src/vanilla/__tests__/actions.test.ts +++ b/src/vanilla/__tests__/actions.test.ts @@ -1,5 +1,5 @@ import { testCleanup } from '../helpers/test-cleanup'; -import { createKey } from '../slice'; +import { createKey } from '../slice/key'; import { createStore } from '../store'; afterEach(() => { diff --git a/src/vanilla/__tests__/dependency-helpers.test.ts b/src/vanilla/__tests__/dependency-helpers.test.ts index 6d5dc69..d227c85 100644 --- a/src/vanilla/__tests__/dependency-helpers.test.ts +++ b/src/vanilla/__tests__/dependency-helpers.test.ts @@ -1,5 +1,5 @@ import { calcReverseDependencies as _calcReverseDependencies } from '../helpers/dependency-helpers'; -import { Slice } from '../slice'; +import { Slice } from '../slice/slice'; const createSlice = (id: string) => ({ sliceId: id, dependencies: [] }) as unknown as Slice; diff --git a/src/vanilla/__tests__/effect.test.ts b/src/vanilla/__tests__/effect.test.ts index c256b68..3804b87 100644 --- a/src/vanilla/__tests__/effect.test.ts +++ b/src/vanilla/__tests__/effect.test.ts @@ -1,6 +1,6 @@ import { testCleanup } from '../helpers/test-cleanup'; import waitForExpect from 'wait-for-expect'; -import { createKey } from '../slice'; +import { createKey } from '../slice/key'; import { createStore } from '../store'; import { EffectScheduler, effect } from '../effect/effect'; import { cleanup } from '../cleanup'; @@ -43,9 +43,20 @@ const sliceB = sliceBKey.slice({ const sliceCDepBKey = createKey('sliceCDepB', [sliceB]); const sliceCDepBField = sliceCDepBKey.field('value:sliceCDepBField'); + +const sliceCDepBSelector1 = sliceCDepBKey.derive((state) => { + return sliceCDepBField.get(state) + ':' + sliceB.get(state).sliceBField1; +}); + +const sliceCDepBSelector2 = sliceCDepBKey.derive((state) => { + return sliceCDepBField.get(state) + ':selector2'; +}); + const sliceCDepB = sliceCDepBKey.slice({ fields: { sliceCDepBField, + sliceCDepBSelector1, + sliceCDepBSelector2, }, }); @@ -191,7 +202,7 @@ describe('effect with store', () => { expect(effectCalled).toHaveBeenLastCalledWith('new-value'); }); - test.skip('should run effect for dependent slice', async () => { + test('should run effect for dependent slice', async () => { const { store, sliceB, updateSliceBField1, sliceCDepB } = setup(); let effect1Called = jest.fn(); @@ -200,6 +211,11 @@ describe('effect with store', () => { const selector2InitValue = 'value:sliceCDepBField:selector2'; + expect({ ...sliceCDepB.get(store.state) }).toEqual({ + sliceCDepBField: 'value:sliceCDepBField', + sliceCDepBSelector1: 'value:sliceCDepBField:value:sliceBField1', + sliceCDepBSelector2: 'value:sliceCDepBField:selector2', + }); expect(sliceCDepB.get(store.state).sliceCDepBSelector2).toBe( selector2InitValue, ); @@ -355,7 +371,7 @@ describe('effect with store', () => { }); }); - describe.skip('effects tracking', () => { + describe('effects tracking', () => { const setup2 = () => { const { store, diff --git a/src/vanilla/__tests__/field.test.ts b/src/vanilla/__tests__/field.test.ts new file mode 100644 index 0000000..80b7604 --- /dev/null +++ b/src/vanilla/__tests__/field.test.ts @@ -0,0 +1,147 @@ +import { testCleanup } from '../helpers/test-cleanup'; +import { createKey } from '../slice/key'; +import { createStore } from '../store'; + +beforeEach(() => { + testCleanup(); +}); + +describe('internal fields', () => { + test('internal field should be updated', () => { + const key = createKey('mySliceName'); + const counter = key.field(0); + const counterSlice = key.slice({ + fields: {}, + }); + + function updateCounter(state: number) { + return counter.update(state + 1); + } + + const store = createStore({ + slices: [counterSlice], + }); + + expect(counter.get(store.state)).toBe(0); + expect(Object.keys(counterSlice.get(store.state))).toEqual([]); + }); + + describe('mix of internal and external fields', () => { + const setup = () => { + const key = createKey('mySliceName'); + const counter = key.field(0); + const myName = key.field('kj'); + const callCount = { + externalDerivedOnCounter: 0, + internalDerivedOnCounter: 0, + }; + + const externalDerivedOnCounter = key.derive((state) => { + callCount.externalDerivedOnCounter++; + return `external:counter is ${counter.get(state)}`; + }); + + const internalDerivedOnCounter = key.derive((state) => { + callCount.internalDerivedOnCounter++; + return `internal:counter is ${counter.get(state)}`; + }); + + const counterSlice = key.slice({ + fields: { + myName, + externalDerivedOnCounter, + }, + }); + + function updateCounter() { + return counter.update((existing) => existing + 1); + } + + function updateName(name: string) { + return myName.update(name + '!'); + } + + return { + counter, + counterSlice, + updateCounter, + updateName, + callCount, + internalDerivedOnCounter, + }; + }; + + test('access external fields', () => { + const { counterSlice, counter, callCount } = setup(); + const store = createStore({ + slices: [counterSlice], + }); + + expect(counter.get(store.state)).toBe(0); + + const result = counterSlice.get(store.state); + expect('myName' in result).toBe(true); + expect('counter' in result).toBe(false); + expect({ ...result }).toEqual({ + externalDerivedOnCounter: 'external:counter is 0', + myName: 'kj', + }); + expect(Object.keys(result)).toEqual([ + 'myName', + 'externalDerivedOnCounter', + ]); + + expect(callCount).toEqual({ + externalDerivedOnCounter: 1, + internalDerivedOnCounter: 0, + }); + }); + + test('updating', () => { + const { counterSlice, counter, callCount, updateCounter } = setup(); + + const store = createStore({ + slices: [counterSlice], + }); + + store.dispatch(updateCounter()); + expect(counter.get(store.state)).toBe(1); + let result = counterSlice.get(store.state); + + expect(result.externalDerivedOnCounter).toBe('external:counter is 1'); + // to test proxy + result.externalDerivedOnCounter; + result.externalDerivedOnCounter; + expect(callCount.externalDerivedOnCounter).toEqual(1); + + store.dispatch(updateCounter()); + expect(counter.get(store.state)).toBe(2); + result = counterSlice.get(store.state); + expect(result.externalDerivedOnCounter).toBe('external:counter is 2'); + // to test proxy + result.externalDerivedOnCounter; + expect(callCount.externalDerivedOnCounter).toEqual(2); + }); + + test('derived is lazy', () => { + const { counterSlice, counter, callCount, updateCounter } = setup(); + + const store = createStore({ + slices: [counterSlice], + }); + + store.dispatch(updateCounter()); + expect(counter.get(store.state)).toBe(1); + let result = counterSlice.get(store.state); + + // accessing some other field should not trigger the derived + expect(result.myName).toBe('kj'); + expect(callCount.externalDerivedOnCounter).toEqual(0); + // access the derived field + result.externalDerivedOnCounter; + expect(callCount.externalDerivedOnCounter).toEqual(1); + + expect(counterSlice.get(store.state)).toBe(result); + }); + }); +}); diff --git a/src/vanilla/__tests__/store-state.test.ts b/src/vanilla/__tests__/store-state.test.ts index b651f6c..73fe205 100644 --- a/src/vanilla/__tests__/store-state.test.ts +++ b/src/vanilla/__tests__/store-state.test.ts @@ -1,5 +1,5 @@ import { testCleanup } from '../helpers/test-cleanup'; -import { createKey } from '../slice'; +import { createKey } from '../index'; import { StoreState } from '../store-state'; const sliceOneKey = createKey('sliceOne', []); diff --git a/src/vanilla/effect/effect-manager.ts b/src/vanilla/effect/effect-manager.ts index dad9f4d..9169cb6 100644 --- a/src/vanilla/effect/effect-manager.ts +++ b/src/vanilla/effect/effect-manager.ts @@ -1,6 +1,6 @@ import { calcReverseDependencies } from '../helpers/dependency-helpers'; import type { DebugLogger } from '../logger'; -import { Slice } from '../slice'; +import { Slice } from '../slice/slice'; import type { SliceId } from '../types'; import type { Effect } from './effect'; @@ -37,6 +37,9 @@ export class EffectManager { } } + /** + * Will include all slices that depend on the slices that changed. + */ getAllSlicesChanged(slicesChanged?: Slice[]): undefined | Set { if (slicesChanged === undefined) { return undefined; @@ -65,9 +68,9 @@ export class EffectManager { } run(slicesChanged?: Slice[]) { - const allSlices = this.getAllSlicesChanged(slicesChanged); + const allSlicesChanged = this.getAllSlicesChanged(slicesChanged); for (const effect of this._effects) { - effect.run(allSlices); + effect.run(allSlicesChanged); } } diff --git a/src/vanilla/effect/effect-run.ts b/src/vanilla/effect/effect-run.ts index fe9f949..eea1747 100644 --- a/src/vanilla/effect/effect-run.ts +++ b/src/vanilla/effect/effect-run.ts @@ -1,19 +1,16 @@ import type { CleanupCallback } from '../cleanup'; -import { loggerWarn } from '../helpers/logger-warn'; -import type { FieldState, Slice } from '../slice'; +import { BaseField } from '../slice/field'; +import type { Slice } from '../slice/slice'; import type { Store } from '../store'; -type Dependencies = Map>; -type ConvertToReadonlyMap = T extends Map - ? ReadonlyMap - : T; +type TrackedFieldObj = { field: BaseField; value: unknown }; /** * @internal */ export class EffectRun { - private _cleanups: Set = new Set(); - private readonly _dependencies: Dependencies = new Map(); + private cleanups: Set = new Set(); + private readonly trackedFields: TrackedFieldObj[] = []; private isDestroyed = false; /** @@ -37,8 +34,8 @@ export class EffectRun { public readonly name: string, ) {} - get dependencies(): ConvertToReadonlyMap { - return this._dependencies; + getTrackedFields(): ReadonlyArray { + return this.trackedFields; } addCleanup(cleanup: CleanupCallback): void { @@ -49,35 +46,20 @@ export class EffectRun { void cleanup(); return; } - this._cleanups.add(cleanup); + this.cleanups.add(cleanup); } - addTrackedField(slice: Slice, field: FieldState, val: unknown): void { + addTrackedField(field: BaseField, val: unknown): void { this.addTrackedCount++; - - const existing = this._dependencies.get(slice); - - if (existing === undefined) { - this._dependencies.set(slice, [{ field, value: val }]); - - return; - } - - existing.push({ field, value: val }); - + this.trackedFields.push({ field, value: val }); return; } - whatDependenciesStateChange(): undefined | FieldState { - for (const [slice, fields] of this._dependencies) { - const currentSliceState = slice.get(this.store.state); - - for (const { field, value } of fields) { - const oldVal = field._getFromSliceState(currentSliceState); - - if (!field.isEqual(oldVal, value)) { - return field; - } + whatDependenciesStateChange(): undefined | BaseField { + for (const { field, value } of this.trackedFields) { + const curVal = field.get(this.store.state); + if (!field.isEqual(curVal, value)) { + return field; } } @@ -89,9 +71,9 @@ export class EffectRun { return; } this.isDestroyed = true; - this._cleanups.forEach((cleanup) => { + this.cleanups.forEach((cleanup) => { void cleanup(); }); - this._cleanups.clear(); + this.cleanups.clear(); } } diff --git a/src/vanilla/effect/effect.ts b/src/vanilla/effect/effect.ts index a397798..8c77dc8 100644 --- a/src/vanilla/effect/effect.ts +++ b/src/vanilla/effect/effect.ts @@ -1,7 +1,8 @@ import { BaseStore } from '../base-store'; +import type { BaseField } from '../slice/field'; import { hasIdleCallback } from '../helpers/has-idle-callback'; import type { DebugLogger } from '../logger'; -import { FieldState, Slice } from '../slice'; +import { Slice } from '../slice/slice'; import type { Store } from '../store'; import { Transaction } from '../transaction'; import { EffectRun } from './effect-run'; @@ -121,11 +122,11 @@ export class Effect { this.pendingRun = true; this.scheduler(() => { queueMicrotask(() => { - this._run(); - }); - // queue it so that if run throws error, it doesn't block the next run - queueMicrotask(() => { - this.pendingRun = false; + try { + this._run(); + } finally { + this.pendingRun = false; + } }); }, this.opts); @@ -141,8 +142,9 @@ export class Effect { return true; } - for (const slice of this.runInstance.dependencies.keys()) { - if (slicesChanged.has(slice)) { + for (const { field } of this.runInstance.getTrackedFields()) { + const parentSlice = field._getSlice(); + if (slicesChanged.has(parentSlice)) { return true; } } @@ -159,7 +161,7 @@ export class Effect { return; } - let fieldChanged: FieldState | undefined; + let fieldChanged: BaseField | undefined; // if runCount == 0, always run, to ensure the effect runs at least once if (this.runCount != 0) { @@ -178,14 +180,14 @@ export class Effect { this.runInstance = new EffectRun(this.rootStore, this.name); + this.runCount++; void this.effectCallback(this.effectStore); this.debug?.({ type: this.opts.deferred ? 'UPDATE_EFFECT' : 'SYNC_UPDATE_EFFECT', name: this.name, - changed: fieldChanged?._fieldId || '', + changed: fieldChanged?.id || '', }); - this.runCount++; } } diff --git a/src/vanilla/helpers/create-ids.ts b/src/vanilla/helpers/create-ids.ts index 6e3a41c..8e094ce 100644 --- a/src/vanilla/helpers/create-ids.ts +++ b/src/vanilla/helpers/create-ids.ts @@ -1,9 +1,5 @@ import { FieldId, SliceId } from '../types'; -export function createFieldId(id: string): FieldId { - return id as FieldId; -} - export function createSliceId(id: string): SliceId { return id as SliceId; } diff --git a/src/vanilla/helpers/dependency-helpers.ts b/src/vanilla/helpers/dependency-helpers.ts index fccb1a9..bcd50ae 100644 --- a/src/vanilla/helpers/dependency-helpers.ts +++ b/src/vanilla/helpers/dependency-helpers.ts @@ -1,4 +1,4 @@ -import type { Slice } from '../slice'; +import type { Slice } from '../slice/slice'; import type { SliceId } from '../types'; export function allDependencies(slices: Slice[]): Set { diff --git a/src/vanilla/helpers/id-generation.ts b/src/vanilla/helpers/id-generation.ts index 091b17a..8998b9a 100644 --- a/src/vanilla/helpers/id-generation.ts +++ b/src/vanilla/helpers/id-generation.ts @@ -1,55 +1,37 @@ -import type { ActionId, SliceId } from '../types'; -import { createSliceId } from './create-ids'; - -type InternalIdGenerators = { - txCounter: number; - actionIdCounters: Record; - sliceIdCounters: Record; -}; +import type { FieldId, SliceId } from '../types'; + +const resetSymbol = Symbol('reset'); + +function createIdGenerator(prefix: string) { + let counterMap: Record = Object.create(null); + + return { + // only for testing + [resetSymbol]: () => { + counterMap = Object.create(null); + }, + generate: (name: string): T => { + if (name in counterMap) { + return `${prefix}_${name}$${++counterMap[name]}` as T; + } else { + counterMap[name] = 0; + return `${prefix}_${name}$` as T; + } + }, + }; +} -const internalInitState: () => InternalIdGenerators = () => ({ - txCounter: 0, - actionIdCounters: Object.create(null), - sliceIdCounters: Object.create(null), -}); +let txCounter = 0; -let idGenerators = internalInitState(); +export const fieldIdCounters = createIdGenerator('f'); +export const sliceIdCounters = createIdGenerator('sl'); +export const genTransactionID = () => `tx_${txCounter++}`; /** - * Should only be used in tests, to avoid side effects between tests + * WARNING Should only be used in tests, to avoid side effects between tests */ export const testOnlyResetIdGeneration = () => { - idGenerators = internalInitState(); + fieldIdCounters[resetSymbol](); + sliceIdCounters[resetSymbol](); + txCounter = 0; }; - -class IdGeneration { - createActionId(sliceId: SliceId, hint = ''): ActionId { - let prefix = `a_${hint}[${sliceId}]`; - - if (sliceId in idGenerators.actionIdCounters) { - return `${prefix}${idGenerators.actionIdCounters[sliceId]++}` as ActionId; - } else { - idGenerators.actionIdCounters[sliceId] = 0; - - return prefix as ActionId; - } - } - - createSliceId(name: string): SliceId { - if (name in idGenerators.sliceIdCounters) { - return createSliceId( - `sl_${name}$${++idGenerators.sliceIdCounters[name]}`, - ); - } - - idGenerators.sliceIdCounters[name] = 0; - - return createSliceId(`sl_${name}$`); - } - - createTransactionId(): string { - return `tx_${idGenerators.txCounter++}`; - } -} - -export const idGeneration = new IdGeneration(); diff --git a/src/vanilla/index.ts b/src/vanilla/index.ts index 8155632..94cd118 100644 --- a/src/vanilla/index.ts +++ b/src/vanilla/index.ts @@ -1,2 +1,2 @@ -export { createKey } from './slice'; +export { createKey } from './slice/key'; export { createStore } from './store'; diff --git a/src/vanilla/slice.ts b/src/vanilla/slice.ts deleted file mode 100644 index a517003..0000000 --- a/src/vanilla/slice.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { EffectCallback, EffectOpts, EffectStore } from './effect/effect'; -import { createFieldId } from './helpers/create-ids'; -import { idGeneration } from './helpers/id-generation'; -import { throwValidationError } from './helpers/throw-error'; -import type { StoreState } from './store-state'; -import { Transaction } from './transaction'; -import type { SliceId, FieldId } from './types'; - -export function createKey(name: string, dependencies: Slice[] = []) { - return new Key(name, dependencies); -} - -class Key { - constructor( - public readonly name: string, - public readonly dependencies: Slice[], - ) {} - - _effectCallbacks: [EffectCallback, Partial][] = []; - - _slice: Slice | undefined; - - _assertedSlice(): Slice { - if (!this._slice) { - throwValidationError( - `Slice "${this.name}" does not exist. A slice must be created before it can be used.`, - ); - } - - return this._slice; - } - - _knownFieldState = new Set(); - - field(val: TVal) { - const fieldState = new FieldState(val, this); - this._knownFieldState.add(fieldState); - return fieldState; - } - - slice>({ - fields, - actions, - }: { - fields: TFieldsSpec; - actions?: (...args: any) => Transaction; - }): Slice { - 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); - - return this._slice; - } - - transaction() { - return new Transaction(); - } - - effect(callback: EffectCallback, opts: Partial = {}) { - this._effectCallbacks.push([callback, opts]); - } -} - -export class FieldState { - _fieldId: FieldId | undefined; - - constructor( - public readonly initialValue: T, - public readonly key: Key, - ) {} - - _getFromSliceState(sliceState: Record): T { - return sliceState[this._fieldId!] as T; - } - - get(storeState: StoreState): T { - if (!this._fieldId) { - throwValidationError( - `Cannot access state before Slice "${this.key.name}" has been created.`, - ); - } - const slice = this.key._assertedSlice(); - - return slice.get(storeState)[this._fieldId] as T; - } - - isEqual(a: T, b: T): boolean { - // TODO: allow users to provide a custom equality function - return Object.is(a, b); - } - - update(val: T | ((val: T) => T)): Transaction { - const txn = this.key.transaction(); - - txn._addStep({ - cb: (state: StoreState) => { - const slice = this.key._assertedSlice(); - const manager = state._getSliceStateManager(slice); - - const newManager = manager._updateFieldState(this, val); - - if (newManager === manager) { - return state; - } - - return state._updateSliceStateManager(slice, newManager); - }, - }); - - return txn; - } -} - -type MapSliceState> = { - [K in keyof TFieldsSpec]: TFieldsSpec[K] extends FieldState - ? T - : never; -}; - -export class Slice = any> { - sliceId: SliceId; - - readonly initialValue: MapSliceState; - - get dependencies(): Slice[] { - return this._key.dependencies; - } - - /** - * Called when the user overrides the initial value of a slice in the store. - */ - _verifyInitialValueOverride(val: Record): void { - // TODO: when user provides an override, do more checks - if (Object.keys(val).length !== Object.keys(this.initialValue).length) { - throwValidationError( - `Slice "${this.name}" has fields that are not defined in the override. Did you forget to pass a state field?`, - ); - } - } - - constructor( - public readonly name: string, - private fieldsSpec: TFieldsSpec, - public readonly _key: Key, - ) { - this.sliceId = idGeneration.createSliceId(name); - - if (_key._knownFieldState.size !== Object.keys(fieldsSpec).length) { - throwValidationError( - `Slice "${name}" has fields that are not defined in the state spec. Did you forget to pass a state field?`, - ); - } - - for (const [fieldName, fieldState] of Object.entries(fieldsSpec)) { - if (!_key._knownFieldState.has(fieldState)) { - throwValidationError(`Field "${fieldName}" was not found.`); - } - - fieldState._fieldId = createFieldId(fieldName); - } - - this.initialValue = Object.fromEntries( - Object.entries(fieldsSpec).map(([fieldName, fieldState]) => [ - fieldName, - fieldState.initialValue, - ]), - ) as any; - } - - get(storeState: StoreState): MapSliceState { - return storeState._getSliceStateManager(this).sliceState as any; - } - - track(store: EffectStore): MapSliceState { - return new Proxy(this.get(store.state), { - get: (target, prop: FieldId) => { - const val = target[prop]; - - const field: FieldState = this.fieldsSpec[prop]!; - - // track this field - store._getRunInstance().addTrackedField(this, field, val); - - return val; - }, - }); - } -} diff --git a/src/vanilla/slice/field.ts b/src/vanilla/slice/field.ts new file mode 100644 index 0000000..945146a --- /dev/null +++ b/src/vanilla/slice/field.ts @@ -0,0 +1,118 @@ +import { EffectStore } from '../effect/effect'; +import { fieldIdCounters } from '../helpers/id-generation'; +import { throwValidationError } from '../helpers/throw-error'; +import type { Key } from './key'; +import { StoreState } from '../store-state'; +import { Transaction } from '../transaction'; +import type { FieldId } from '../types'; + +export type BaseFieldOptions = { + equal?: (a: TVal, b: TVal) => boolean; +}; + +export abstract class BaseField { + readonly id: FieldId; + + name: string | undefined; + + constructor( + public readonly key: Key, + public readonly options: BaseFieldOptions, + ) { + this.id = fieldIdCounters.generate(key.name); + } + + _getSlice() { + return this.key._assertedSlice(); + } + + abstract get(storeState: StoreState): TVal; + + isEqual(a: TVal, b: TVal): boolean { + if (this.options.equal) { + return this.options.equal(a, b); + } + return Object.is(a, b); + } + + track(store: EffectStore) { + const value = this.get(store.state); + store._getRunInstance().addTrackedField(this, value); + return value; + } +} + +export class DerivedField extends BaseField { + constructor( + public readonly deriveCallback: (state: StoreState) => TVal, + key: Key, + options: BaseFieldOptions, + ) { + super(key, options); + } + + private getCache = new WeakMap(); + + get(storeState: StoreState): TVal { + if (!this.id) { + throwValidationError( + `Cannot access state before Slice "${this.key.name}" has been created.`, + ); + } + + // TODO: return previously seen value based on isEqual and the lineage of store-state + + if (this.getCache.has(storeState)) { + return this.getCache.get(storeState); + } + + const newValue = this.deriveCallback(storeState); + + this.getCache.set(storeState, newValue); + + return newValue; + } +} + +export class StateField extends BaseField { + constructor( + public readonly initialValue: TVal, + key: Key, + options: BaseFieldOptions, + ) { + super(key, options); + } + + get(storeState: StoreState): TVal { + if (!this.id) { + throwValidationError( + `Cannot access state before Slice "${this.key.name}" has been created.`, + ); + } + const slice = this.key._assertedSlice(); + return storeState + ._getSliceStateManager(slice) + ._getFieldStateVal(this) as TVal; + } + + update(val: TVal | ((val: TVal) => TVal)): Transaction { + const txn = this.key.transaction(); + + txn._addStep({ + cb: (state: StoreState) => { + const slice = this.key._assertedSlice(); + const manager = state._getSliceStateManager(slice); + + const newManager = manager._updateFieldState(this, val); + + if (newManager === manager) { + return state; + } + + return state._updateSliceStateManager(slice, newManager); + }, + }); + + return txn; + } +} diff --git a/src/vanilla/slice/key.ts b/src/vanilla/slice/key.ts new file mode 100644 index 0000000..0507799 --- /dev/null +++ b/src/vanilla/slice/key.ts @@ -0,0 +1,93 @@ +import type { EffectCallback, EffectOpts, EffectStore } from '../effect/effect'; +import { + StateField, + type BaseField, + BaseFieldOptions, + DerivedField, +} from './field'; +import { throwValidationError } from '../helpers/throw-error'; +import type { StoreState } from '../store-state'; +import { Transaction } from '../transaction'; +import type { FieldId, NoInfer } from '../types'; +import { Slice } from './slice'; + +export function createKey(name: string, dependencies: Slice[] = []) { + return new Key(name, dependencies); +} + +export class Key { + constructor( + public readonly name: string, + public readonly dependencies: Slice[], + ) {} + + private _slice: Slice | undefined; + _effectCallbacks: [EffectCallback, Partial][] = []; + readonly _derivedFields: Record> = {}; + readonly _initialStateFieldValue: Record = {}; + readonly _fields = new Set>(); + readonly _fieldIdToFieldLookup: Record> = {}; + + hasField(field: BaseField) { + return this._fields.has(field); + } + + _assertedSlice(): Slice { + if (!this._slice) { + throwValidationError( + `Slice "${this.name}" does not exist. A slice must be created before it can be used.`, + ); + } + return this._slice; + } + + private registerField>(field: T): T { + this._fields.add(field); + this._fieldIdToFieldLookup[field.id] = field; + + if (field instanceof StateField) { + this._initialStateFieldValue[field.id] = field.initialValue; + } else if (field instanceof DerivedField) { + this._derivedFields[field.id] = field; + } + + return field; + } + + field(val: TVal, options: BaseFieldOptions> = {}) { + return this.registerField(new StateField(val, this, options)); + } + + slice>>({ + fields, + actions, + }: { + fields: TFieldsSpec; + actions?: (...args: any) => Transaction; + }): Slice { + 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); + + return this._slice; + } + + transaction() { + return new Transaction(); + } + + effect(callback: EffectCallback, opts: Partial = {}) { + this._effectCallbacks.push([callback, opts]); + } + + derive( + cb: (storeState: StoreState) => TVal, + options: BaseFieldOptions> = {}, + ) { + return this.registerField(new DerivedField(cb, this, options)); + } +} diff --git a/src/vanilla/slice/slice.ts b/src/vanilla/slice/slice.ts new file mode 100644 index 0000000..a0d614f --- /dev/null +++ b/src/vanilla/slice/slice.ts @@ -0,0 +1,142 @@ +import type { EffectStore } from '../effect/effect'; +import { type BaseField, DerivedField } from './field'; +import { sliceIdCounters } from '../helpers/id-generation'; +import { throwValidationError } from '../helpers/throw-error'; +import type { StoreState } from '../store-state'; +import type { SliceId, FieldId, NoInfer } from '../types'; +import type { Key } from './key'; + +type MapSliceState>> = { + [K in keyof TFieldsSpec]: TFieldsSpec[K] extends BaseField + ? T + : never; +}; + +export class Slice> = any> { + sliceId: SliceId; + + private getCache = new WeakMap(); + private fieldNameToField: Record> = {}; + + get dependencies(): Slice[] { + return this._key.dependencies; + } + + _getFieldByName(fieldName: string): BaseField { + const field = this.fieldNameToField[fieldName]; + if (field === undefined) { + throwValidationError(`Field "${fieldName.toString()}" does not exist.`); + } + + return field; + } + + /** + * Called when the user overrides the initial value of a slice in the store. + */ + _verifyInitialValueOverride(val: Record): void { + // // TODO: when user provides an override, do more checks + } + + constructor( + public readonly name: string, + externalFieldSpec: TFieldsSpec, + public readonly _key: Key, + ) { + this.sliceId = sliceIdCounters.generate(name); + for (const [fieldName, field] of Object.entries(externalFieldSpec)) { + if (!_key._fields.has(field)) { + throwValidationError(`Field "${fieldName}" was not found.`); + } + field.name = fieldName; + this.fieldNameToField[fieldName] = field; + } + } + + /** + * Get a field value from the slice state. Slightly faster than `get`. + */ + getField( + storeState: StoreState, + fieldName: T, + ): MapSliceState[T] { + return this._getFieldByName(fieldName as string).get(storeState) as any; + } + + /** + * Similar to `track`, but only tracks a single field. + */ + trackField( + store: EffectStore, + fieldName: T, + ): MapSliceState[T] { + return this._getFieldByName(fieldName as string).track(store) as any; + } + + get(storeState: StoreState): MapSliceState { + const existing = this.getCache.get(storeState); + + if (existing) { + return existing; + } + + // since derived fields are lazy, we have to build this proxy + const lazyExternalState = new Proxy( + storeState._getSliceStateManager(this).rawState, + { + get: (target, fieldName: string, receiver) => { + if (!this.fieldNameToField[fieldName]) { + return undefined; + } + + const field = this._getFieldByName(fieldName); + + // this could have been a simple undefined check, but for some reason + // jest is hijacking the proxy + if (field instanceof DerivedField) { + return field.get(storeState); + } + // map field name to id and then forward the get to raw state + return Reflect.get(target, field.id, receiver); + }, + + has: (target, fieldName: string) => { + return fieldName in this.fieldNameToField; + }, + + ownKeys: () => { + return Object.keys(this.fieldNameToField); + }, + + getOwnPropertyDescriptor: (target, fieldName: string) => { + if (!this.fieldNameToField[fieldName]) { + return undefined; + } + + const field = this._getFieldByName(fieldName); + + if (field instanceof DerivedField) { + return { + configurable: true, + enumerable: true, + }; + } + + return Reflect.getOwnPropertyDescriptor(target, field.id); + }, + }, + ) as any; + + this.getCache.set(storeState, lazyExternalState); + + return lazyExternalState; + } + + track(store: EffectStore): MapSliceState { + return new Proxy(this.get(store.state), { + get: (target, prop: string, receiver) => { + return this._getFieldByName(prop).track(store); + }, + }); + } +} diff --git a/src/vanilla/store-state.ts b/src/vanilla/store-state.ts index 3bfdb7e..9343f40 100644 --- a/src/vanilla/store-state.ts +++ b/src/vanilla/store-state.ts @@ -1,8 +1,9 @@ -import type { FieldState, Slice } from './slice'; +import type { Slice } from './slice/slice'; import { FieldId, SliceId } from './types'; import { throwValidationError } from './helpers/throw-error'; import { Transaction } from './transaction'; import { calcReverseDependencies } from './helpers/dependency-helpers'; +import { StateField } from './slice/field'; type SliceStateMap = Record; @@ -64,6 +65,7 @@ export class StoreState { computed, }); } + constructor(private config: StoreStateConfig) {} apply(transaction: Transaction): StoreState { @@ -116,10 +118,10 @@ export class StoreState { Object.values(this.config.sliceStateMap).forEach((sliceStateManager) => { const slice = sliceStateManager.slice; - const sliceState = sliceStateManager.sliceState; + const sliceState = sliceStateManager.rawState; const otherSliceState = - otherStoreState.config.sliceStateMap[slice.sliceId]?.sliceState; + otherStoreState.config.sliceStateMap[slice.sliceId]?.rawState; if (sliceState !== otherSliceState) { diff.push(sliceStateManager.slice); @@ -130,29 +132,48 @@ export class StoreState { } } -class SliceStateManager { +export class SliceStateManager { static new(slice: Slice, sliceStateOverride?: Record) { if (sliceStateOverride) { slice._verifyInitialValueOverride(sliceStateOverride); } - return new SliceStateManager( - slice, - sliceStateOverride ?? slice.initialValue, - ); + + let override = slice._key._initialStateFieldValue; + + if (sliceStateOverride) { + const normalizedOverride = Object.fromEntries( + Object.entries(sliceStateOverride).map(([fieldName, val]) => [ + slice._getFieldByName(fieldName).id, + val, + ]), + ); + + override = { + ...override, + ...normalizedOverride, + }; + } + return new SliceStateManager(slice, override); } constructor( public readonly slice: Slice, - public readonly sliceState: Record, + private readonly sliceState: Record, ) {} - _getFieldState(field: FieldState): unknown { - const fieldState = this.sliceState[field._fieldId!]; - return fieldState; + /** + * Raw state includes the state of all fields (internal and external) with fieldIds as keys. + */ + get rawState(): Record { + return this.sliceState; + } + + _getFieldStateVal(field: StateField): unknown { + return this.sliceState[field.id]; } - _updateFieldState(field: FieldState, updater: any): SliceStateManager { - const oldValue = this._getFieldState(field); + _updateFieldState(field: StateField, updater: any): SliceStateManager { + const oldValue = this._getFieldStateVal(field); const newValue = typeof updater === 'function' ? updater(oldValue) : updater; @@ -162,7 +183,7 @@ class SliceStateManager { return new SliceStateManager(this.slice, { ...this.sliceState, - [field._fieldId!]: newValue, + [field.id]: newValue, }); } } diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 52995d4..c2e1558 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -7,7 +7,7 @@ import { } from './effect/effect'; import type { DebugLogger } from './logger'; import type { Operation } from './effect/operation'; -import type { Slice } from './slice'; +import type { Slice } from './slice/slice'; import { StoreState } from './store-state'; import { Transaction } from './transaction'; import type { SliceId } from './types'; @@ -51,6 +51,8 @@ export function createStore(config: StoreOptions) { export class Store extends BaseStore { private _state: StoreState; + public readonly initialState: StoreState; + private effectsManager: EffectManager; private destroyed = false; private registeredSlicesEffect = false; @@ -78,6 +80,7 @@ export class Store extends BaseStore { this._state = StoreState.create({ slices: options.slices, }); + this.initialState = this._state; this._dispatchTxn = options.overrides?.dispatchTransactionOverride || diff --git a/src/vanilla/transaction.ts b/src/vanilla/transaction.ts index 2c5279e..885f0ae 100644 --- a/src/vanilla/transaction.ts +++ b/src/vanilla/transaction.ts @@ -1,4 +1,4 @@ -import { idGeneration } from './helpers/id-generation'; +import { genTransactionID } from './helpers/id-generation'; import { StoreState } from './store-state'; type Step = { cb: (storeState: StoreState) => StoreState }; @@ -7,7 +7,7 @@ export const META_DISPATCHER = 'DEBUG__DISPATCHER'; export const TX_META_STORE_NAME = 'store-name'; export class Transaction { - readonly id = idGeneration.createTransactionId(); + readonly id = genTransactionID(); private destroyed = false;