feat(store): add reactive state primitives (#311)

This commit is contained in:
rahim
2026-01-20 00:59:14 +11:00
committed by GitHub
parent eea4c512dd
commit beb8615c1c
56 changed files with 1073 additions and 2896 deletions
-1
View File
@@ -1,2 +1 @@
export { pick } from './pick';
export { getSelectorKeys, type Selector } from './selector';
-25
View File
@@ -1,25 +0,0 @@
import { isObject } from '../predicate/predicate';
/**
* A function that selects a subset of state.
*/
export type Selector<State, Selected> = (state: State) => Selected;
/**
* Extract state keys a selector depends on. Returns null for primitives/arrays.
*
* @example
* getSelectorKeys((s) => ({ volume: s.volume }), state); // ['volume']
*/
export function getSelectorKeys<State, Selected>(
selector: Selector<State, Selected>,
state: State,
): (keyof State)[] | null {
const result = selector(state);
if (!isObject(result) || Array.isArray(result)) {
return null;
}
return Object.keys(result) as (keyof State)[];
}
@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSelectorKeys } from '../selector';
describe('getSelectorKeys', () => {
interface State {
volume: number;
muted: boolean;
currentTime: number;
}
const state: State = { volume: 1, muted: false, currentTime: 0 };
it('extracts keys from object selector result', () => {
const selector = (s: State) => ({ volume: s.volume, muted: s.muted });
expect(getSelectorKeys(selector, state)).toEqual(['volume', 'muted']);
});
it('extracts single key', () => {
const selector = (s: State) => ({ volume: s.volume });
expect(getSelectorKeys(selector, state)).toEqual(['volume']);
});
it('returns null for primitive selector result (number)', () => {
const selector = (s: State) => s.volume;
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns null for primitive selector result (boolean)', () => {
const selector = (s: State) => s.muted;
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns null for primitive selector result (string)', () => {
const selector = () => 'hello';
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns null for array selector result', () => {
const selector = (s: State) => [s.volume, s.muted];
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns null for null selector result', () => {
const selector = () => null;
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns null for undefined selector result', () => {
const selector = () => undefined;
expect(getSelectorKeys(selector, state)).toBeNull();
});
it('returns empty array for empty object selector', () => {
const selector = () => ({});
expect(getSelectorKeys(selector, state)).toEqual([]);
});
it('handles derived/computed properties in selector', () => {
const selector = (s: State) => ({
volumePercent: Math.round(s.volume * 100),
isMuted: s.muted,
});
// Returns the result object's keys, not the state keys accessed
expect(getSelectorKeys(selector, state)).toEqual(['volumePercent', 'isMuted']);
});
it('handles selector that accesses nested state', () => {
interface NestedState {
audio: { volume: number; muted: boolean };
video: { quality: string };
}
const nestedState: NestedState = {
audio: { volume: 1, muted: false },
video: { quality: 'hd' },
};
const selector = (s: NestedState) => ({ audio: s.audio });
expect(getSelectorKeys(selector, nestedState)).toEqual(['audio']);
});
});
+10 -4
View File
@@ -37,12 +37,18 @@ export function isObject(value: unknown): value is object {
return value !== null && typeof value === 'object';
}
/**
* Check if a value is a plain object (not a class instance like Date, Map, etc).
*/
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!isObject(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
/**
* Check if a value is an AbortError.
*/
export function isAbortError(value: unknown): value is Error {
return (
value instanceof Error
&& value.name === 'AbortError'
);
return value instanceof Error && value.name === 'AbortError';
}
@@ -8,6 +8,7 @@ import {
isNull,
isNumber,
isObject,
isPlainObject,
isPromise,
isString,
isUndefined,
@@ -175,6 +176,44 @@ describe('predicate', () => {
});
});
describe('isPlainObject', () => {
it('returns true for plain objects', () => {
expect(isPlainObject({})).toBe(true);
expect(isPlainObject({ a: 1 })).toBe(true);
expect(isPlainObject(Object.create(null))).toBe(true);
expect(isPlainObject(new Object())).toBe(true);
});
it('returns false for arrays', () => {
expect(isPlainObject([])).toBe(false);
expect(isPlainObject([1, 2, 3])).toBe(false);
});
it('returns false for class instances', () => {
class Foo {}
expect(isPlainObject(new Foo())).toBe(false);
expect(isPlainObject(new Date())).toBe(false);
expect(isPlainObject(new Map())).toBe(false);
expect(isPlainObject(new Set())).toBe(false);
expect(isPlainObject(/regex/)).toBe(false);
});
it('returns false for primitives', () => {
expect(isPlainObject(null)).toBe(false);
expect(isPlainObject(undefined)).toBe(false);
expect(isPlainObject('string')).toBe(false);
expect(isPlainObject(123)).toBe(false);
expect(isPlainObject(true)).toBe(false);
// eslint-disable-next-line symbol-description
expect(isPlainObject(Symbol())).toBe(false);
});
it('returns false for functions', () => {
expect(isPlainObject(() => {})).toBe(false);
expect(isPlainObject(() => {})).toBe(false);
});
});
describe('isAbortError', () => {
it('returns true for AbortError', () => {
const error = new DOMException('Aborted', 'AbortError');