Skip to content

Commit

Permalink
selectors (#26)
Browse files Browse the repository at this point in the history
* selectors

* better external internal fields

* add some tests
  • Loading branch information
kepta committed Oct 3, 2023
1 parent 18d926d commit 1f73c93
Show file tree
Hide file tree
Showing 21 changed files with 637 additions and 324 deletions.
4 changes: 2 additions & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +31,7 @@ const sel0 = key.selector(
},
);

const sel1 = key.selector(
const sel1 = key.derive(
// will have sliceA
(state) => {
const otherSel = sel0(state);
Expand Down
6 changes: 3 additions & 3 deletions documentation/pages/docs/selectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
});
Expand Down
2 changes: 1 addition & 1 deletion src/vanilla/__tests__/actions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { testCleanup } from '../helpers/test-cleanup';
import { createKey } from '../slice';
import { createKey } from '../slice/key';
import { createStore } from '../store';

afterEach(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/vanilla/__tests__/dependency-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
22 changes: 19 additions & 3 deletions src/vanilla/__tests__/effect.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
},
});

Expand Down Expand Up @@ -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();
Expand All @@ -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,
);
Expand Down Expand Up @@ -355,7 +371,7 @@ describe('effect with store', () => {
});
});

describe.skip('effects tracking', () => {
describe('effects tracking', () => {
const setup2 = () => {
const {
store,
Expand Down
147 changes: 147 additions & 0 deletions src/vanilla/__tests__/field.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
2 changes: 1 addition & 1 deletion src/vanilla/__tests__/store-state.test.ts
Original file line number Diff line number Diff line change
@@ -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', []);
Expand Down
9 changes: 6 additions & 3 deletions src/vanilla/effect/effect-manager.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -37,6 +37,9 @@ export class EffectManager {
}
}

/**
* Will include all slices that depend on the slices that changed.
*/
getAllSlicesChanged(slicesChanged?: Slice[]): undefined | Set<Slice> {
if (slicesChanged === undefined) {
return undefined;
Expand Down Expand Up @@ -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);
}
}

Expand Down
52 changes: 17 additions & 35 deletions src/vanilla/effect/effect-run.ts
Original file line number Diff line number Diff line change
@@ -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<Slice, Array<{ field: FieldState; value: unknown }>>;
type ConvertToReadonlyMap<T> = T extends Map<infer K, infer V>
? ReadonlyMap<K, V>
: T;
type TrackedFieldObj = { field: BaseField<unknown>; value: unknown };

/**
* @internal
*/
export class EffectRun {
private _cleanups: Set<CleanupCallback> = new Set();
private readonly _dependencies: Dependencies = new Map();
private cleanups: Set<CleanupCallback> = new Set();
private readonly trackedFields: TrackedFieldObj[] = [];
private isDestroyed = false;

/**
Expand All @@ -37,8 +34,8 @@ export class EffectRun {
public readonly name: string,
) {}

get dependencies(): ConvertToReadonlyMap<Dependencies> {
return this._dependencies;
getTrackedFields(): ReadonlyArray<TrackedFieldObj> {
return this.trackedFields;
}

addCleanup(cleanup: CleanupCallback): void {
Expand All @@ -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<any>, 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<any> {
for (const { field, value } of this.trackedFields) {
const curVal = field.get(this.store.state);
if (!field.isEqual(curVal, value)) {
return field;
}
}

Expand All @@ -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();
}
}
Loading

0 comments on commit 1f73c93

Please sign in to comment.