feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-studio/autosave
> Project template for react component with storybook and supports publish independently.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// eslint-disable-next-line @coze-arch/no-batch-import-or-export
import type * as zustand from 'zustand';
import { act } from '@testing-library/react';
const { create: actualCreate, createStore: actualCreateStore } =
await vi.importActual<typeof zustand>('zustand');
// a variable to hold reset functions for all stores declared in the app
export const storeResetFns = new Set<() => void>();
const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator);
const initialState = store.getState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand create mock');
// to support curried version of create
return typeof stateCreator === 'function'
? createUncurried(stateCreator)
: createUncurried;
}) as typeof zustand.create;
const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator);
const initialState = store.getState();
storeResetFns.add(() => {
store.setState(initialState, true);
});
return store;
};
// when creating a store, we get its initial state, create a reset function and add it in the set
export const createStore = (<T>(stateCreator: zustand.StateCreator<T>) => {
console.log('zustand createStore mock');
// to support curried version of createStore
return typeof stateCreator === 'function'
? createStoreUncurried(stateCreator)
: createStoreUncurried;
}) as typeof zustand.createStore;
// reset all stores after each test run
afterEach(() => {
act(() => {
storeResetFns.forEach(resetFn => {
resetFn();
});
});
});

View File

@@ -0,0 +1,234 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StoreApi, type UseBoundStore } from 'zustand';
import { describe, beforeEach, afterEach, vi, expect } from 'vitest';
import { DebounceTime } from '../../src/type';
import { AutosaveManager } from '../../src/core/manager';
vi.mock('lodash-es', () => ({
debounce: vi.fn((func, wait) => {
let timeout;
const debounced = (...args) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
debounced.cancel = vi.fn(() => clearTimeout(timeout));
debounced.flush = vi.fn(() => {
if (timeout) {
clearTimeout(timeout);
func();
}
});
return debounced;
}),
}));
const mockStore = {
subscribe: vi.fn().mockReturnValue(() => {
console.log('Unsubscribed');
}),
} as unknown as UseBoundStore<StoreApi<any>>;
describe('AutosaveManager', () => {
let manager;
const saveRequest = vi.fn().mockResolvedValue(Promise.resolve());
const registers = [
{
key: 'testKey',
debounce: DebounceTime.Immediate,
selector: state => state,
},
];
beforeEach(() => {
manager = new AutosaveManager({
store: mockStore,
registers,
saveRequest,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should initialize and set initial values correctly', () => {
expect(manager.observerList.length).toBe(0);
expect(manager.configList).toMatchObject(
registers.map(r => ({
...r,
eventCallBacks: undefined,
saveRequest,
})),
);
});
it('should start observers correctly', () => {
manager.start();
expect(manager.observerList.length).toBe(1);
const observer = manager.observerList[0];
expect(observer.config.key).toBe('testKey');
expect(observer.config.saveRequest).toBe(saveRequest);
});
it('should not start observers if already started', () => {
manager.start();
expect(manager.observerList.length).toBe(1);
manager.start(); // 再次调用 start
expect(manager.observerList.length).toBe(1);
});
it('should handle manualSave correctly when config is undefined', async () => {
await manager.manualSave('undefinedKey', { value: 'test' });
expect(saveRequest).not.toHaveBeenCalled();
});
it('should close observers correctly', () => {
manager.start();
const observer = manager.observerList[0];
const closeSpy = vi.spyOn(observer, 'close');
manager.close();
expect(closeSpy).toHaveBeenCalled();
expect(manager.observerList.length).toBe(0);
});
it('should handle manualSave correctly', async () => {
const params = { value: 'test' };
await manager.manualSave('testKey', params);
expect(saveRequest).toHaveBeenCalledWith(params, 'testKey', []);
});
it('call middleware correctly', async () => {
const mockOnBeforeSave = vi.fn().mockResolvedValue({ value: 'before' });
const mockOnAfterSave = vi.fn().mockResolvedValue({ value: 'after' });
const registersWithMiddleware = [
{
key: 'testWithMiddlewareKey',
debounce: DebounceTime.Immediate,
selector: state => state,
middleware: {
onBeforeSave: mockOnBeforeSave,
onAfterSave: mockOnAfterSave,
},
},
];
manager = new AutosaveManager({
store: mockStore,
registers: registersWithMiddleware,
saveRequest,
});
const params = { value: 'test' };
await manager.manualSave('testWithMiddlewareKey', params);
expect(mockOnBeforeSave).toHaveBeenCalledWith(params);
expect(saveRequest).toHaveBeenCalledWith(
{ value: 'before' },
'testWithMiddlewareKey',
[],
);
expect(mockOnAfterSave).toHaveBeenCalledWith(params);
});
it('should call eventCallBacks', async () => {
const onBeforeSaveCallback = vi.fn();
const onAfterSaveCallback = vi.fn();
const eventCallBacks = {
onBeforeSave: onBeforeSaveCallback,
onAfterSave: onAfterSaveCallback,
};
manager = new AutosaveManager({
store: mockStore,
registers,
saveRequest,
eventCallBacks,
});
await manager.manualSave('testKey', { value: 'test' });
expect(onBeforeSaveCallback).toHaveBeenCalledWith({ value: 'test' });
expect(onAfterSaveCallback).toHaveBeenCalledWith({ value: 'test' });
});
it('should save without auto save', async () => {
const handler = vi.fn().mockResolvedValue(undefined);
manager.start();
await manager.handleWithoutAutosave({
key: 'testKey',
handler,
});
const observer = manager.getObserver('testKey');
// 确保所有异步操作完成
await Promise.resolve();
expect(observer.lock).toBe(false);
expect(handler).toHaveBeenCalled();
});
it('should save flush correctly', async () => {
manager.start();
vi.spyOn(manager, 'getObserver').mockReturnValue({
debouncedSaveFunc: {
flush: vi.fn(),
},
});
await manager.saveFlush('testKey');
const observer = manager.getObserver('testKey');
expect(observer.debouncedSaveFunc.flush).toHaveBeenCalled();
});
it('should save flush all correctly', async () => {
manager.start();
const observer = manager.getObserver('testKey');
const nextState = { value: 'next' };
const prevState = { value: 'prev' };
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(1000);
await observer.subscribeCallback(nextState, prevState);
manager.observerList.forEach(ob => {
ob.debouncedSaveFunc.flush = vi.fn();
});
manager.saveFlushAll();
manager.observerList.forEach(ob => {
expect(ob.debouncedSaveFunc.flush).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,354 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StoreApi, type UseBoundStore } from 'zustand';
import { describe, beforeEach, afterEach, vi, expect } from 'vitest';
import { diff, type Diff } from 'deep-diff';
import { DebounceTime, type AutosaveObserverConfig } from '../../src/type';
import { AutosaveObserver } from '../../src/core/observer';
const debounceMock = vi.fn((func, wait) => {
let timeout;
const debounced = (...args) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
debounced.cancel = vi.fn(() => clearTimeout(timeout));
debounced.flush = vi.fn(() => {
if (timeout) {
clearTimeout(timeout);
func();
}
});
return debounced;
});
vi.mock('lodash-es/debounce', () => {
const originalModule = vi.importActual('lodash-es/debounce');
return {
__esModule: true,
default: debounceMock,
...originalModule,
};
});
const mockStore = {
subscribe: vi.fn(),
} as unknown as UseBoundStore<StoreApi<any>>;
describe('AutosaveObserver', () => {
let observer;
const saveRequest = vi.fn().mockResolvedValue(Promise.resolve());
const config: AutosaveObserverConfig<any, any, any> = {
key: 'testKey',
debounce: DebounceTime.Immediate,
selector: state => state,
saveRequest,
};
beforeEach(() => {
vi.useFakeTimers(); // 使用假定时器
observer = new AutosaveObserver({
store: mockStore,
...config,
});
});
afterEach(() => {
vi.resetAllMocks();
vi.useRealTimers(); // 恢复真实的定时器
});
it('should initialize and set initial values correctly', () => {
expect(observer.lock).toBe(false);
expect(observer.config).toStrictEqual(config);
expect(observer.store).toBe(mockStore);
expect(mockStore.subscribe).toHaveBeenCalled();
});
it('should trigger callback and parsedSaveFunc', async () => {
const nextState = { value: 'next' };
const prevState = { value: 'prev' };
const diffChange: Diff<any, any>[] = [
{
kind: 'E',
path: ['value'],
lhs: 'prev',
rhs: 'next',
},
];
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(0);
await observer.subscribeCallback(nextState, prevState);
expect(observer.nextState).toBe(nextState);
expect(observer.prevState).toBe(prevState);
expect(observer.diff).toEqual(diffChange);
expect(saveRequest).toHaveBeenCalledWith(nextState, 'testKey', diffChange);
});
it('should handle array diff change', async () => {
const nextState = { value: [1, 2, 3] };
const prevState = { value: [1, 2] };
const diffChange = diff(prevState, nextState);
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(500);
await observer.subscribeCallback(nextState, prevState);
expect(observer.debouncedSaveFunc).toBeInstanceOf(Function);
vi.runAllTimers(); // 手动推进定时器时间以触发防抖函数
await vi.runAllTimersAsync(); // 确保所有异步操作完成
expect(saveRequest).toHaveBeenCalledWith(nextState, 'testKey', diffChange);
});
it('should cancel and unsubscribe correctly', async () => {
const prevState = {
value: {
a: 1,
},
};
const nextState = {
value: {
a: 2,
},
};
vi.spyOn(observer, 'getMemoizeSelector').mockReturnValue(() => nextState);
vi.spyOn(observer, 'getTriggerDelayTime').mockReturnValue(1000);
await observer.subscribeCallback(nextState, prevState);
const cancelSpy = vi.spyOn(observer.debouncedSaveFunc, 'flush');
const unsubscribeSpy = vi.spyOn(observer, 'unsubscribe');
observer.close();
expect(cancelSpy).toHaveBeenCalled();
expect(unsubscribeSpy).toHaveBeenCalled();
});
it('selector with deps', () => {
const deps = [state => state.value];
const transformer = value => value * 2;
const observerConfig: AutosaveObserverConfig<any, any, any> = {
...config,
selector: { deps, transformer },
};
observer = new AutosaveObserver({
store: mockStore,
...observerConfig,
});
const selector = observer.getMemoizeSelector();
const state = { value: 3 };
const result = selector(state);
expect(result).toBe(transformer(3));
});
it('should return in callback if lock is true', async () => {
observer.lock = true;
const nextState = { value: 'next' };
const prevState = { value: 'prev' };
const getTriggerDelayTimeSpy = vi.spyOn(observer, 'getTriggerDelayTime');
const parsedSaveFuncSpy = vi.spyOn(observer, 'parsedSaveFunc');
await observer.subscribeCallback(nextState, prevState);
expect(observer.nextState).toBe(nextState);
expect(observer.prevState).toBe(prevState);
expect(getTriggerDelayTimeSpy).not.toHaveBeenCalled();
expect(parsedSaveFuncSpy).not.toHaveBeenCalled();
});
it('should return in callback if diffChange is undefined', async () => {
const nextState = { value: 'prev' };
const prevState = { value: 'prev' };
const getTriggerDelayTimeSpy = vi.spyOn(observer, 'getTriggerDelayTime');
const parsedSaveFuncSpy = vi.spyOn(observer, 'parsedSaveFunc');
await observer.subscribeCallback(nextState, prevState);
expect(observer.nextState).toBe(nextState);
expect(observer.prevState).toBe(prevState);
expect(getTriggerDelayTimeSpy).not.toHaveBeenCalled();
expect(parsedSaveFuncSpy).not.toHaveBeenCalled();
});
it('should call onBeforeSave lifecycle callback', async () => {
const onBeforeSave = vi.fn();
observer.config.eventCallBacks = { onBeforeSave };
observer.nextState = { value: 'next' };
await observer.parsedSaveFunc();
expect(onBeforeSave).toHaveBeenCalledWith({
key: observer.config.key,
data: observer.nextState,
});
});
it('should call onAfterSave lifecycle callback', async () => {
const onAfterSave = vi.fn();
observer.config.eventCallBacks = { onAfterSave };
observer.nextState = { value: 'next' };
await observer.parsedSaveFunc();
expect(onAfterSave).toHaveBeenCalledWith({
key: observer.config.key,
data: observer.nextState,
});
});
it('should call onError lifecycle method on error', async () => {
const onError = vi.fn();
observer.config.eventCallBacks = { onError };
saveRequest.mockRejectedValueOnce(new Error('Failed request'));
observer.nextState = { value: 'next' };
await observer.parsedSaveFunc();
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
key: observer.config.key,
error: expect.any(Error),
}),
);
});
it('should return the correct delay time based on debounce configuration', () => {
const prevState = { value: 'prev' };
const diffChange = [{ kind: 'E', path: ['value'] }];
observer.config.debounce = () => 200;
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(200);
observer.config.debounce = { default: 300 };
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(300);
observer.config.debounce = 100;
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(100);
observer.config.debounce = null;
expect(observer.getTriggerDelayTime(prevState, diffChange)).toBe(
DebounceTime.Immediate,
);
});
it('should return default debounce time when diffChange is empty or undefined', () => {
const prevState = {};
const diffChange = undefined;
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(DebounceTime.Immediate);
});
it('should push default debounce time when changePath does not exist or is a number', () => {
const prevState = { value: 'prev' };
const diffChange: Diff<any, any>[] = [
{
kind: 'E',
path: [123],
lhs: 'prev',
rhs: 'next',
},
];
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(DebounceTime.Immediate);
});
it('should push action delay time when DebounceConfig.action is not an object', () => {
const prevState = { value: 'prev' };
const diffChange = [
{
kind: 'E',
path: ['value'],
lhs: 'prev',
rhs: 'next',
},
];
const debounceActionTime = DebounceTime.Long;
observer.config.debounce = {
default: DebounceTime.Long,
value: {
action: debounceActionTime,
},
};
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(debounceActionTime);
});
it('should return an empty string if changePath does not exist', () => {
const changePath = undefined;
const debouncePath = observer.getdebouncePath(changePath);
expect(debouncePath).toBe('');
});
it('should return the first element of changePath', () => {
const changePath = ['value', 0, 'key'];
const debouncePath = observer.getdebouncePath(changePath);
expect(debouncePath).toBe('value');
});
it('should return default debounce time when diffChange is undefined or empty', () => {
const prevState = {};
let diffChange: Diff<any, any>[] | undefined = undefined;
let delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(DebounceTime.Immediate);
diffChange = [];
delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(DebounceTime.Immediate);
});
it('should push default debounce time when changePath does not exist or has conditions', () => {
const prevState = { value: 'prev' };
observer.config.debounce = { default: DebounceTime.Long };
const diffChange = [
{ kind: 'E', path: undefined },
{ kind: 'E', path: ['nonexistentPath'] },
{ kind: 'E', path: [123] },
];
vi.spyOn(observer, 'getdebouncePath').mockReturnValue(123);
const delayTime = observer.getTriggerDelayTime(prevState, diffChange);
expect(delayTime).toBe(DebounceTime.Long);
});
});

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
isFunction,
isObject,
getPayloadByFormatter,
} from '../../src/utils/index';
import { type DebounceConfig, DebounceTime } from '../../src/type/index';
describe('isFunction', () => {
const fn: DebounceConfig = () => DebounceTime.Immediate;
const ob: DebounceConfig = {
default: DebounceTime.Immediate,
};
it('isFunction should return true when the input is function', () => {
const result = isFunction(fn);
expect(result).toBe(true);
});
it('isFunction should return false when the input is object', () => {
const result = isFunction(ob);
expect(result).toBe(false);
});
it('isObject should return true when the input is object', () => {
const result = isObject(ob);
expect(result).toBe(true);
});
});
describe('getPayloadByFormatter', () => {
it('should return state directly if formatter is not provided', async () => {
const state = { key: 'value' };
const result = await getPayloadByFormatter(state);
expect(result).toEqual(state);
});
it('should call formatter and return its result if formatter is provided', async () => {
const state = { key: 'value' };
const formatter = vi
.fn()
.mockResolvedValue({ formattedKey: 'formattedValue' });
const result = await getPayloadByFormatter(state, formatter);
expect(formatter).toHaveBeenCalledWith(state);
expect(result).toEqual({ formattedKey: 'formattedValue' });
});
});

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["./dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,59 @@
{
"name": "@coze-studio/autosave",
"version": "0.0.1",
"description": "autosave",
"license": "Apache-2.0",
"author": "zhangxiang.01@bytedance.com",
"maintainers": [],
"main": "src/index.ts",
"unpkg": "./dist/umd/index.js",
"types": "./src/index.ts",
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/logger": "workspace:*",
"ahooks": "^3.7.8",
"dayjs": "^1.11.7",
"deep-diff": "~1.0.2",
"lodash-es": "^4.17.21",
"reselect": "^5.1.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@swc/core": "^1.3.35",
"@swc/helpers": "^0.4.12",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/deep-diff": "^1.0.5",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"autoprefixer": "^10.4.16",
"less-loader": "~11.1.3",
"postcss": "^8.4.32",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
vi.mock('zustand');

View File

@@ -0,0 +1,179 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StoreApi, type UseBoundStore } from 'zustand';
import {
type AutosaveObserverConfig,
type HostedObserverConfig,
type EventCallBacks,
type SaveRequest,
} from '../type';
import { AutosaveObserver } from './observer';
export interface AutosaveManagerProps<StoreType, ScopeKey, ScopeStateType> {
store: UseBoundStore<StoreApi<StoreType>>;
registers: HostedObserverConfig<StoreType, ScopeKey, ScopeStateType>[];
saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
}
export class AutosaveManager<StoreType, ScopeKey, ScopeStateType> {
private configList: AutosaveObserverConfig<
StoreType,
ScopeKey,
ScopeStateType
>[];
private observerList: AutosaveObserver<StoreType, ScopeKey, ScopeStateType>[];
private store: UseBoundStore<StoreApi<StoreType>>;
private eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
private saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
constructor(
props: AutosaveManagerProps<StoreType, ScopeKey, ScopeStateType>,
) {
this.configList = [];
this.observerList = [];
this.saveRequest = props.saveRequest;
this.eventCallBacks = props.eventCallBacks;
this.store = props.store;
this.register(props.registers);
}
/**
* 注册数据源和定义对应的 Observer 配置
* @param _config
*/
public register = (
registers: HostedObserverConfig<StoreType, ScopeKey, ScopeStateType>[],
) => {
this.close();
this.configList = [];
registers.forEach(register => {
const config: AutosaveObserverConfig<
StoreType,
ScopeKey,
ScopeStateType
> = {
...register,
eventCallBacks: this.eventCallBacks,
saveRequest: this.saveRequest,
};
this.configList.push(config);
});
};
/**
* 启动 Manager 模块
*/
public start = () => {
if (this.observerList.length > 0) {
return;
}
this.observerList = this.configList.map(
config =>
new AutosaveObserver({
store: this.store,
...config,
}),
);
};
/**
* 关闭 Manager 模块下的所有属性监听
*/
public close = () => {
this.observerList.forEach(observer => observer.close());
this.observerList = [];
};
/**
* 手动保存
* @param params
*/
public manualSave = async (key: ScopeKey, params: ScopeStateType) => {
const config = this.getConfig(key);
if (!config) {
return;
}
const { middleware, eventCallBacks, saveRequest } = config;
const beforeSavePayload = middleware?.onBeforeSave
? await middleware?.onBeforeSave(params)
: params;
eventCallBacks?.onBeforeSave?.(beforeSavePayload);
await saveRequest(beforeSavePayload as ScopeStateType, key, []);
const afterSavePayload = middleware?.onAfterSave
? await middleware?.onAfterSave(params)
: params;
eventCallBacks?.onAfterSave?.(afterSavePayload);
};
/**
* 回调过程中关闭自动保存
* @param params
*/
public handleWithoutAutosave = async (params: {
key: ScopeKey;
handler: () => Promise<void>;
}) => {
const { key, handler } = params;
const observers = this.observerList.filter(o => o.config.key === key);
if (observers.length) {
observers.forEach(o => (o.lock = true));
await handler();
observers.forEach(o => (o.lock = false));
}
};
/**
* 立即触发保存
* @param key
*/
public saveFlush = (key: ScopeKey) => {
const observer = this.getObserver(key);
observer?.debouncedSaveFunc?.flush?.();
};
/**
* 立即触发所有保存
* @param key
*/
public saveFlushAll = () => {
this.observerList.forEach(observer =>
observer?.debouncedSaveFunc?.flush?.(),
);
};
/**
* 获取目标 observer 配置
* @param key
*/
private getObserver = (key: ScopeKey) =>
this.observerList.find(i => i.config.key === key);
/**
* 获取目标配置项
* @param key
*/
private getConfig = (key: ScopeKey) =>
this.configList.find(i => i.key === key);
}

View File

@@ -0,0 +1,253 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type StoreApi, type UseBoundStore } from 'zustand';
import { createSelector } from 'reselect';
import { debounce, has, get, type DebouncedFunc } from 'lodash-es';
import diff, { type Diff } from 'deep-diff';
import { isFunction, isObject, getPayloadByFormatter } from '../utils';
import {
DebounceTime,
type AutosaveObserverConfig,
type UseStoreType,
type PathType,
type AutosaveObserverProps,
} from '../type/index';
export class AutosaveObserver<StoreType, ScopeKey, ScopeStateType> {
private store: UseBoundStore<StoreApi<StoreType>>;
public lock: boolean;
public debouncedSaveFunc: DebouncedFunc<() => Promise<void>>;
public nextState!: ScopeStateType;
public prevState!: ScopeStateType;
private diff!: Diff<ScopeStateType, ScopeStateType>[];
private unobserver?: () => void;
private unsubscribe!: () => void;
public config: AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType>;
constructor(
props: AutosaveObserverProps<StoreType, ScopeKey, ScopeStateType>,
) {
const { store, ...config } = props;
this.store = store;
this.lock = false;
this.config = config;
// 订阅字段初始化
this.initSubscribe();
}
private initSubscribe = () => {
const memoizeSelector = this.getMemoizeSelector();
this.unsubscribe = (
this.store as unknown as UseStoreType<StoreType, ScopeStateType>
).subscribe(memoizeSelector, this.subscribeCallback);
};
private getMemoizeSelector = () => {
if (typeof this.config.selector === 'function') {
return this.config.selector;
} else {
// 使用createSelector创建可记忆化的选择器
const { deps, transformer } = this.config.selector;
return createSelector(deps, transformer);
}
};
private subscribeCallback = async (nextState, prevState) => {
console.log('nextState :>> ', nextState);
console.log('prevState :>> ', prevState);
// selector 返回的 state
this.nextState = nextState;
this.prevState = prevState;
if (this.lock) {
return;
}
const diffChange: Diff<ScopeStateType, ScopeStateType>[] | undefined = diff(
prevState,
nextState,
);
console.log('diffChange:>>', diffChange);
if (!diffChange) {
return;
}
this.debouncedSaveFunc?.cancel?.();
this.diff = diffChange;
const delayTime = this.getTriggerDelayTime(prevState, diffChange);
console.log('delayTime:>>>>>', delayTime);
if (delayTime === 0 || this.config.immediate) {
await this.parsedSaveFunc();
return;
}
this.debouncedSaveFunc = debounce(this.parsedSaveFunc, delayTime);
await this.debouncedSaveFunc();
};
private parsedSaveFunc = async () => {
// 中间件-保存前
const beforeSavePayload = await getPayloadByFormatter<ScopeStateType>(
this.nextState,
this.config?.middleware?.onBeforeSave,
);
// 生命周期-保存前
await this.config?.eventCallBacks?.onBeforeSave?.({
key: this.config.key,
data: beforeSavePayload,
});
console.log('beforeSavePayload:>>', beforeSavePayload);
try {
await this.config.saveRequest(
beforeSavePayload,
this.config.key,
this.diff,
);
// 中间件-保存后
const afterSavePayload = await getPayloadByFormatter<ScopeStateType>(
this.nextState,
this.config?.middleware?.onAfterSave,
);
console.log('afterSavePayload:>>', afterSavePayload);
// 生命周期-保存后
await this.config?.eventCallBacks?.onAfterSave?.({
key: this.config.key,
data: afterSavePayload,
});
} catch (error) {
console.log('error:>>', error);
// 生命周期-异常
this.config?.eventCallBacks?.onError?.({
key: this.config.key,
error: error as Error,
});
}
};
/**
* 取消订阅
*/
public close = () => {
this.debouncedSaveFunc?.flush();
this.unsubscribe();
this.unobserver?.();
};
/**
* 获取状态变更带来的触发延时时间
* @param prevState selector 选择的 store 的内容
* @param diffChange 前后比对的diff
* @returns 延时时间
*/
private getTriggerDelayTime = (
prevState?: ScopeStateType,
diffChange?: Diff<ScopeStateType, ScopeStateType>[],
) => {
const configDebounce = this.config.debounce;
if (!configDebounce) {
return DebounceTime.Immediate;
}
if (isFunction(configDebounce)) {
return configDebounce();
}
if (!isObject(configDebounce)) {
return configDebounce;
}
if (!diffChange || diffChange.length === 0) {
return configDebounce.default;
}
const targetDelayTimes: number[] = [];
for (const change of diffChange) {
const changePath = change.path;
const debouncePath = this.getdebouncePath(changePath);
if (
!changePath ||
!has(prevState, changePath) ||
typeof debouncePath === 'number'
) {
targetDelayTimes.push(configDebounce.default);
continue;
}
const debounceType = get(
configDebounce,
debouncePath,
configDebounce.default,
);
if (!isObject(debounceType)) {
targetDelayTimes.push(debounceType);
continue;
}
if (!debounceType.arrayType) {
targetDelayTimes.push(configDebounce.default);
continue;
}
if (!isObject(debounceType.action as DebounceTime)) {
targetDelayTimes.push(debounceType.action as DebounceTime);
} else {
const kind =
change.kind === 'A' && change.item?.kind
? change.item?.kind
: change.kind;
const triggerKind = debounceType.action[kind];
targetDelayTimes.push(triggerKind);
}
}
return Math.min(...targetDelayTimes);
};
/**
* 获取变更与 trigger 声明配置对应的 key
* @param changePath diff path
* @returns path key
*/
private getdebouncePath = (changePath?: PathType[]) => {
if (!changePath) {
return '';
}
const indexPath = path => typeof path === 'number';
const isArrayPath = changePath.some(indexPath);
if (isArrayPath) {
return changePath[0];
}
return changePath.join('.');
};
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { AutosaveManager } from './core/manager';
export {
DebounceTime,
EventCallBacks,
SaveRequest,
HostedObserverConfig,
} from './type';
export type { Diff } from 'deep-diff';

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/naming-convention */
import { type StoreApi, type UseBoundStore } from 'zustand';
import type { Diff } from 'deep-diff';
/**
* 防抖延迟时间
* @readonly
* @enum {number}
*/
export enum DebounceTime {
/** 用于需要立即响应的保存操作,如按钮或下拉选择等操作 */
Immediate = 0,
/** 用于需要较短时间响应的保存操作,如拖拽等操作 */
Medium = 500,
/** 适合于文本输入等操作 */
Long = 1000,
}
/** trigger 配置声明时的函数形式,用于在运行时指定字段触发时机 */
export type FunctionDebounceTime = () => DebounceTime;
/** trigger 配置声明时的数组形式,用于分别指定数组内容变化时的触发时机 */
export interface ArrayDebounceTime {
arrayType: boolean;
action:
| DebounceTime
| {
N?: DebounceTime;
D?: DebounceTime;
E?: DebounceTime;
};
}
/** trigger 配置声明时的对象形式,用于分别指定多字段触发时机 */
export interface ObjectDebounceTime {
default: DebounceTime;
[index: string]: DebounceTime | ArrayDebounceTime;
}
export type DebounceConfig =
| DebounceTime
| ObjectDebounceTime
| FunctionDebounceTime;
export type FlexibleState<T> = T | any;
export type HostedObserverConfig<StoreType, ScopeKey, ScopeStateType> = Omit<
AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType>,
'saveRequest' | 'eventCallBacks' | 'unobserver' | 'immediate'
>;
export type AutosaveObserverProps<StoreType, ScopeKey, ScopeStateType> =
AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType> & {
store: UseBoundStore<StoreApi<StoreType>>;
};
export type SelectorType<StoreType, ScopeStateType> =
| ((store: StoreType) => ScopeStateType)
| {
deps: ((store: StoreType) => ScopeStateType)[];
transformer: (...args: ScopeStateType[]) => FlexibleState<ScopeStateType>;
};
export interface AutosaveObserverConfig<StoreType, ScopeKey, ScopeStateType> {
/** 被托管的数据字段的类型 */
key: ScopeKey;
/** 防抖延迟时间 */
debounce?: DebounceConfig;
/** store 需要被监听的属性选择器,支持配置依赖 */
selector: SelectorType<StoreType, ScopeStateType>;
/** 中间件 支持业务链式处理监听数据 */
middleware?: MiddlewareHanderMap<ScopeStateType>;
/** 是否立即保存当前字段 */
immediate?: boolean;
/** 保存的请求 */
saveRequest: SaveRequest<ScopeStateType, ScopeKey>;
/** 被托管的数据取消订阅时进行的回调 */
unobserver?: () => void;
/** 生命周期 */
eventCallBacks?: EventCallBacks<ScopeStateType, ScopeKey>;
}
export type SaveRequest<ScopeStateType, ScopeKey> = (
payload: FlexibleState<ScopeStateType>,
key: ScopeKey,
diff: Diff<ScopeStateType, ScopeStateType>[],
) => Promise<FlexibleState<ScopeStateType>>;
export type SaveMiddlewareHander<ScopeStateType> = (
data: FlexibleState<ScopeStateType>,
) => Promise<FlexibleState<ScopeStateType>> | FlexibleState<ScopeStateType>;
export interface MiddlewareHanderMap<ScopeStateType> {
/** 生命周期-检测变更后 */
onBeforeSave?: SaveMiddlewareHander<ScopeStateType>;
/** 生命周期-成功保存后 */
onAfterSave?: SaveMiddlewareHander<ScopeStateType>;
}
export interface EventCallBacks<ScopeStateType, ScopeKey> {
/** 生命周期-检测变更后 */
onBeforeSave?: (params: {
data: FlexibleState<ScopeStateType>;
key: ScopeKey;
}) => void | Promise<void>;
/** 生命周期-成功保存后 */
onAfterSave?: (params: {
data: FlexibleState<ScopeStateType>;
key: ScopeKey;
}) => void | Promise<void>;
/** 生命周期-异常 */
onError?: (params: { error: Error; key: ScopeKey }) => void;
}
// 比对出来的被变化的 key 的 path number 形式对应数组
export type PathType = string | number;
export interface UseStoreType<StoreType, ScopeStateType> {
subscribe: {
(
listener: (
selectedState: ScopeStateType,
previousSelectedState: ScopeStateType,
) => void,
): () => void;
<U>(
selector: (state: StoreType) => U,
listener: (selectedState: U, previousSelectedState: U) => void,
options?: {
equalityFn?: (a: U, b: U) => boolean;
fireImmediately?: boolean;
},
): () => void;
};
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
isObject as isObjectBase,
isFunction as isFunctionBase,
} from 'lodash-es';
import {
type DebounceConfig,
type ObjectDebounceTime,
type SaveMiddlewareHander,
type FunctionDebounceTime,
} from '../type/index';
export function isFunction(
value: DebounceConfig,
): value is FunctionDebounceTime {
return isFunctionBase(value);
}
export function isObject(value: DebounceConfig): value is ObjectDebounceTime {
return isObjectBase(value);
}
/**
* 获取保存接口调用时候需要的参数
*/
export const getPayloadByFormatter = async <T>(
state: T,
formatter?: SaveMiddlewareHander<T>,
) => {
if (formatter) {
return await formatter(state);
}
return state;
};

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"types": [],
"module": "ESNext",
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"references": [
{
"path": "../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../arch/logger/tsconfig.build.json"
},
{
"path": "../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../config/vitest-config/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
],
"exclude": ["**/*"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"include": ["__tests__", "__mocks__", "vitest.config.ts", "setup-vitest.ts"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
],
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"rootDir": "./",
"outDir": "./dist",
"types": ["vitest/globals"],
"module": "ESNext"
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
test: {
setupFiles: ['./setup-vitest.ts'],
},
});