STF: fix array access

This commit is contained in:
Fuma Nama
2026-02-26 16:31:50 +08:00
parent b3efba6107
commit bd8c33a9c7
6 changed files with 213 additions and 119 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumari/stf': patch
---
fix array access
+24 -109
View File
@@ -1,4 +1,3 @@
import { useEffect, useRef, useState } from 'react';
import {
fieldKeyStartsWith,
isPlainObject,
@@ -7,11 +6,10 @@ import {
stringifyFieldKey,
} from './utils';
import type { FieldKey } from './types';
import { Stf, useDataEngine } from './stf';
export type DefaultValue<T = unknown> = T | (() => T);
function getDefaultValue<T>(defaultValue: DefaultValue<T>): T {
export function getDefaultValue<T>(defaultValue: DefaultValue<T>): T {
return typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
}
@@ -140,36 +138,40 @@ export class DataEngine {
/**
* init a field
* @param key the key of field
* @param field the key of field
* @param defaultValue the initial value, the field is also created for `undefined`
* @returns the value of initialized field, or the current value of field if already initialized
*/
init(key: FieldKey, defaultValue?: DefaultValue, ctx: OnInitContext = {}): unknown {
if (key.length === 0) return this.data;
init(field: FieldKey, defaultValue?: DefaultValue, ctx: OnInitContext = {}): unknown {
if (field.length === 0) return this.data;
const parentKey: FieldKey = [];
const parentUpdateCtx = { swallow: true, ...ctx };
let cur = this.data as Record<string, unknown>;
let parent = this.data as Record<string, unknown> | unknown[];
for (let i = 0; i < key.length; i++) {
const propKey = key[i];
const propValue = cur[propKey];
for (let i = 0; i < field.length; i++) {
const key = field[i];
// @ts-expect-error -- allow access
const value: unknown = parent[key];
if (i === key.length - 1) {
if (propValue !== undefined) return propValue;
cur[propKey] = getDefaultValue(defaultValue);
if (i === field.length - 1) {
if (value !== undefined) return value;
// @ts-expect-error -- allow access
const out = (parent[key] = getDefaultValue(defaultValue));
this.listeners.onUpdate(parentKey, parentUpdateCtx);
this.listeners.onInit(key, ctx);
return cur[propKey];
} else if (isPlainObject(propValue)) {
cur = propValue;
parentKey.push(propKey);
this.listeners.onInit(field, ctx);
return out;
} else if (isPlainObject(value) || Array.isArray(value)) {
parent = value;
parentKey.push(key);
} else {
cur = cur[propKey] = {};
const nextKey = field[i + 1];
// @ts-expect-error -- allow access
parent = parent[key] = typeof nextKey === 'number' ? new Array(nextKey + 1) : {};
this.listeners.onUpdate(parentKey, parentUpdateCtx);
parentKey.push(propKey);
if (propValue !== undefined) {
parentKey.push(key);
if (value !== undefined) {
console.warn(
`the original value of field ${parentKey.join('.')} is overidden, this might be unexpected.`,
);
@@ -185,7 +187,7 @@ export class DataEngine {
const prop = key[key.length - 1];
const parent = this.get(parentKey);
if (Array.isArray(parent) && typeof prop === 'number') {
if (typeof prop === 'number' && Array.isArray(parent)) {
const deleted: unknown[] = parent.splice(prop, 1);
if (deleted.length === 0) return;
@@ -244,90 +246,3 @@ export class DataEngine {
for (const { reset } of this.namespaces.values()) reset?.();
}
}
export function useFieldValue<V = unknown>(
key: FieldKey,
options: {
stf?: Stf | DataEngine;
defaultValue?: DefaultValue;
/**
* compute value from the actual field value.
*
* to re-compute on in-place updates (may happen on objects, arrays), you should clone the object here.
*/
compute?: (currentValue: unknown) => V;
/** determine whether the value/computed value is changed */
isChanged?: (prev: V, next: V) => boolean;
} = {},
) {
const { stf, compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
const engine = useDataEngine(stf);
const [value, setValue] = useState<V>(() => compute(engine.init(key, defaultValue)));
const prevEngineRef = useRef(engine);
if (prevEngineRef.current !== engine) {
setValue(compute(engine.init(key, defaultValue)));
prevEngineRef.current = engine;
}
useListener({
field: key,
stf,
onInit() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onUpdate() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onDelete() {
const computed = compute(undefined);
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
});
return [value, (newValue: unknown) => engine.update(key, newValue)] as const;
}
export function useListener(listener: DataEngineListener & { stf?: Stf | DataEngine }) {
const engine = useDataEngine(listener.stf);
const listenerRef = useRef(listener);
listenerRef.current = listener;
useEffect(() => {
const internal: DataEngineListener = {
field: listener.field,
onDelete(...args) {
return listenerRef.current.onDelete?.(...args);
},
onInit(...args) {
return listenerRef.current.onInit?.(...args);
},
onUpdate(...args) {
return listenerRef.current.onUpdate?.(...args);
},
};
engine.listen(internal);
return () => {
engine.unlisten(internal);
};
}, [engine, listener.field]);
}
export function useNamespace(options: {
namespace: string;
initial?: DefaultValue<NonNullable<object>>;
stf?: Stf | DataEngine;
}) {
const { namespace, stf, initial } = options;
const engine = useDataEngine(stf);
const value = engine.namespace(namespace, initial, {
reset() {
value.update([], getDefaultValue(initial));
},
});
return value;
}
+95 -3
View File
@@ -1,6 +1,11 @@
import { createContext, ReactNode, use, useMemo } from 'react';
import { DataEngine, DefaultValue, useFieldValue } from './data-engine';
import { FieldKey } from './types';
import { createContext, ReactNode, use, useEffect, useMemo, useRef, useState } from 'react';
import {
DataEngine,
getDefaultValue,
type DataEngineListener,
type DefaultValue,
} from './data-engine';
import type { FieldKey } from './types';
import { deepEqual } from './utils';
const Context = createContext<Stf | null>(null);
@@ -172,3 +177,90 @@ export function useObject<T>(
},
};
}
export function useFieldValue<V = unknown>(
key: FieldKey,
options: {
stf?: Stf | DataEngine;
defaultValue?: DefaultValue;
/**
* compute value from the actual field value.
*
* to re-compute on in-place updates (may happen on objects, arrays), you should clone the object here.
*/
compute?: (currentValue: unknown) => V;
/** determine whether the value/computed value is changed */
isChanged?: (prev: V, next: V) => boolean;
} = {},
) {
const { stf, compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
const engine = useDataEngine(stf);
const [value, setValue] = useState<V>(() => compute(engine.init(key, defaultValue)));
const prevEngineRef = useRef(engine);
if (prevEngineRef.current !== engine) {
setValue(compute(engine.init(key, defaultValue)));
prevEngineRef.current = engine;
}
useListener({
field: key,
stf,
onInit() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onUpdate() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
onDelete() {
const computed = compute(undefined);
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
},
});
return [value, (newValue: unknown) => engine.update(key, newValue)] as const;
}
export function useListener(listener: DataEngineListener & { stf?: Stf | DataEngine }) {
const engine = useDataEngine(listener.stf);
const listenerRef = useRef(listener);
listenerRef.current = listener;
useEffect(() => {
const internal: DataEngineListener = {
field: listener.field,
onDelete(...args) {
return listenerRef.current.onDelete?.(...args);
},
onInit(...args) {
return listenerRef.current.onInit?.(...args);
},
onUpdate(...args) {
return listenerRef.current.onUpdate?.(...args);
},
};
engine.listen(internal);
return () => {
engine.unlisten(internal);
};
}, [engine, listener.field]);
}
export function useNamespace(options: {
namespace: string;
initial?: DefaultValue<NonNullable<object>>;
stf?: Stf | DataEngine;
}) {
const { namespace, stf, initial } = options;
const engine = useDataEngine(stf);
const value = engine.namespace(namespace, initial, {
reset() {
value.update([], getDefaultValue(initial));
},
});
return value;
}
+18 -7
View File
@@ -4,8 +4,13 @@ export function objectGet(obj: unknown, key: (string | number)[]): unknown | und
let cur = obj;
for (const prop of key) {
if (!isPlainObject(cur) || !(prop in cur)) return;
cur = cur[prop];
if (isPlainObject(cur) && prop in cur) {
cur = cur[prop];
} else if (typeof prop === 'number' && Array.isArray(cur)) {
cur = cur[prop];
} else {
return;
}
}
return cur;
@@ -16,14 +21,20 @@ export function objectGet(obj: unknown, key: (string | number)[]): unknown | und
*
* @returns updated value, throw error if parent object doesn't exist
*/
export function objectSet(obj: unknown, key: FieldKey, value: unknown): unknown {
if (key.length === 0) {
export function objectSet(obj: unknown, field: FieldKey, value: unknown): unknown {
if (field.length === 0) {
return value;
}
const parent = objectGet(obj, key.slice(0, -1));
if (!isPlainObject(parent)) throw new Error('missing parent object');
parent[key[key.length - 1]] = value;
const parent = objectGet(obj, field.slice(0, -1));
const key = field[field.length - 1];
if (isPlainObject(parent)) {
parent[key] = value;
} else if (typeof key === 'number' && Array.isArray(parent)) {
parent[key] = value;
} else {
throw new Error('missing parent object');
}
return obj;
}
+62
View File
@@ -0,0 +1,62 @@
import { DataEngine } from '@/index';
import { expect, test } from 'vitest';
test('data engine: basic', () => {
const logs: string[] = [];
const engine = new DataEngine({});
engine.listen({
onDelete(key) {
logs.push(`delete ${key}`);
},
onInit(key) {
logs.push(`init ${key}`);
},
onUpdate(key, ctx) {
logs.push(`update ${key} ${ctx.swallow}`);
},
});
expect(engine.init(['hello', 'world', 2], 'test')).toBe('test');
expect(engine.init(['hello', 'world', 1, 'property'], 'test')).toBe('test');
expect(engine.getData()).toMatchInlineSnapshot(`
{
"hello": {
"world": [
,
{
"property": "test",
},
"test",
],
},
}
`);
expect(engine.delete(['hello', 'world', 1])).toMatchInlineSnapshot(`
{
"property": "test",
}
`);
expect(engine.getData()).toMatchInlineSnapshot(`
{
"hello": {
"world": [
,
"test",
],
},
}
`);
expect(logs).toMatchInlineSnapshot(`
[
"update true",
"update hello true",
"update hello,world true",
"init hello,world,2",
"update hello,world true",
"update hello,world,1 true",
"init hello,world,1,property",
"delete hello,world,1",
"update hello,world false",
]
`);
});
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from 'vitest/config';
export default defineProject({
resolve: {
alias: {
'@/': new URL('./src/', import.meta.url).pathname,
},
},
});