STF: support cleaning namespace information

This commit is contained in:
Fuma Nama
2026-02-25 17:51:16 +08:00
parent 5a9b381353
commit 21e60c30b9
8 changed files with 156 additions and 81 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': patch
'@fumadocs/story': patch
'@fumari/stf': patch
---
support cleaning namespace information
+3 -1
View File
@@ -236,6 +236,7 @@ export default function PlaygroundClient({
if (stf.dataEngine.getData() === defaultValues) return;
stf.dataEngine.reset(defaultValues);
stf.dataEngine.clearNamespaces();
// eslint-disable-next-line react-hooks/exhaustive-deps -- ignore other parts
}, [defaultValues]);
@@ -427,7 +428,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
const field = useResolvedSchema(_field);
const [isJson, setIsJson] = useState(false);
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} />;
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
if (isJson)
return (
@@ -454,6 +455,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
field={field}
fieldName={['body']}
collapsible={false}
isRequired
name={
<button
type="button"
@@ -267,7 +267,7 @@ export function FieldSet({
const { readOnly, writeOnly } = useSchemaScope();
const field = useResolvedSchema(_field);
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
const id = stringifyFieldKey(fieldName);
const dataEngine = useDataEngine();
const [isDefined] = useFieldValue(fieldName, {
@@ -335,6 +335,7 @@ export function FieldSet({
field={union[info.oneOf]}
depth={depth + 1}
slotType={showSelect ? false : slotType}
collapsible={collapsible}
toolbar={
<>
{showSelect && (
@@ -374,6 +375,7 @@ export function FieldSet({
...field,
type: info.selectedType,
}}
collapsible={collapsible}
depth={depth + 1}
slotType={showSelect ? false : slotType}
toolbar={
+8 -12
View File
@@ -3,7 +3,7 @@ import { createContext, ReactNode, use, useMemo } from 'react';
import { getDefaultValue } from '@/playground/get-default-values';
import type { ParsedSchema } from '@/utils/schema';
import { mergeAllOf } from '@/utils/merge-schema';
import { FieldKey, useDataEngine, useFieldValue } from '@fumari/stf';
import { FieldKey, useDataEngine, useFieldValue, useNamespace } from '@fumari/stf';
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
interface SchemaContextType extends SchemaScope {
@@ -89,15 +89,16 @@ export function useSchemaScope(): SchemaScope {
export function useFieldInfo(
fieldName: FieldKey,
schema: Exclude<ParsedSchema, boolean>,
depth = 0,
): {
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const { ajv } = use(SchemaContext)!;
const engine = useDataEngine();
const fieldData = engine.namespace(
`field-info:${stringifyFieldKey(fieldName)}`,
(): FieldInfo => {
const fieldData = useNamespace({
namespace: `field-info:${depth}:${stringifyFieldKey(fieldName)}`,
initial(): FieldInfo {
const value = engine.get(fieldName);
const out: FieldInfo = {
oneOf: -1,
@@ -116,12 +117,8 @@ export function useFieldInfo(
out.selectedType =
types.find((type) => {
schema.type = type;
const match = ajv.validate(schema, value);
schema.type = types;
return match;
}) ?? types.at(0);
return ajv.validate({ ...schema, type }, value);
}) ?? types[0];
}
if (schema.allOf) {
@@ -132,10 +129,9 @@ export function useFieldInfo(
merged,
};
}
return out;
},
);
});
const [info, setInfo] = useFieldValue<FieldInfo>([], {
stf: fieldData,
});
+74 -23
View File
@@ -1,5 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { fieldKeyStartsWith, objectGet, objectSet, stringifyFieldKey } from './utils';
import {
fieldKeyStartsWith,
isPlainObject,
objectGet,
objectSet,
stringifyFieldKey,
} from './utils';
import type { FieldKey } from './types';
import { Stf, useDataEngine } from './stf';
@@ -28,6 +34,10 @@ export interface DataEngineListener {
* when `field` is specified, this is also fired when parent is deleted.
*/
onDelete?: (key: FieldKey, ctx: OnDeleteContext) => void;
/**
* For listener attached to a namespace's engine, this is fired when the namespace is removed.
*/
onUnmount?: () => void;
}
export interface OnUpdateContext {
@@ -105,12 +115,16 @@ class ListenerManager {
for (const v of listeners) v.onDelete?.(field, ctx);
}
}
onUnmount() {
for (const v of this.unindexed) v.onUnmount?.();
for (const listeners of this.indexed.values()) for (const v of listeners) v.onUnmount?.();
}
}
export class DataEngine {
private data: NonNullable<object>;
/** namespace -> engine */
private namespaces = new Map<string, DataEngine>();
private readonly namespaces = new Map<string, DataEngine>();
private readonly listeners = new ListenerManager();
constructor(defaultValues: DefaultValue<NonNullable<object>> = {}) {
@@ -137,9 +151,9 @@ export class DataEngine {
*/
init(key: FieldKey, defaultValue?: DefaultValue, ctx: OnInitContext = {}): unknown {
if (key.length === 0) return this.data;
let cur = this.data as Record<string, unknown>;
const currentKey: FieldKey = [];
const parentKey: FieldKey = [];
const parentUpdateCtx = { swallow: true, ...ctx };
let cur = this.data as Record<string, unknown>;
for (let i = 0; i < key.length; i++) {
const propKey = key[i];
@@ -149,21 +163,24 @@ export class DataEngine {
if (propValue !== undefined) return propValue;
cur[propKey] = getDefaultValue(defaultValue);
this.listeners.onUpdate(currentKey, parentUpdateCtx);
this.listeners.onUpdate(parentKey, parentUpdateCtx);
this.listeners.onInit(key, ctx);
return cur[propKey];
} else if (typeof propValue === 'object' && propValue !== null) {
cur = propValue as Record<string, unknown>;
} else if (isPlainObject(propValue)) {
cur = propValue;
parentKey.push(propKey);
} else {
if (propValue !== undefined)
console.warn(
`the original value of field ${currentKey.join('.')} is overidden, this might be unexpected.`,
);
cur = cur[propKey] = {};
this.listeners.onUpdate(currentKey, parentUpdateCtx);
this.listeners.onUpdate(parentKey, parentUpdateCtx);
parentKey.push(propKey);
if (propValue !== undefined) {
console.warn(
`the original value of field ${parentKey.join('.')} is overidden, this might be unexpected.`,
);
this.listeners.onUpdate(parentKey, parentUpdateCtx);
}
}
currentKey.push(propKey);
}
}
@@ -181,9 +198,9 @@ export class DataEngine {
// it will change children's field value when removed at middle
this.listeners.onUpdate(parentKey, { swallow: false, ...ctx });
return deleted[0];
} else if (typeof parent === 'object' && parent !== null) {
const temp = (parent as Record<string, unknown>)[prop];
delete parent[prop as never];
} else if (isPlainObject(parent)) {
const temp = parent[prop];
delete parent[prop];
this.listeners.onDelete(key, ctx);
this.listeners.onUpdate(parentKey, { swallow: true, ...ctx });
return temp;
@@ -221,10 +238,16 @@ export class DataEngine {
return child;
}
reset(data: Record<string, unknown>) {
this.namespaces.clear();
reset(data: NonNullable<object>) {
this.update([], data);
}
clearNamespaces() {
for (const [name, engine] of this.namespaces) {
this.namespaces.delete(name);
engine.listeners.onUnmount();
}
}
}
export function useFieldValue<V = unknown>(
@@ -243,13 +266,19 @@ export function useFieldValue<V = unknown>(
isChanged?: (prev: V, next: V) => boolean;
} = {},
) {
const engine = useDataEngine(options.stf);
const { compute = (v) => v as V, defaultValue, isChanged = (a, b) => a !== b } = options;
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: options.stf,
stf,
onInit() {
const computed = compute(engine.get(key));
setValue((prev) => (isChanged(prev, computed) ? computed : prev));
@@ -275,6 +304,9 @@ export function useListener(listener: DataEngineListener & { stf?: Stf | DataEng
useEffect(() => {
const internal: DataEngineListener = {
field: listener.field,
onUnmount(...args) {
return listenerRef.current.onUnmount?.(...args);
},
onDelete(...args) {
return listenerRef.current.onDelete?.(...args);
},
@@ -292,3 +324,22 @@ export function useListener(listener: DataEngineListener & { stf?: Stf | DataEng
};
}, [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, setValue] = useState(() => engine.namespace(namespace, initial));
useListener({
stf: value,
onUnmount() {
setValue(engine.namespace(namespace, initial));
},
});
return value;
}
+24 -13
View File
@@ -4,9 +4,8 @@ export function objectGet(obj: unknown, key: (string | number)[]): unknown | und
let cur = obj;
for (const prop of key) {
if (typeof cur !== 'object' || cur === null || !(prop in cur)) return;
cur = cur[prop as keyof typeof cur];
if (!isPlainObject(cur) || !(prop in cur)) return;
cur = cur[prop];
}
return cur;
@@ -23,8 +22,8 @@ export function objectSet(obj: unknown, key: FieldKey, value: unknown): unknown
}
const parent = objectGet(obj, key.slice(0, -1));
if (typeof parent !== 'object' || parent === null) throw new Error('missing parent object');
(parent as Record<string, unknown>)[key[key.length - 1]] = value;
if (!isPlainObject(parent)) throw new Error('missing parent object');
parent[key[key.length - 1]] = value;
return obj;
}
@@ -55,22 +54,21 @@ export function deepEqual(a: unknown, b: unknown): boolean {
return false;
}
const keysA = Object.keys(a as object);
const keysB = Object.keys(b as object);
if (!isPlainObject(a) || !isPlainObject(b)) {
return false;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
return keysA.every(
(key) =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
return keysA.every((key) => key in b && deepEqual(a[key], b[key]));
}
export function stringifyFieldKey(fieldKey: FieldKey) {
return fieldKey.map((v) => `${typeof v}:${v}`).join('.');
return fieldKey.map((v) => (typeof v === 'string' ? `_${v}` : `n${v}`)).join('.');
}
/**
@@ -79,3 +77,16 @@ export function stringifyFieldKey(fieldKey: FieldKey) {
export function fieldKeyStartsWith(a: string, b: string): boolean {
return b.length === 0 || a === b || a.startsWith(b + '.');
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null
);
}
+36 -31
View File
@@ -1,6 +1,13 @@
import { type ComponentProps, type HTMLAttributes, type ReactNode, useState } from 'react';
import { ChevronRight, Plus, Trash2, X } from 'lucide-react';
import { FieldKey, useArray, useDataEngine, useFieldValue, useObject } from '@fumari/stf';
import {
FieldKey,
useArray,
useDataEngine,
useFieldValue,
useNamespace,
useObject,
} from '@fumari/stf';
import {
Select,
SelectContent,
@@ -226,30 +233,30 @@ export function FieldInput({
);
}
export function FieldSet(
props: HTMLAttributes<HTMLElement> & {
isRequired?: boolean;
name?: ReactNode;
field: TypeNode;
fieldName: FieldKey;
export function FieldSet({
depth = 0,
field: _field,
fieldName,
toolbar,
name,
isRequired,
slotType,
collapsible = true,
...rest
}: HTMLAttributes<HTMLElement> & {
isRequired?: boolean;
name?: ReactNode;
field: TypeNode;
fieldName: FieldKey;
depth?: number;
slotType?: ReactNode;
toolbar?: ReactNode;
collapsible?: boolean;
},
) {
const {
field,
fieldName,
toolbar,
name,
isRequired,
slotType,
collapsible = true,
...rest
} = props;
slotType?: ReactNode;
toolbar?: ReactNode;
collapsible?: boolean;
}) {
const field = _field.type === 'intersection' ? _field.intersection : _field;
const [show, setShow] = useState(!collapsible);
const { info, updateInfo } = useFieldInfo(fieldName, field);
const { info, updateInfo } = useFieldInfo(fieldName, field, depth);
const id = stringifyFieldKey(fieldName);
const dataEngine = useDataEngine();
const onShow = () => {
@@ -267,6 +274,7 @@ export function FieldSet(
<FieldSet
{...rest}
name={name}
depth={depth + 1}
fieldName={fieldName}
isRequired={isRequired}
field={selectedType}
@@ -297,10 +305,6 @@ export function FieldSet(
);
}
if (field.type === 'intersection') {
return <FieldSet {...props} field={field.intersection} />;
}
if (field.type === 'object') {
return (
<fieldset
@@ -465,14 +469,15 @@ interface FieldInfo {
function useFieldInfo(
fieldName: FieldKey,
node: TypeNode,
depth = 0,
): {
info: FieldInfo;
updateInfo: (value: Partial<FieldInfo>) => void;
} {
const engine = useDataEngine();
const fieldData = engine.namespace(
`field-info:${stringifyFieldKey(fieldName)}`,
(): FieldInfo => {
const fieldData = useNamespace({
namespace: `field-info:${depth}:${stringifyFieldKey(fieldName)}`,
initial(): FieldInfo {
const out: FieldInfo = {
unionIndex: 0,
};
@@ -485,7 +490,7 @@ function useFieldInfo(
return out;
},
);
});
const [info, setInfo] = useFieldValue<FieldInfo>([], {
stf: fieldData,
});
@@ -40,6 +40,7 @@ export function WithControl({ presets, displayName, Component }: WithControlProp
if (preset) {
setVariant(value);
stf.dataEngine.reset(preset.defaultValues ?? {});
stf.dataEngine.clearNamespaces();
}
}}
>