/** * S StoreType; // Append root to the first value type V you are given * G StateRefStore; // Add "value" to the ending point with "root" unattached. * T StateRefStore>; // Attach a "value" to the ending point in the state where the root exists. */ export type Renew = ( store: G, isFirst: boolean ) => boolean | AbortSignal | void; export type Run = null | ((isFirst?: boolean) => boolean | AbortSignal | void); export type StoreType = { root: V }; export type WithRoot = { root: unknown } & { [key: string | symbol]: unknown }; export type StateRefStore = S extends object ? { [K in keyof S]: StateRefStore; } & { value: S; } : { value: S }; // [K in keyof S]: StateRefStore & { value: S[K] }; // value: { [K in keyof S]: StateRefStore } & { value: S }; export type Watch = ( renew?: Renew>, userOption?: { cache?: boolean; editable?: boolean } ) => StateRefStore; export type RunInfo = { value: A; getNextValue: () => A; key: string; primitiveSetter?: (newValue: A) => void; }; export type RenderListSub = Map>; export type StoreRenderList = Map>; export type Copyable = { [K in keyof T]: Copyable; } & { writeCopy: (v: V) => Root; }; export type StateRefsTuple[]> = { -readonly [K in keyof W]: W[K] extends Watch ? StateRefStore : never; }; export type CombinedValue[]> = { [K in keyof W]: W[K] extends Watch ? T : never; }; export type ManualSyncStore = { watch: Watch; updateRef: StateRefStore; sync: () => void; }; /** * FILE: lens.ts */ /** * Type helper to extract nested property type */ type PropType = K extends keyof T ? T[K] : K extends `${number}` ? T extends readonly (infer U)[] ? U : any : any; /** * The stateRef relies on data immutability to determine changes. * The lens pattern is used as a core part of the stateRef because, * it makes it easy to locate and safely change data. */ export function lens( sceneList: (string | number | symbol)[] = [] ) { return new Lens(sceneList); } export class Lens { private sceneList: (string | number | symbol)[]; constructor(sceneList: (string | number | symbol)[]) { this.sceneList = sceneList; } chain(prop: K): Lens>; chain( prop: K ): Lens>; chain(prop: string | number | symbol): Lens { return new Lens([...this.sceneList, prop]); } get(targetObject: Root): Focus { return this.sceneList.reduce( (currentObject: any, prop) => currentObject?.[prop], targetObject ) as Focus; } set(value: Focus) { return (targetObject: Root): Root => this.copyOnWrite(targetObject, value); } private copyOnWrite(targetObject: Root, value: Focus): Root { const copiedObject = this.shallowCopy(targetObject); this.sceneList.reduce((currentObject: any, prop, index) => { return (currentObject[prop] = index === this.sceneList.length - 1 ? value : this.shallowCopy(currentObject[prop])); }, copiedObject); return copiedObject; } private shallowCopy(x: T): T { if (Array.isArray(x)) { return [...x] as T; } else if (x && typeof x === 'object') { return { ...x } as T; } return x; } } /** * FILE: helper/index.ts */ export const DEFAULT_WATCH_OPTION = { cache: true, editable: true }; export const DEFAULT_CREATE_OPTION = { autoSync: true }; /** * Map to assign a unique ID to each Symbol. * - WeakMap can't use symbol as key in TS, so we use Map. * - This ensures every Symbol in a path is uniquely identified. */ const symbolIdMap = new Map(); let symbolCounter = 0; /** * Create information about the proxy that can be viewed in the developer console. */ export function makeDisplayProxyValue( depthList: (string | number | symbol)[], value: unknown ) { return { _navi: keyFromDepthList(depthList), _type: getType(value), _value: '..', }; } function getType(value: unknown) { if (value === null) { return 'null'; } else if (Array.isArray(value)) { return 'array'; } else if (typeof value === 'object') { return 'object'; } else { return typeof value; } } /** * Escape special characters in strings to make keys bulletproof. * - Escapes ':', '|', and '\' to prevent collisions in the final key string. */ function escapeString(str: string): string { return str.replace(/[:|\\]/g, '\\$&'); } /** * Convert a path array into a unique, collision-resistant string key. * - Supports strings, numbers, and Symbols. * - Prefixes each element with a type marker: * - 's:' for string * - 'n:' for number * - 'y:' for Symbol (unique ID via Map) * - Escapes special characters in strings. * - Joins all elements with '|' to form a flat key string. * * Example: * ["user", Symbol("id"), 42] -> "s:user|y:1|n:42" */ export function keyFromDepthList(path: (string | number | symbol)[]): string { return path .map(k => { if (typeof k === 'string') return 's:' + escapeString(k); if (typeof k === 'number') return 'n:' + k; if (typeof k === 'symbol') { if (!symbolIdMap.has(k)) symbolIdMap.set(k, ++symbolCounter); return 'y:' + symbolIdMap.get(k); } return '?'; }) .join('|'); // safe separator } /** * We provide a convenience utility to make it easy to create data when "copyOnWrite" is required. */ export function copyable( origObj: T, lensInit?: Lens ): Copyable { let lensIns = lensInit || lens(); return new Proxy(origObj as unknown as Copyable, { get(target: Copyable, prop: keyof T | 'writeCopy') { if (prop === 'writeCopy') { return (value: V) => { return lensIns.set(value)(target as unknown as T); }; } return copyable(origObj, lensIns.chain(prop as keyof T)); }, set() { throw new Error( 'Property modification is not supported on a copyable object. Use "writeCopy" for state updates.' ); }, }); } /** * Provides a convenience utility to make deep copying easier in special cases. */ export function cloneDeep(value: T): T { if (value == null) { return value; } if (typeof value !== 'object') { return value; } const isArray = Array.isArray(value); const Ctor = isArray ? Array : Object; const result = new Ctor() as T; // 새로운 객체 또는 배열 생성 for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { result[key] = cloneDeep(value[key]); // 재귀적으로 깊은 복사 } } return result; } /** * Combines multiple state watchers to produce a derived (computed) value, * and invokes the provided callback whenever the computed value changes. */ export function createComputed[], R>( watches: W, callback: (a: StateRefsTuple) => R ) { let result: R; const proxy: { value: R } = { get value(): R { return result; }, set value(_setter) { console.warn('Can not setting'); }, }; return ( computedCallback?: (proxy: { value: R }, isFirst: boolean) => void ) => { const refs = watches.map(watch => watch(() => false)) as StateRefsTuple; watches.forEach((watch, index) => { watch((ref, init) => { (refs as any)[index] = ref; result = callback(refs); if (!init && computedCallback) { computedCallback(proxy, false); } }); }); if (computedCallback) { computedCallback(proxy, true); } return proxy; }; } /** * Observes multiple Watch instances together and triggers a callback * whenever any of them changes. The callback receives the current * StateRefStore values of all watches and a boolean indicating * whether this is the first invocation. */ export function combineWatch[]>( watches: [...W] ): Watch> { type R = CombinedValue; type RefsTuple = StateRefsTuple; return ( callback?: Renew>, userOption?: { cache?: boolean } ): StateRefStore => { const refs: RefsTuple = watches.map(w => w(() => {}, userOption) ) as RefsTuple; const combinedStore: StateRefStore = new Proxy({} as StateRefStore, { get(_, prop) { if (prop === 'value') { console.warn( `You cannot directly access the nested 'value' of a store created by combineWatch.` ); return refs.map(s => s.value) as R; } return Reflect.get(refs, prop); }, set(_, prop) { if (prop === 'value') { console.warn( "You cannot directly assign to '.value' of a store created by combineWatch. Use an individual store instead (e.g., store[0].value)." ); } else { console.warn( `You cannot directly assign to the property '${String( prop )}' of a store created by combineWatch.` ); } return false; }, }); watches.forEach((watch, i) => { const index = i as keyof RefsTuple; watch((ref, isFirst) => { refs[index] = ref as RefsTuple[number]; if (!isFirst && callback) { callback(combinedStore, false); } }, userOption); }); if (callback) { callback(combinedStore, true); } return combinedStore; }; } /** * FILE: collector.ts */ /** * The subscription to store starts the moment the user of stateRef fetches the reference as a ".value". * This code captures the moment of fetching to ".value" and collects the subscription. */ export function collector( value: unknown, getNextValue: () => unknown, newDepthList: (string | number | symbol)[], run: Run, storeRenderList: StoreRenderList ) { if (run) { const key = keyFromDepthList(newDepthList); const runInfo: RunInfo = { value, getNextValue, key, }; if (storeRenderList.has(run)) { const subList = storeRenderList.get(run)!; if (!subList.has(key)) subList.set(key, runInfo); } else { const subList = new Map>(); subList.set(key, runInfo); storeRenderList.set(run, subList); } } } /** * FILE: runner.ts */ /** * Based on the information gathered by the "collector", * this code identifies and executes a callback function for store changes. */ export function runner(storeRenderList: StoreRenderList) { const runableRenewList: Set = new Set(); storeRenderList.forEach((defs, run) => { defs.forEach((item, key) => { const { value, getNextValue } = item; try { const nextValue = getNextValue(); if (value !== nextValue) { runableRenewList.add(run); item.value = nextValue; } } catch (error) { /** * The subscribe function is subscribing to a value that has already been removed, * so when run is executed, either the user has handled the exception with optional chaining or similar, * or an error will occur. Therefore, it's fine not to throw an error at this point. */ console.warn( `Value for key ${key} has been removed, skipping update:`, error ); } }); }); runableRenewList.forEach(run => { if (run && run() === false) { storeRenderList.delete(run); } }); runableRenewList.clear(); } export function firstRunner( run: Run, storeRenderList: StoreRenderList, cacheMap: WeakMap>, StateRefStore>, renew: Renew> ) { const renewResult = run!(true); if (renewResult instanceof AbortSignal) { renewResult.addEventListener('abort', () => { cacheMap.delete(renew); storeRenderList.delete(run); }); } } /** * FILE: proxy/index.ts */ /** * Use proxies to secure values and match them to lens. */ export function makeProxy( value: unknown, storeRenderList: StoreRenderList, run: Run, autoSync: boolean, editable: boolean, rootValue: S, lensValue: Lens = lens(), depth: number = 0, depthList: (string | number | symbol)[] = [] ): T { const result = new Proxy( makeDisplayProxyValue(depthList, value) as unknown as T, { /** * 1. When accessing ".value" from a proxy * 1-1. Have the "collector" collect the subscription callbacks and the * 1-2. Subtracts a value from "lens" and returns it * 2. When accessing iterables from a proxy. * 3. When accessing child object types from a proxy */ get(_: T, prop: keyof T & (string | symbol)) { const newDepthList = [...depthList, prop]; /** * When accessing ".value" from a proxy */ if (prop === 'value') { const currentValue = lensValue.get(rootValue); collector( currentValue, () => lensValue.get(rootValue), [...depthList], run, storeRenderList ); return currentValue; } /** * When accessing "iterator" from a proxy */ if (prop === Symbol.iterator) { return function* () { const iterableValue = lensValue.get(rootValue); if ( !iterableValue || typeof (iterableValue as any)[Symbol.iterator] !== 'function' ) { return; } for (const [index, itemValue] of ( iterableValue as unknown as any[] ).entries()) { yield makeProxy( itemValue, storeRenderList, run, autoSync, editable, rootValue, lensValue.chain(index), depth + 1, [...depthList, String(index)] ); } }; } /** * When accessing child object types from a proxy */ const lens = lensValue.chain(prop); const propertyValue = lens.get(rootValue); return makeProxy( propertyValue, storeRenderList, run, autoSync, editable, rootValue, lens, depth + 1, newDepthList ); }, /** * When assigning a value to ".value", copyOnWrite is performed. * Error if you try to assign a value to something that isn't a ".value". * ex) ref.a.b = 'newValue'; // Error * ex) ref.a.b.value = 'newValue'; // Success */ set(_, prop: string | symbol, value) { if (prop !== 'value') { throw new Error('Can only be assigned to a "value".'); } else if (prop === 'value' && !editable) { throw new Error( 'With the current settings, direct modification is not allowed.' ); } else if (prop === 'value' && value !== lensValue.get(rootValue)) { const newTree = lensValue.set(value)(rootValue); rootValue.root = newTree.root; /** * Run dependency subscription callbacks. */ if (autoSync) { runner(storeRenderList); } } return true; }, } ); return result; } /** * FILE: core/ref.ts */ /** * Make the value a stateRef. */ export function makeReference({ renew, rootValue, storeRenderList, cacheMap, autoSync, editable, }: { renew: Renew>; rootValue: StoreType; storeRenderList: StoreRenderList; cacheMap: WeakMap>, StateRefStore>; autoSync: boolean; editable: boolean; }) { const ref: { value: null | StateRefStore> } = { value: null, }; const run = (isFirst?: boolean) => renew(ref.value!.root, isFirst ?? false); ref.value = makeProxy, StateRefStore>>( rootValue, storeRenderList, run, autoSync, editable, rootValue ); /** * It is initialized only once per subscription and collects the 'abort' signal. */ firstRunner(run, storeRenderList, cacheMap, renew); cacheMap.set(renew, ref.value!.root); return ref.value!.root; } /** * FILE: core/index.ts */ /** * createStore - The argument is the initial value of the state * * Can work with primitive types, or you can work with object types. * The return value is the "watch" function. * * example> * const watch = createStore(7) * // const watch = createStore<{name: string; age: number;}>({ name: 'brown', age: 38 }) * const stateRef = watch(stateRef => { * console.log(stateRef.value)); * }); */ export function createStore(orignalValue: V) { const { watch } = create(orignalValue, { autoSync: true }); return watch; } export function createStoreManualSync(orignalValue: V): ManualSyncStore { return create(orignalValue, { autoSync: false }); } function create(orignalValue: V, userCreateOption?: { autoSync?: boolean }) { const storeRenderList: StoreRenderList = new Map(); const cacheMap = new WeakMap>, StateRefStore>(); const { autoSync } = Object.assign( {}, DEFAULT_CREATE_OPTION, userCreateOption || {} ); const rootValue: StoreType = { root: orignalValue }; const watch = ( renew: Renew> = () => {}, userOption?: { cache?: boolean; editable?: boolean } ): StateRefStore => { const watchOption = Object.assign( {}, DEFAULT_WATCH_OPTION, userOption || { editable: autoSync } ); const { cache, editable } = watchOption; /** * Caching */ if (cache && renew && cacheMap.has(renew)) { return cacheMap.get(renew)!; } /** * Make the value a stateRef. */ return makeReference({ renew, rootValue, storeRenderList, cacheMap, autoSync, editable, }); }; return { watch, updateRef: watch(() => {}, { editable: true }), sync: () => { runner(storeRenderList); }, }; }