diff --git a/libs/horizon/src/horizonx/CommonUtils.ts b/libs/horizon/src/horizonx/CommonUtils.ts index e799c963..e6387b6a 100644 --- a/libs/horizon/src/horizonx/CommonUtils.ts +++ b/libs/horizon/src/horizonx/CommonUtils.ts @@ -19,19 +19,31 @@ export function isObject(obj: any): boolean { } export function isSet(obj: any): boolean { - return (obj !== null || obj !== undefined) && (Object.prototype.toString.call(obj) === '[object Set]' || obj.constructor === Set); + return ( + (obj !== null || obj !== undefined) && + (Object.prototype.toString.call(obj) === '[object Set]' || obj.constructor === Set) + ); } export function isWeakSet(obj: any): boolean { - return (obj !== null || obj !== undefined) && (Object.prototype.toString.call(obj) === '[object WeakSet]' || obj.constructor === WeakSet); + return ( + (obj !== null || obj !== undefined) && + (Object.prototype.toString.call(obj) === '[object WeakSet]' || obj.constructor === WeakSet) + ); } export function isMap(obj: any): boolean { - return (obj !== null || obj !== undefined) && (Object.prototype.toString.call(obj) === '[object Map]' || obj.constructor === Map); + return ( + (obj !== null || obj !== undefined) && + (Object.prototype.toString.call(obj) === '[object Map]' || obj.constructor === Map) + ); } export function isWeakMap(obj: any): boolean { - return (obj !== null || obj !== undefined) && (Object.prototype.toString.call(obj) === '[object WeakMap]' || obj.constructor === WeakMap); + return ( + (obj !== null || obj !== undefined) && + (Object.prototype.toString.call(obj) === '[object WeakMap]' || obj.constructor === WeakMap) + ); } export function isArray(obj: any): boolean { @@ -68,3 +80,74 @@ export function isSame(x, y) { return Object.is(x, y); } } + +export function getDetailedType(val: any) { + if (val === undefined) return 'undefined'; + if (val === null) return 'null'; + if (isCollection(val)) return 'collection'; + if (isPromise(val)) return 'promise'; + if (isArray(val)) return 'array'; + if (isWeakMap(val)) return 'weakMap'; + if (isMap(val)) return 'map'; + if (isWeakSet(val)) return 'weakSet'; + if (isSet(val)) return 'set'; + return typeof val; +} + +export function resolveMutation(from, to) { + if (getDetailedType(from) !== getDetailedType(to)) { + return { mutation: true, from, to }; + } + + switch (getDetailedType(from)) { + case 'array': { + let len = Math.max(from.length, to.length); + const res: any[] = []; + let found = false; + for (let i = 0; i < len; i++) { + if (from.length <= i) { + res[i] = { mutation: true, to: to[i] }; + found = true; + } else if (to.length <= i) { + res[i] = { mutation: true, from: from[i] }; + found = true; + } else { + res[i] = resolveMutation(from[i], to[i]); + if (res[i].mutation) found = true; + } + } + // TODO: resolve shifts + return { mutation: found, items: res, from, to }; + } + + case 'object': { + let keys = Object.keys({ ...from, ...to }); + const res = {}; + let found = false; + keys.forEach(key => { + if (!(key in from)) { + res[key] = { mutation: true, to: to[key] }; + found = true; + return; + } + + if (!(key in to)) { + res[key] = { mutation: true, from: from[key] }; + found = true; + return; + } + res[key] = resolveMutation(from[key], to[key]); + if (res[key].mutation) found = true; + }); + return { mutation: found, attributes: res, from, to }; + } + + // TODO: implement collections + + default: { + if (from === to) return { mutation: false }; + + return { mutation: true, from, to }; + } + } +} diff --git a/libs/horizon/src/horizonx/adapters/reduxReact.ts b/libs/horizon/src/horizonx/adapters/reduxReact.ts index 8f2818b3..6922cf64 100644 --- a/libs/horizon/src/horizonx/adapters/reduxReact.ts +++ b/libs/horizon/src/horizonx/adapters/reduxReact.ts @@ -34,9 +34,9 @@ export function Provider({ return createElement(Context.Provider, { value: store }, children); } -export function createStoreHook(context: Context) { +export function createStoreHook(context: Context): () => ReduxStoreHandler { return () => { - return useContext(context); + return useContext(context) as unknown as ReduxStoreHandler; }; } @@ -85,17 +85,19 @@ export function connect( mergeProps?: (stateProps: object, dispatchProps: object, ownProps: object) => object, options?: { areStatesEqual?: (oldState: any, newState: any) => boolean; - context?: any; // TODO: type this + context?: Context; } -) { +): Connector { if (!options) { options = {}; } - return Component => { + //this component should bear the type returned from mapping functions + return (Component: OriginalComponent): WrappedComponent => { const useStore = createStoreHook(options?.context || DefaultContext); - function Wrapper(props) { + //this component should mimic original type of component used + const Wrapper: WrappedComponent = (props: OwnProps) => { const [f, forceReload] = useState(true); const store = useStore() as unknown as ReduxStoreHandler; @@ -112,36 +114,34 @@ export function connect( mappedState: {}, }) as { current: { - state: {}; - mappedState: {}; + state: { [key: string]: any }; + mappedState: StateProps; }; }; - let mappedState; + let mappedState: StateProps; if (options?.areStatesEqual) { if (options.areStatesEqual(previous.current.state, store.getState())) { - mappedState = previous.current.mappedState; + mappedState = previous.current.mappedState as StateProps; } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); previous.current.mappedState = mappedState; } } else { - mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : {}; + mappedState = mapStateToProps ? mapStateToProps(store.getState(), props) : ({} as StateProps); previous.current.mappedState = mappedState; } - let mappedDispatch: { dispatch?: (action) => void } = {}; + let mappedDispatch: DispatchProps = {} as DispatchProps; if (mapDispatchToProps) { if (typeof mapDispatchToProps === 'object') { Object.entries(mapDispatchToProps).forEach(([key, value]) => { - mappedDispatch[key] = (...args) => { + mappedDispatch[key] = (...args: ReduxAction[]) => { store.dispatch(value(...args)); }; }); } else { mappedDispatch = mapDispatchToProps(store.dispatch, props); } - } else { - mappedDispatch.dispatch = store.dispatch; } const mergedProps = ( mergeProps || @@ -154,7 +154,7 @@ export function connect( const node = createElement(Component, mergedProps); return node; - } + }; return Wrapper; }; diff --git a/libs/horizon/src/horizonx/devtools/constants.ts b/libs/horizon/src/horizonx/devtools/constants.ts new file mode 100644 index 00000000..3e105125 --- /dev/null +++ b/libs/horizon/src/horizonx/devtools/constants.ts @@ -0,0 +1,10 @@ +export const INITIALIZED = 'horizonx store initialized'; +export const STATE_CHANGE = 'horizonx state change'; +export const SUBSCRIBED = 'horizonx subscribed'; +export const UNSUBSCRIBED = 'horizonx unsubscribed'; +export const ACTION = 'horizonx action'; +export const ACTION_QUEUED = 'horizonx action queued'; +export const QUEUE_PENDING = 'horizonx queue pending'; +export const QUEUE_FINISHED = 'horizonx queue finished'; +export const RENDER_TRIGGERED = 'horizonx render triggered'; +export const OBSERVED_COMPONENTS = 'horizonx observed components'; diff --git a/libs/horizon/src/horizonx/devtools/index.ts b/libs/horizon/src/horizonx/devtools/index.ts new file mode 100644 index 00000000..e7f3514e --- /dev/null +++ b/libs/horizon/src/horizonx/devtools/index.ts @@ -0,0 +1,120 @@ +import { getStore, getAllStores } from '../store/StoreHandler'; +import { OBSERVED_COMPONENTS } from './constants'; + +const sessionId = Date.now(); + +// this function is used to detect devtool connection +export function isPanelActive() { + return window['__HORIZON_DEV_HOOK__']; +} + +// serializes store and creates expanded object with baked-in containing current computed values +function makeStoreSnapshot({ type, data }) { + const expanded = {}; + Object.keys(data.store.$c).forEach(key => { + expanded[key] = data.store[key]; + }); + data.store.expanded = expanded; + const snapshot = makeProxySnapshot({ + data, + type, + sessionId, + }); + return snapshot; +} + +// safely serializes variables containing values wrapped in Proxy object +function makeProxySnapshot(obj) { + let clone; + try { + if (!obj) { + return obj; + } + if (obj.nativeEvent) return obj.type + 'Event'; + if (typeof obj === 'function') { + return obj.toString(); + } + if (Array.isArray(obj)) { + clone = []; + obj.forEach(item => clone.push(makeProxySnapshot(item))); + return clone; + } else if (typeof obj === 'object') { + clone = {}; + Object.entries(obj).forEach(([id, value]) => (clone[id] = makeProxySnapshot(value))); + return clone; + } + return obj; + } catch (err) { + throw console.log('cannot serialize object. ' + err); + } +} + +export const devtools = { + // returns vNode id from horizon devtools + getVNodeId: vNode => { + if (!isPanelActive()) return; + window['__HORIZON_DEV_HOOK__'].send(); // update list first + return window['__HORIZON_DEV_HOOK__'].getVnodeId(vNode); + }, + // sends horizonx devtool message to extension + emit: (type, data) => { + if (!isPanelActive()) return; + window.postMessage({ + type: 'HORIZON_DEV_TOOLS', + payload: makeStoreSnapshot({ type, data }), + from: 'dev tool hook', + }); + }, +}; + +// collects components that are dependant on horizonx store and their ids +function getAffectedComponents() { + const allStores = getAllStores(); + const keys = Object.keys(allStores); + let res = {}; + keys.forEach(key => { + const subRes = new Set(); + const process = Array.from(allStores[key].$config.state._horizonObserver.keyVNodes.values()); + while (process.length) { + let pivot = process.shift(); + if (pivot?.tag) subRes.add(pivot); + if (pivot?.toString() === '[object Set]') Array.from(pivot).forEach(item => process.push(item)); + } + res[key] = Array.from(subRes).map(vnode => { + return { + name: vnode?.type + .toString() + .replace(/\{.*\}/gms, '{...}') + .replace('function ', ''), + nodeId: window.__HORIZON_DEV_HOOK__.getVnodeId(vnode), + }; + }); + }); + + return res; +} + +// listens to messages from background +window.addEventListener('message', messageEvent => { + if (messageEvent?.data?.payload?.type === 'horizonx request observed components') { + // get observed components + setTimeout(() => { + window.postMessage({ + type: 'HORIZON_DEV_TOOLS', + payload: { type: OBSERVED_COMPONENTS, data: getAffectedComponents() }, + from: 'dev tool hook', + }); + }, 100); + } + + // executes store action + if (messageEvent.data.payload.type === 'horizonx executue action') { + const data = messageEvent.data.payload.data; + const store = getStore(data.storeId); + if (!store?.[data.action]) return; + + const action = store[data.action]; + const params = data.params; + action(...params); + } +}); diff --git a/libs/horizon/src/horizonx/proxy/HooklessObserver.ts b/libs/horizon/src/horizonx/proxy/HooklessObserver.ts index ba390a3a..dd5015e9 100644 --- a/libs/horizon/src/horizonx/proxy/HooklessObserver.ts +++ b/libs/horizon/src/horizonx/proxy/HooklessObserver.ts @@ -19,28 +19,28 @@ import type { IObserver } from './Observer'; * 一个对象(对象、数组、集合)对应一个Observer */ export class HooklessObserver implements IObserver { - listeners: (() => void)[] = []; + listeners: ((mutation) => void)[] = []; useProp(key: string | symbol): void {} - addListener(listener: () => void) { + addListener(listener: (mutation) => void) { this.listeners.push(listener); } - removeListener(listener: () => void) { + removeListener(listener: (mutation) => void) { this.listeners = this.listeners.filter(item => item != listener); } - setProp(key: string | symbol): void { - this.triggerChangeListeners(); + setProp(key: string | symbol, mutation: any): void { + this.triggerChangeListeners(mutation); } - triggerChangeListeners(): void { + triggerChangeListeners(mutation: any): void { this.listeners.forEach(listener => { if (!listener) { return; } - listener(); + listener(mutation); }); } diff --git a/libs/horizon/src/horizonx/proxy/Observer.ts b/libs/horizon/src/horizonx/proxy/Observer.ts index c898c2b9..b1aa43d7 100644 --- a/libs/horizon/src/horizonx/proxy/Observer.ts +++ b/libs/horizon/src/horizonx/proxy/Observer.ts @@ -16,6 +16,7 @@ import { launchUpdateFromVNode } from '../../renderer/TreeBuilder'; import { getProcessingVNode } from '../../renderer/GlobalVar'; import { VNode } from '../../renderer/vnode/VNode'; +import { devtools } from '../devtools'; export interface IObserver { useProp: (key: string) => void; @@ -24,9 +25,9 @@ export interface IObserver { removeListener: (listener: () => void) => void; - setProp: (key: string) => void; + setProp: (key: string, mutation: any) => void; - triggerChangeListeners: () => void; + triggerChangeListeners: (mutation: any) => void; triggerUpdate: (vNode: any) => void; @@ -43,9 +44,9 @@ export class Observer implements IObserver { keyVNodes = new Map(); - listeners: (() => void)[] = []; + listeners: ((mutation) => void)[] = []; - watchers = {} as { [key: string]: ((key: string, oldValue: any, newValue: any) => void)[] }; + watchers = {} as { [key: string]: ((key: string, oldValue: any, newValue: any, mutation: any) => void)[] }; // 对象的属性被使用时调用 useProp(key: string | symbol): void { @@ -76,7 +77,7 @@ export class Observer implements IObserver { } // 对象的属性被赋值时调用 - setProp(key: string | symbol): void { + setProp(key: string | symbol, mutation: any): void { const vNodes = this.keyVNodes.get(key); //NOTE: using Set directly can lead to deadlock const vNodeArray = Array.from(vNodes || []); @@ -91,7 +92,8 @@ export class Observer implements IObserver { this.triggerUpdate(vNode); }); - this.triggerChangeListeners(); + // NOTE: mutations are different in dev and production. + this.triggerChangeListeners({ mutation, vNodes }); } triggerUpdate(vNode: VNode): void { @@ -103,16 +105,35 @@ export class Observer implements IObserver { launchUpdateFromVNode(vNode); } - addListener(listener: () => void): void { + addListener(listener: (mutation) => void): void { this.listeners.push(listener); } - removeListener(listener: () => void): void { + removeListener(listener: (mutation) => void): void { this.listeners = this.listeners.filter(item => item != listener); } - triggerChangeListeners(): void { - this.listeners.forEach(listener => listener()); + triggerChangeListeners({ mutation, vNodes }): void { + const nodesList = vNodes ? Array.from(vNodes) : []; + this.listeners.forEach(listener => + listener({ + mutation, + vNodes: nodesList.map(vNode => { + let realNode = vNode.realNode; + let searchedNode = vNode; + while (!realNode) { + searchedNode = searchedNode.child; + realNode = searchedNode.realNode; + } + return { + type: vNode?.type?.name, + id: devtools.getVNodeId(vNode), + path: vNode.path, + element: realNode?.outerHTML?.substr(0, 100), + }; + }), + }) + ); } // 触发所有使用的props的VNode更新 @@ -120,7 +141,7 @@ export class Observer implements IObserver { const keyIt = this.keyVNodes.keys(); let keyItem = keyIt.next(); while (!keyItem.done) { - this.setProp(keyItem.value); + this.setProp(keyItem.value, {}); keyItem = keyIt.next(); } } diff --git a/libs/horizon/src/horizonx/proxy/ProxyHandler.ts b/libs/horizon/src/horizonx/proxy/ProxyHandler.ts index 9ea2c7af..fda6d653 100644 --- a/libs/horizon/src/horizonx/proxy/ProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/ProxyHandler.ts @@ -27,9 +27,9 @@ const proxyMap = new WeakMap(); export const hookObserverMap = new WeakMap(); -export function createProxy(rawObj: any, isHookObserver = true): any { +export function createProxy(rawObj: any, id, isHookObserver = true): any { // 不是对象(是原始数据类型)不用代理 - if (!isObject(rawObj)) { + if (!(rawObj && isObject(rawObj))) { return rawObj; } diff --git a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts index 8279512f..9e9c2845 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/ArrayProxyHandler.ts @@ -16,6 +16,8 @@ import { getObserver } from '../ProxyHandler'; import { isSame, isValidIntegerKey } from '../../CommonUtils'; import { get as objectGet } from './ObjectProxyHandler'; +import { resolveMutation } from '../../CommonUtils'; +import { isPanelActive } from '../../devtools'; export function createArrayProxy(rawObj: any[]): any[] { const handle = { @@ -53,26 +55,30 @@ function set(rawObj: any[], key: string, value: any, receiver: any) { const oldLength = rawObj.length; const newValue = value; + const oldArray = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; + const ret = Reflect.set(rawObj, key, newValue, receiver); const newLength = rawObj.length; const observer = getObserver(rawObj); + const mutation = isPanelActive() ? resolveMutation(oldArray, rawObj) : { mutation: true, from: [], to: rawObj }; + if (!isSame(newValue, oldValue)) { // 值不一样,触发监听器 if (observer.watchers?.[key]) { observer.watchers[key].forEach(cb => { - cb(key, oldValue, newValue); + cb(key, oldValue, newValue, mutation); }); } // 触发属性变化 - observer.setProp(key); + observer.setProp(key, mutation); } if (oldLength !== newLength) { // 触发数组的大小变化 - observer.setProp('length'); + observer.setProp('length', mutation); } return ret; diff --git a/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts index dad28366..05174cc5 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/CollectionProxyHandler.ts @@ -15,6 +15,8 @@ import { createProxy, getObserver, hookObserverMap } from '../ProxyHandler'; import { isMap, isWeakMap, isSame } from '../../CommonUtils'; +import { resolveMutation } from '../../CommonUtils'; +import { isPanelActive } from '../../devtools'; const COLLECTION_CHANGE = '_collectionChange'; const handler = { @@ -90,18 +92,20 @@ function set( const valChange = !isSame(newValue, oldValue); const observer = getObserver(rawObj); + const mutation = isPanelActive() ? resolveMutation(oldValue, rawObj) : { mutation: true, from: null, to: rawObj }; + if (valChange || !rawObj.has(key)) { - observer.setProp(COLLECTION_CHANGE); + observer.setProp(COLLECTION_CHANGE, mutation); } if (valChange) { if (observer.watchers?.[key]) { observer.watchers[key].forEach(cb => { - cb(key, oldValue, newValue); + cb(key, oldValue, newValue, mutation); }); } - observer.setProp(key); + observer.setProp(key, mutation); } return rawObj; @@ -109,12 +113,16 @@ function set( // Set的add方法 function add(rawObj: { add: (any) => void; set: (string, any) => any; has: (any) => boolean }, value: any): Object { + const oldCollection = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; if (!rawObj.has(value)) { rawObj.add(value); const observer = getObserver(rawObj); - observer.setProp(value); - observer.setProp(COLLECTION_CHANGE); + const mutation = isPanelActive() + ? resolveMutation(oldCollection, rawObj) + : { mutation: true, from: null, to: rawObj }; + observer.setProp(value, mutation); + observer.setProp(COLLECTION_CHANGE, mutation); } return rawObj; @@ -138,12 +146,16 @@ function clear(rawObj: { size: number; clear: () => void }) { } function deleteFun(rawObj: { has: (key: any) => boolean; delete: (key: any) => void }, key: any) { + const oldCollection = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; if (rawObj.has(key)) { rawObj.delete(key); const observer = getObserver(rawObj); - observer.setProp(key); - observer.setProp(COLLECTION_CHANGE); + const mutation = isPanelActive() + ? resolveMutation(oldCollection, rawObj) + : { mutation: true, from: null, to: rawObj }; + observer.setProp(key, mutation); + observer.setProp(COLLECTION_CHANGE, mutation); return true; } diff --git a/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts index a231988e..80da37fd 100644 --- a/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts +++ b/libs/horizon/src/horizonx/proxy/handlers/ObjectProxyHandler.ts @@ -13,9 +13,10 @@ * See the Mulan PSL v2 for more details. */ -import { isSame } from '../../CommonUtils'; +import { isSame, resolveMutation } from '../../CommonUtils'; import { createProxy, getObserver, hookObserverMap } from '../ProxyHandler'; import { OBSERVER_KEY } from '../../Constants'; +import { isPanelActive } from '../../devtools'; export function createObjectProxy(rawObj: T, singleLevel = false): ProxyHandler { const proxy = new Proxy(rawObj, { @@ -70,6 +71,7 @@ export function get(rawObj: object, key: string | symbol, receiver: any, singleL } export function set(rawObj: object, key: string, value: any, receiver: any): boolean { + const oldObject = isPanelActive() ? JSON.parse(JSON.stringify(rawObj)) : null; const observer = getObserver(rawObj); if (value && key == 'removeListener') { @@ -79,15 +81,15 @@ export function set(rawObj: object, key: string, value: any, receiver: any): boo const newValue = value; const ret = Reflect.set(rawObj, key, newValue, receiver); + const mutation = isPanelActive() ? resolveMutation(oldObject, rawObj) : { mutation: true, from: null, to: rawObj }; if (!isSame(newValue, oldValue)) { if (observer.watchers?.[key]) { observer.watchers[key].forEach(cb => { - cb(key, oldValue, newValue); + cb(key, oldValue, newValue, mutation); }); } - observer.setProp(key); + observer.setProp(key, mutation); } - return ret; } diff --git a/libs/horizon/src/horizonx/store/StoreHandler.ts b/libs/horizon/src/horizonx/store/StoreHandler.ts index aaa8e029..8b3aa154 100644 --- a/libs/horizon/src/horizonx/store/StoreHandler.ts +++ b/libs/horizon/src/horizonx/store/StoreHandler.ts @@ -14,26 +14,44 @@ */ import { useEffect, useRef } from '../../renderer/hooks/HookExternal'; -import { getProcessingVNode } from '../../renderer/GlobalVar'; +import { getProcessingVNode, getStartVNode } from '../../renderer/GlobalVar'; import { createProxy } from '../proxy/ProxyHandler'; import readonlyProxy from '../proxy/readonlyProxy'; import { Observer } from '../proxy/Observer'; import { FunctionComponent, ClassComponent } from '../../renderer/vnode/VNodeTags'; import { isPromise } from '../CommonUtils'; import type { - ActionFunction, ComputedValues, - PlannedAction, QueuedStoreActions, + ActionFunction, + ComputedValues, + PlannedAction, + QueuedStoreActions, StoreActions, StoreConfig, StoreObj, UserActions, - UserComputedValues + UserComputedValues, } from '../types'; -import {VNode} from '../../renderer/vnode/VNode'; +import { VNode } from '../../renderer/vnode/VNode'; +import { devtools } from '../devtools'; +import { + ACTION, + ACTION_QUEUED, + INITIALIZED, + QUEUE_FINISHED, + STATE_CHANGE, + SUBSCRIBED, + UNSUBSCRIBED, +} from '../devtools/constants'; + +const idGenerator = { + id: 0, + get: function (prefix) { + return prefix.toString() + this.id++; + }, +}; const storeMap = new Map>(); - export function createStore, C extends UserComputedValues>( config: StoreConfig ): () => StoreObj { @@ -42,7 +60,9 @@ export function createStore, C extend throw new Error('store obj must be pure object'); } - const proxyObj = createProxy(config.state, !config.options?.isReduxAdapter); + const id = config.id || idGenerator.get('UNNAMED_STORE'); + + const proxyObj = createProxy(config.state, id, !config.options?.isReduxAdapter); proxyObj.$pending = false; @@ -50,15 +70,18 @@ export function createStore, C extend const $queue: Partial> = {}; const $c: Partial> = {}; const storeObj = { + id, $s: proxyObj, $a: $a as StoreActions, $c: $c as ComputedValues, $queue: $queue as QueuedStoreActions, $config: config, $subscribe: listener => { + devtools.emit(SUBSCRIBED, { store: storeObj, listener }); proxyObj.addListener(listener); }, $unsubscribe: listener => { + devtools.emit(UNSUBSCRIBED, storeObj); proxyObj.removeListener(listener); }, } as unknown as StoreObj; @@ -71,6 +94,14 @@ export function createStore, C extend // 让store.$queue[action]可以访问到action方法 // 要达到的效果:如果通过store.$queue[action1]调用的action1返回promise,会阻塞下一个store.$queue[action2] ($queue as any)[action] = (...payload) => { + devtools.emit(ACTION_QUEUED, { + store: storeObj, + action: { + action, + payload, + }, + fromQueue: true, + }); return new Promise(resolve => { if (!proxyObj.$pending) { proxyObj.$pending = true; @@ -99,6 +130,14 @@ export function createStore, C extend // 让store.$a[action]可以访问到action方法 ($a as any)[action] = function Wrapped(...payload) { + devtools.emit(ACTION, { + store: storeObj, + action: { + action, + payload, + }, + fromQueue: false, + }); return config.actions![action].bind(storeObj, proxyObj)(...payload); }; @@ -106,6 +145,14 @@ export function createStore, C extend Object.defineProperty(storeObj, action, { writable: false, value: (...payload) => { + devtools.emit(ACTION, { + store: storeObj, + action: { + action, + payload, + }, + fromQueue: false, + }); return config.actions![action].bind(storeObj, proxyObj)(...payload); }, }); @@ -132,13 +179,25 @@ export function createStore, C extend // 从Proxy对象获取值,会触发代理 return proxyObj[key]; }, + set: value => { + proxyObj[key] = value; + }, }); }); } - if (config.id) { - storeMap.set(config.id, storeObj); - } + storeMap.set(id, storeObj); + + devtools.emit(INITIALIZED, { + store: storeObj, + }); + + proxyObj.addListener(change => { + devtools.emit(STATE_CHANGE, { + store: storeObj, + change, + }); + }); return createGetStore(storeObj); } @@ -217,9 +276,10 @@ function registerDestroyFunction() { vNodeRef.current.observers = null; }; }, []); - } else if (processingVNode.tag === ClassComponent) { // 类组件 + } else if (processingVNode.tag === ClassComponent) { + // 类组件 if (!processingVNode.classComponentWillUnmount) { - processingVNode.classComponentWillUnmount = (vNode) => { + processingVNode.classComponentWillUnmount = vNode => { clearVNodeObservers(vNode); vNode.observers = null; }; @@ -240,6 +300,14 @@ export function useStore, C extends U return storeObj as StoreObj; } +export function getStore(id: string) { + return storeMap.get(id); +} + +export function getAllStores() { + return Object.fromEntries(storeMap); +} + export function clearStore(id: string): void { storeMap.delete(id); } diff --git a/libs/horizon/src/horizonx/types.d.ts b/libs/horizon/src/horizonx/types.d.ts index f1a88e56..eec4a826 100644 --- a/libs/horizon/src/horizonx/types.d.ts +++ b/libs/horizon/src/horizonx/types.d.ts @@ -16,13 +16,13 @@ export interface IObserver { useProp: (key: string | symbol) => void; - addListener: (listener: () => void) => void; + addListener: (listener: (mutation: any) => void) => void; - removeListener: (listener: () => void) => void; + removeListener: (listener: (mutation: any) => void) => void; - setProp: (key: string | symbol) => void; + setProp: (key: string | symbol, mutation: any) => void; - triggerChangeListeners: () => void; + triggerChangeListeners: (mutation: any) => void; triggerUpdate: (vNode: any) => void; @@ -42,13 +42,13 @@ export type StoreConfig, C extends Us }; export type UserActions = { - [K: string]: ActionFunction + [K: string]: ActionFunction; }; type ActionFunction = (this: StoreObj, state: S, ...args: any[]) => any; export type StoreActions> = { - [K in keyof A]: Action + [K in keyof A]: Action; }; type Action, S extends object> = ( @@ -61,8 +61,8 @@ export type StoreObj, C extends UserC $a: StoreActions; $c: UserComputedValues; $queue: QueuedStoreActions; - $subscribe: (listener: () => void) => void; - $unsubscribe: (listener: () => void) => void; + $subscribe: (listener: (mutation) => void) => void; + $unsubscribe: (listener: (mutation) => void) => void; } & { [K in keyof S]: S[K] } & { [K in keyof A]: Action } & { [K in keyof C]: ReturnType }; export type PlannedAction> = { @@ -74,11 +74,11 @@ export type PlannedAction> = { type RemoveFirstFromTuple = T['length'] extends 0 ? [] : ((...b: T) => void) extends (a, ...b: infer I) => void - ? I - : []; + ? I + : []; export type UserComputedValues = { - [K: string]: ComputedFunction + [K: string]: ComputedFunction; }; type ComputedFunction = (state: S) => any; @@ -89,9 +89,9 @@ export type AsyncAction, S extends object> = ( ) => Promise>; export type QueuedStoreActions> = { - [K in keyof A]: AsyncAction + [K in keyof A]: AsyncAction; }; export type ComputedValues> = { - [K in keyof C]: ReturnType + [K in keyof C]: ReturnType; }; diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx index 3f89de81..658c708d 100644 --- a/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/basicAccess.test.tsx @@ -51,6 +51,34 @@ describe('Basic store manipulation', () => { expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('1'); }); + it('Should use direct setters', () => { + function App() { + const logStore = useLogStore(); + + return ( +
+ +

{logStore.logs[0]}

+
+ ); + } + + Horizon.render(, container); + + Horizon.act(() => { + triggerClickEvent(container, BUTTON_ID); + }); + + expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('q'); + }); + it('Should use actions and update components', () => { function App() { const logStore = useLogStore(); @@ -89,7 +117,7 @@ describe('Basic store manipulation', () => { increment: state => { state.count++; }, - doublePlusOne: function(state) { + doublePlusOne: function (state) { state.count = state.count * 2; this.increment(); }, @@ -130,15 +158,15 @@ describe('Basic store manipulation', () => { count: 2, }, actions: { - doublePlusOne: function(state) { + doublePlusOne: function (state) { state.count = this.double + 1; }, }, - computed:{ - double: (state) => { - return state.count*2 - } - } + computed: { + double: state => { + return state.count * 2; + }, + }, }); function App() { @@ -166,5 +194,5 @@ describe('Basic store manipulation', () => { }); expect(document.getElementById(RESULT_ID)?.innerHTML).toBe('5'); - }) + }); }); diff --git a/scripts/__tests__/HorizonXText/StoreFunctionality/utils.test.js b/scripts/__tests__/HorizonXText/StoreFunctionality/utils.test.js new file mode 100644 index 00000000..7d1f786f --- /dev/null +++ b/scripts/__tests__/HorizonXText/StoreFunctionality/utils.test.js @@ -0,0 +1,80 @@ +import { resolveMutation } from '../../../../libs/horizon/src/horizonx/CommonUtils'; + +describe('Mutation resolve', () => { + it('should resolve mutation different types', () => { + const mutation = resolveMutation(null, 42); + + expect(mutation.mutation).toBe(true); + expect(mutation.from).toBe(null); + expect(mutation.to).toBe(42); + }); + + it('should resolve mutation same type types, different values', () => { + const mutation = resolveMutation(13, 42); + + expect(mutation.mutation).toBe(true); + expect(mutation.from).toBe(13); + expect(mutation.to).toBe(42); + }); + + it('should resolve mutation same type types, same values', () => { + const mutation = resolveMutation(42, 42); + + expect(mutation.mutation).toBe(false); + expect(Object.keys(mutation).length).toBe(1); + }); + + it('should resolve mutation same type types, same objects', () => { + const mutation = resolveMutation({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }); + + expect(mutation.mutation).toBe(false); + }); + + it('should resolve mutation same type types, same array', () => { + const mutation = resolveMutation([1, 2, 3, 4, 5], [1, 2, 3, 4, 5]); + + expect(mutation.mutation).toBe(false); + }); + + it('should resolve mutation same type types, longer array', () => { + const mutation = resolveMutation([1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]); + + expect(mutation.mutation).toBe(true); + expect(mutation.items[5].mutation).toBe(true); + expect(mutation.items[5].to).toBe(6); + }); + + it('should resolve mutation same type types, shorter array', () => { + const mutation = resolveMutation([1, 2, 3, 4, 5], [1, 2, 3, 4]); + + expect(mutation.mutation).toBe(true); + expect(mutation.items[4].mutation).toBe(true); + expect(mutation.items[4].from).toBe(5); + }); + + it('should resolve mutation same type types, changed array', () => { + const mutation = resolveMutation([1, 2, 3, 4, 5], [1, 2, 3, 4, 'a']); + + expect(mutation.mutation).toBe(true); + expect(mutation.items[4].mutation).toBe(true); + expect(mutation.items[4].from).toBe(5); + expect(mutation.items[4].to).toBe('a'); + }); + + it('should resolve mutation same type types, same object', () => { + const mutation = resolveMutation({ a: 1, b: 2 }, { a: 1, b: 2 }); + + console.log(mutation); + expect(mutation.mutation).toBe(false); + }); + + it('should resolve mutation same type types, changed object', () => { + const mutation = resolveMutation({ a: 1, b: 2, c: 3 }, { a: 1, c: 2 }); + + expect(mutation.mutation).toBe(true); + expect(mutation.attributes.a.mutation).toBe(false); + expect(mutation.attributes.b.mutation).toBe(true); + expect(mutation.attributes.b.from).toBe(2); + expect(mutation.attributes.c.to).toBe(2); + }); +}); diff --git a/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx index 3720e943..6baf7cd9 100644 --- a/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx +++ b/scripts/__tests__/HorizonXText/adapters/ReduxReactAdapter.test.tsx @@ -308,4 +308,4 @@ describe('Redux/React binding adapter', () => { expect(getE(BUTTON).innerHTML).toBe('1'); expect(getE(BUTTON2).innerHTML).toBe('true'); }); -}); +}); \ No newline at end of file diff --git a/scripts/__tests__/HorizonXText/adapters/connectTest.tsx b/scripts/__tests__/HorizonXText/adapters/connectTest.tsx new file mode 100644 index 00000000..a11b8249 --- /dev/null +++ b/scripts/__tests__/HorizonXText/adapters/connectTest.tsx @@ -0,0 +1,46 @@ +import { createElement } from '../../../../libs/horizon/src/external/JSXElement'; +import { createDomTextVNode } from '../../../../libs/horizon/src/renderer/vnode/VNodeCreator'; +import { createStore } from '../../../../libs/horizon/src/horizonx/adapters/redux'; +import { connect } from '../../../../libs/horizon/src/horizonx/adapters/reduxReact'; + +createStore((state: number = 0, action): number => { + if (action.type === 'add') return state + 1; + return 0; +}); + +type WrappedButtonProps = { add: () => void; count: number; text: string }; + +function Button(props: WrappedButtonProps) { + const { add, count, text } = props; + return createElement( + 'button', + { + onClick: add, + }, + createDomTextVNode(text), + createDomTextVNode(': '), + createDomTextVNode(count) + ); +} + +const connector = connect( + state => ({ count: state }), + dispatch => ({ + add: (): void => { + dispatch({ type: 'add' }); + }, + }), + (stateProps, dispatchProps, ownProps: { text: string }) => ({ + add: dispatchProps.add, + count: stateProps.count, + text: ownProps.text, + }) +); + +const ConnectedButton = connector(Button); + +function App() { + return createElement('div', {}, createElement(ConnectedButton, { text: 'click' })); +} + +export default App;