@fumari/stf 0.0.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@ declare function useStf(options: {
19
19
  */
20
20
  defaultValues?: DefaultValue<Record<string, unknown>>;
21
21
  }): Stf;
22
- declare function useDataEngine(stf?: Stf): DataEngine;
22
+ declare function useDataEngine(instance?: Stf | DataEngine): DataEngine;
23
23
  interface ArrayItemInfo {
24
24
  field: FieldKey;
25
25
  index: number;
@@ -62,27 +62,43 @@ interface DataEngineListener {
62
62
  */
63
63
  field?: FieldKey;
64
64
  /**
65
- * when field value is changed
65
+ * when field value is changed.
66
66
  */
67
67
  onUpdate?: (key: FieldKey, ctx: OnUpdateContext) => void;
68
68
  /**
69
- * when `init(field)` is called
69
+ * fired on `init(field)`.
70
70
  */
71
- onInit?: (key: FieldKey) => void;
71
+ onInit?: (key: FieldKey, ctx: OnInitContext) => void;
72
72
  /**
73
- * when `delete(field)` is called
73
+ * fired on `delete(field)`.
74
+ *
75
+ * when `field` is specified, this is also fired when parent is deleted.
76
+ */
77
+ onDelete?: (key: FieldKey, ctx: OnDeleteContext) => void;
78
+ /**
79
+ * For listener attached to a namespace's engine, this is fired when the namespace is removed.
74
80
  */
75
- onDelete?: (key: FieldKey) => void;
81
+ onUnmount?: () => void;
76
82
  }
77
83
  interface OnUpdateContext {
78
84
  /**
79
85
  * An update is swallow if the change doesn't affect the values of children fields.
80
86
  */
81
87
  swallow: boolean;
88
+ /** custom data */
89
+ custom?: Record<string, unknown>;
90
+ }
91
+ interface OnDeleteContext {
92
+ /** custom data */
93
+ custom?: Record<string, unknown>;
94
+ }
95
+ interface OnInitContext {
96
+ /** custom data */
97
+ custom?: Record<string, unknown>;
82
98
  }
83
99
  declare class DataEngine {
84
100
  private data;
85
- private attachedDataMap;
101
+ private readonly namespaces;
86
102
  private readonly listeners;
87
103
  constructor(defaultValues?: DefaultValue<NonNullable<object>>);
88
104
  listen(listener: DataEngineListener): void;
@@ -94,23 +110,23 @@ declare class DataEngine {
94
110
  * @param defaultValue the initial value, the field is also created for `undefined`
95
111
  * @returns the value of initialized field, or the current value of field if already initialized
96
112
  */
97
- init(key: FieldKey, defaultValue?: DefaultValue): unknown;
98
- delete(key: FieldKey): unknown | undefined;
113
+ init(key: FieldKey, defaultValue?: DefaultValue, ctx?: OnInitContext): unknown;
114
+ delete(key: FieldKey, ctx?: OnDeleteContext): unknown | undefined;
99
115
  get(key: FieldKey): unknown;
100
116
  /**
101
117
  * update the value of field if it exists
102
118
  * @returns if the field is updated
103
119
  */
104
- update(key: FieldKey, value: unknown): boolean;
105
- attachedData<T>(namespace: string): {
106
- get: (field: FieldKey) => T | undefined;
107
- set: (field: FieldKey, value: T) => void;
108
- delete: (field?: FieldKey) => void;
109
- };
110
- reset(data: Record<string, unknown>): void;
120
+ update(key: FieldKey, value: unknown, ctx?: Partial<OnUpdateContext>): boolean;
121
+ /**
122
+ * create an isolated data engine
123
+ */
124
+ namespace(namespace: string, initialValue?: DefaultValue<NonNullable<object>>): DataEngine;
125
+ reset(data: NonNullable<object>): void;
126
+ clearNamespaces(): void;
111
127
  }
112
128
  declare function useFieldValue<V = unknown>(key: FieldKey, options?: {
113
- stf?: Stf;
129
+ stf?: Stf | DataEngine;
114
130
  defaultValue?: DefaultValue;
115
131
  /**
116
132
  * compute value from the actual field value.
@@ -121,7 +137,12 @@ declare function useFieldValue<V = unknown>(key: FieldKey, options?: {
121
137
  isChanged?: (prev: V, next: V) => boolean;
122
138
  }): readonly [V, (newValue: unknown) => boolean];
123
139
  declare function useListener(listener: DataEngineListener & {
124
- stf?: Stf;
140
+ stf?: Stf | DataEngine;
125
141
  }): void;
142
+ declare function useNamespace(options: {
143
+ namespace: string;
144
+ initial?: DefaultValue<NonNullable<object>>;
145
+ stf?: Stf | DataEngine;
146
+ }): DataEngine;
126
147
  //#endregion
127
- export { ArrayItemInfo, DataEngine, DataEngineListener, DefaultValue, FieldKey, PropertyItemInfo, Stf, StfProvider, useArray, useDataEngine, useFieldValue, useListener, useObject, useStf };
148
+ export { ArrayItemInfo, DataEngine, DataEngineListener, DefaultValue, FieldKey, OnDeleteContext, OnInitContext, OnUpdateContext, PropertyItemInfo, Stf, StfProvider, useArray, useDataEngine, useFieldValue, useListener, useNamespace, useObject, useStf };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { deepEqual, objectGet, objectSet, stringifyFieldKey } from "./lib/utils.js";
3
+ import { deepEqual, fieldKeyStartsWith, isPlainObject, objectGet, objectSet, stringifyFieldKey } from "./lib/utils.js";
4
4
  import { createContext, use, useEffect, useMemo, useRef, useState } from "react";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
 
@@ -17,8 +17,9 @@ function useStf(options) {
17
17
  const dataEngine = useMemo(() => new DataEngine(defaultValues), []);
18
18
  return useMemo(() => ({ dataEngine }), [dataEngine]);
19
19
  }
20
- function useDataEngine(stf) {
21
- if (stf) return stf.dataEngine;
20
+ function useDataEngine(instance) {
21
+ if (instance instanceof DataEngine) return instance;
22
+ if (instance) return instance.dataEngine;
22
23
  return use(Context).dataEngine;
23
24
  }
24
25
  function useArray(field, options = {}) {
@@ -142,24 +143,31 @@ var ListenerManager = class {
142
143
  const set = this.indexed.get(updatedKey);
143
144
  if (set) for (const v of set) v.onUpdate?.(field, ctx);
144
145
  } else for (const [k, listeners] of this.indexed.entries()) {
145
- if (updatedKey.length !== 0 && k !== updatedKey && !k.startsWith(updatedKey + ".")) continue;
146
+ if (!fieldKeyStartsWith(k, updatedKey)) continue;
146
147
  for (const v of listeners) v.onUpdate?.(field, ctx);
147
148
  }
148
149
  }
149
- onInit(field) {
150
- for (const v of this.unindexed) v.onInit?.(field);
150
+ onInit(field, ctx) {
151
+ for (const v of this.unindexed) v.onInit?.(field, ctx);
151
152
  const set = this.indexed.get(stringifyFieldKey(field));
152
- if (set) for (const v of set) v.onInit?.(field);
153
+ if (set) for (const v of set) v.onInit?.(field, ctx);
153
154
  }
154
- onDelete(field) {
155
- for (const v of this.unindexed) v.onDelete?.(field);
156
- const set = this.indexed.get(stringifyFieldKey(field));
157
- if (set) for (const v of set) v.onDelete?.(field);
155
+ onDelete(field, ctx) {
156
+ const fieldKey = stringifyFieldKey(field);
157
+ for (const v of this.unindexed) v.onDelete?.(field, ctx);
158
+ for (const [k, listeners] of this.indexed.entries()) {
159
+ if (!fieldKeyStartsWith(k, fieldKey)) continue;
160
+ for (const v of listeners) v.onDelete?.(field, ctx);
161
+ }
162
+ }
163
+ onUnmount() {
164
+ for (const v of this.unindexed) v.onUnmount?.();
165
+ for (const listeners of this.indexed.values()) for (const v of listeners) v.onUnmount?.();
158
166
  }
159
167
  };
160
- var DataEngine = class {
168
+ var DataEngine = class DataEngine {
161
169
  constructor(defaultValues = {}) {
162
- this.attachedDataMap = /* @__PURE__ */ new Map();
170
+ this.namespaces = /* @__PURE__ */ new Map();
163
171
  this.listeners = new ListenerManager();
164
172
  this.data = getDefaultValue(defaultValues);
165
173
  }
@@ -178,43 +186,59 @@ var DataEngine = class {
178
186
  * @param defaultValue the initial value, the field is also created for `undefined`
179
187
  * @returns the value of initialized field, or the current value of field if already initialized
180
188
  */
181
- init(key, defaultValue) {
189
+ init(key, defaultValue, ctx = {}) {
182
190
  if (key.length === 0) return this.data;
191
+ const parentKey = [];
192
+ const parentUpdateCtx = {
193
+ swallow: true,
194
+ ...ctx
195
+ };
183
196
  let cur = this.data;
184
- const currentKey = [];
185
197
  for (let i = 0; i < key.length; i++) {
186
198
  const propKey = key[i];
187
199
  const propValue = cur[propKey];
188
200
  if (i === key.length - 1) {
189
201
  if (propValue !== void 0) return propValue;
190
202
  cur[propKey] = getDefaultValue(defaultValue);
191
- this.listeners.onUpdate(currentKey, { swallow: true });
192
- this.listeners.onInit(key);
203
+ this.listeners.onUpdate(parentKey, parentUpdateCtx);
204
+ this.listeners.onInit(key, ctx);
193
205
  return cur[propKey];
194
- } else if (typeof propValue === "object" && propValue !== null) cur = propValue;
195
- else {
196
- if (propValue !== void 0) console.warn(`the original value of field ${currentKey.join(".")} is overidden, this might be unexpected.`);
206
+ } else if (isPlainObject(propValue)) {
207
+ cur = propValue;
208
+ parentKey.push(propKey);
209
+ } else {
197
210
  cur = cur[propKey] = {};
198
- this.listeners.onUpdate(currentKey, { swallow: true });
211
+ this.listeners.onUpdate(parentKey, parentUpdateCtx);
212
+ parentKey.push(propKey);
213
+ if (propValue !== void 0) {
214
+ console.warn(`the original value of field ${parentKey.join(".")} is overidden, this might be unexpected.`);
215
+ this.listeners.onUpdate(parentKey, parentUpdateCtx);
216
+ }
199
217
  }
200
- currentKey.push(propKey);
201
218
  }
202
219
  }
203
- delete(key) {
220
+ delete(key, ctx = {}) {
204
221
  if (key.length === 0) return;
205
222
  const parentKey = key.slice(0, -1);
206
223
  const prop = key[key.length - 1];
207
224
  const parent = this.get(parentKey);
208
225
  if (Array.isArray(parent) && typeof prop === "number") {
209
- const [deleted] = parent.splice(prop, 1);
210
- this.listeners.onUpdate(parentKey, { swallow: false });
211
- this.listeners.onDelete(key);
212
- return deleted;
213
- } else if (typeof parent === "object" && parent !== null) {
226
+ const deleted = parent.splice(prop, 1);
227
+ if (deleted.length === 0) return;
228
+ this.listeners.onDelete(key, ctx);
229
+ this.listeners.onUpdate(parentKey, {
230
+ swallow: false,
231
+ ...ctx
232
+ });
233
+ return deleted[0];
234
+ } else if (isPlainObject(parent)) {
214
235
  const temp = parent[prop];
215
236
  delete parent[prop];
216
- this.listeners.onUpdate(parentKey, { swallow: true });
217
- this.listeners.onDelete(key);
237
+ this.listeners.onDelete(key, ctx);
238
+ this.listeners.onUpdate(parentKey, {
239
+ swallow: true,
240
+ ...ctx
241
+ });
218
242
  return temp;
219
243
  }
220
244
  }
@@ -225,47 +249,62 @@ var DataEngine = class {
225
249
  * update the value of field if it exists
226
250
  * @returns if the field is updated
227
251
  */
228
- update(key, value) {
252
+ update(key, value, ctx) {
229
253
  try {
230
254
  this.data = objectSet(this.data, key, value);
231
- this.listeners.onUpdate(key, { swallow: false });
255
+ this.listeners.onUpdate(key, {
256
+ swallow: false,
257
+ ...ctx
258
+ });
232
259
  return true;
233
260
  } catch {
234
261
  return false;
235
262
  }
236
263
  }
237
- attachedData(namespace) {
238
- return {
239
- get: (field) => {
240
- return this.attachedDataMap.get(`${namespace}:${stringifyFieldKey(field)}`);
241
- },
242
- set: (field, value) => {
243
- this.attachedDataMap.set(`${namespace}:${stringifyFieldKey(field)}`, value);
244
- },
245
- delete: (field) => {
246
- if (field) this.attachedDataMap.delete(`${namespace}:${stringifyFieldKey(field)}`);
247
- else for (const key of this.attachedDataMap.keys()) if (key.startsWith(`${namespace}:`)) this.attachedDataMap.delete(key);
248
- }
249
- };
264
+ /**
265
+ * create an isolated data engine
266
+ */
267
+ namespace(namespace, initialValue) {
268
+ let child = this.namespaces.get(namespace);
269
+ if (!child) {
270
+ child = new DataEngine(initialValue);
271
+ this.namespaces.set(namespace, child);
272
+ }
273
+ return child;
250
274
  }
251
275
  reset(data) {
252
- this.attachedDataMap.clear();
253
276
  this.update([], data);
254
277
  }
278
+ clearNamespaces() {
279
+ for (const [name, engine] of this.namespaces) {
280
+ this.namespaces.delete(name);
281
+ engine.listeners.onUnmount();
282
+ }
283
+ }
255
284
  };
256
285
  function useFieldValue(key, options = {}) {
257
- const engine = useDataEngine(options.stf);
258
- const { compute = (v) => v, defaultValue, isChanged = (a, b) => a !== b } = options;
286
+ const { stf, compute = (v) => v, defaultValue, isChanged = (a, b) => a !== b } = options;
287
+ const engine = useDataEngine(stf);
259
288
  const [value, setValue] = useState(() => compute(engine.init(key, defaultValue)));
289
+ const prevEngineRef = useRef(engine);
290
+ if (prevEngineRef.current !== engine) {
291
+ setValue(compute(engine.init(key, defaultValue)));
292
+ prevEngineRef.current = engine;
293
+ }
260
294
  useListener({
261
295
  field: key,
296
+ stf,
297
+ onInit() {
298
+ const computed = compute(engine.get(key));
299
+ setValue((prev) => isChanged(prev, computed) ? computed : prev);
300
+ },
262
301
  onUpdate() {
263
302
  const computed = compute(engine.get(key));
264
- if (isChanged(value, computed)) setValue(computed);
303
+ setValue((prev) => isChanged(prev, computed) ? computed : prev);
265
304
  },
266
305
  onDelete() {
267
306
  const computed = compute(void 0);
268
- if (isChanged(value, computed)) setValue(computed);
307
+ setValue((prev) => isChanged(prev, computed) ? computed : prev);
269
308
  }
270
309
  });
271
310
  return [value, (newValue) => engine.update(key, newValue)];
@@ -277,6 +316,9 @@ function useListener(listener) {
277
316
  useEffect(() => {
278
317
  const internal = {
279
318
  field: listener.field,
319
+ onUnmount(...args) {
320
+ return listenerRef.current.onUnmount?.(...args);
321
+ },
280
322
  onDelete(...args) {
281
323
  return listenerRef.current.onDelete?.(...args);
282
324
  },
@@ -293,6 +335,18 @@ function useListener(listener) {
293
335
  };
294
336
  }, [engine, listener.field]);
295
337
  }
338
+ function useNamespace(options) {
339
+ const { namespace, stf, initial } = options;
340
+ const engine = useDataEngine(stf);
341
+ const [value, setValue] = useState(() => engine.namespace(namespace, initial));
342
+ useListener({
343
+ stf: value,
344
+ onUnmount() {
345
+ setValue(engine.namespace(namespace, initial));
346
+ }
347
+ });
348
+ return value;
349
+ }
296
350
 
297
351
  //#endregion
298
- export { DataEngine, StfProvider, useArray, useDataEngine, useFieldValue, useListener, useObject, useStf };
352
+ export { DataEngine, StfProvider, useArray, useDataEngine, useFieldValue, useListener, useNamespace, useObject, useStf };
@@ -1,10 +1,6 @@
1
1
  import { t as FieldKey } from "../types-Do_aTV5I.js";
2
2
 
3
3
  //#region src/lib/utils.d.ts
4
- /**
5
- * test if array a starts with array b, only compare values via `===`.
6
- */
7
- declare function arrayStartsWith(a: unknown[], b: unknown[]): boolean;
8
4
  declare function objectGet(obj: unknown, key: (string | number)[]): unknown | undefined;
9
5
  /**
10
6
  * set the value of field if it exists (in place)
@@ -17,5 +13,10 @@ declare function objectSet(obj: unknown, key: FieldKey, value: unknown): unknown
17
13
  */
18
14
  declare function deepEqual(a: unknown, b: unknown): boolean;
19
15
  declare function stringifyFieldKey(fieldKey: FieldKey): string;
16
+ /**
17
+ * @returns if `a` starts with `b`.
18
+ */
19
+ declare function fieldKeyStartsWith(a: string, b: string): boolean;
20
+ declare function isPlainObject(value: unknown): value is Record<string, unknown>;
20
21
  //#endregion
21
- export { arrayStartsWith, deepEqual, objectGet, objectSet, stringifyFieldKey };
22
+ export { deepEqual, fieldKeyStartsWith, isPlainObject, objectGet, objectSet, stringifyFieldKey };
package/dist/lib/utils.js CHANGED
@@ -1,16 +1,8 @@
1
1
  //#region src/lib/utils.ts
2
- /**
3
- * test if array a starts with array b, only compare values via `===`.
4
- */
5
- function arrayStartsWith(a, b) {
6
- if (b.length > a.length) return false;
7
- for (let i = 0; i < b.length; i++) if (a[i] !== b[i]) return false;
8
- return true;
9
- }
10
2
  function objectGet(obj, key) {
11
3
  let cur = obj;
12
4
  for (const prop of key) {
13
- if (typeof cur !== "object" || cur === null || !(prop in cur)) return;
5
+ if (!isPlainObject(cur) || !(prop in cur)) return;
14
6
  cur = cur[prop];
15
7
  }
16
8
  return cur;
@@ -23,7 +15,7 @@ function objectGet(obj, key) {
23
15
  function objectSet(obj, key, value) {
24
16
  if (key.length === 0) return value;
25
17
  const parent = objectGet(obj, key.slice(0, -1));
26
- if (typeof parent !== "object" || parent === null) throw new Error("missing parent object");
18
+ if (!isPlainObject(parent)) throw new Error("missing parent object");
27
19
  parent[key[key.length - 1]] = value;
28
20
  return obj;
29
21
  }
@@ -39,14 +31,26 @@ function deepEqual(a, b) {
39
31
  return a.every((item, index) => deepEqual(item, b[index]));
40
32
  }
41
33
  if (Array.isArray(a) || Array.isArray(b)) return false;
34
+ if (!isPlainObject(a) || !isPlainObject(b)) return false;
42
35
  const keysA = Object.keys(a);
43
36
  const keysB = Object.keys(b);
44
37
  if (keysA.length !== keysB.length) return false;
45
- return keysA.every((key) => Object.prototype.hasOwnProperty.call(b, key) && deepEqual(a[key], b[key]));
38
+ return keysA.every((key) => key in b && deepEqual(a[key], b[key]));
46
39
  }
47
40
  function stringifyFieldKey(fieldKey) {
48
- return fieldKey.map((v) => `${typeof v}:${v}`).join(".");
41
+ return fieldKey.map((v) => typeof v === "string" ? `_${v}` : `n${v}`).join(".");
42
+ }
43
+ /**
44
+ * @returns if `a` starts with `b`.
45
+ */
46
+ function fieldKeyStartsWith(a, b) {
47
+ return b.length === 0 || a === b || a.startsWith(b + ".");
48
+ }
49
+ function isPlainObject(value) {
50
+ if (typeof value !== "object" || value === null) return false;
51
+ const prototype = Object.getPrototypeOf(value);
52
+ return prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null;
49
53
  }
50
54
 
51
55
  //#endregion
52
- export { arrayStartsWith, deepEqual, objectGet, objectSet, stringifyFieldKey };
56
+ export { deepEqual, fieldKeyStartsWith, isPlainObject, objectGet, objectSet, stringifyFieldKey };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fumari/stf",
3
- "version": "0.0.3",
3
+ "version": "1.0.1",
4
4
  "description": "Schema to Form.",
5
5
  "keywords": [],
6
6
  "license": "MIT",
@@ -19,8 +19,8 @@
19
19
  "access": "public"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/node": "25.2.1",
23
- "@types/react": "^19.2.13",
22
+ "@types/node": "25.2.3",
23
+ "@types/react": "^19.2.14",
24
24
  "tsdown": "^0.20.3",
25
25
  "eslint-config-custom": "0.0.0",
26
26
  "tsconfig": "0.0.0"