import type { DataProviderKey, DataProviderKeyHost, } from "../../utils/dataProviderKey"; import { ConnectedComponent, setSubscribable } from "./common"; import { extractDynamicDependencies, hasPath, resolveDynamicPath, } from "./dynamicPath"; import { bindDynamicWatchKeys, registerDynamicPropertyWatcher, } from "./dynamicPropertyWatch"; import { getPublisherFromPath } from "./publisherPath"; function bindImpl( path: string, options?: { reflect?: boolean } ): (target: unknown, propertyKey: string) => void { const reflect = options?.reflect ?? false; const dynamicDependencies = extractDynamicDependencies(path); const isDynamicPath = dynamicDependencies.length > 0; return function (target: unknown, propertyKey: string) { if (!target) return; setSubscribable(target); const stateKey = `__bind_state_${propertyKey}`; const publisherKey = `__bind_${propertyKey}_publisher__`; const isUpdatingFromPublisherKey = reflect ? `__bind_${propertyKey}_updating_from_publisher__` : null; if (reflect) { const existingDescriptor = Object.getOwnPropertyDescriptor( target as any, propertyKey ); const internalValueKey = `__bind_${propertyKey}_value__`; const reflectUpdateFlagKey = `__bind_${propertyKey}_updating_from_publisher__`; const initialValue = existingDescriptor && !existingDescriptor.get && !existingDescriptor.set ? existingDescriptor.value : undefined; Object.defineProperty(target as any, propertyKey, { get() { if (existingDescriptor?.get) { return existingDescriptor.get.call(this); } if ( !Object.prototype.hasOwnProperty.call(this, internalValueKey) && initialValue !== undefined ) { (this as any)[internalValueKey] = initialValue; } return (this as any)[internalValueKey]; }, set(newValue: unknown) { if (existingDescriptor?.set) { existingDescriptor.set.call(this, newValue); } else { (this as any)[internalValueKey] = newValue; } if ( !(this as any)[reflectUpdateFlagKey] && (this as any)[publisherKey] ) { (this as any)[publisherKey].set(newValue); } }, enumerable: existingDescriptor?.enumerable ?? true, configurable: existingDescriptor?.configurable ?? true, }); } (target as ConnectedComponent).__onConnected__((component) => { const state = (component as any)[stateKey] || ((component as any)[stateKey] = { cleanupWatchers: [] as Array<() => void>, unsubscribePublisher: null as null | (() => void), currentPath: null as string | null, }); if (state.unsubscribePublisher) { state.unsubscribePublisher(); state.unsubscribePublisher = null; } state.cleanupWatchers.forEach((cleanup: () => void) => cleanup()); state.cleanupWatchers = []; state.currentPath = null; const subscribeToPath = (resolvedPath: string | null) => { if (!resolvedPath) { if (state.unsubscribePublisher) { state.unsubscribePublisher(); state.unsubscribePublisher = null; } state.currentPath = null; (component as any)[publisherKey] = null; return; } if (resolvedPath === state.currentPath) { return; } if (state.unsubscribePublisher) { state.unsubscribePublisher(); state.unsubscribePublisher = null; } const publisher = getPublisherFromPath(resolvedPath); if (!publisher) { state.currentPath = null; (component as any)[publisherKey] = null; return; } const onAssign = (value: unknown) => { if (reflect && isUpdatingFromPublisherKey) { (component as any)[isUpdatingFromPublisherKey] = true; } component[propertyKey] = value; if (reflect && isUpdatingFromPublisherKey) { (component as any)[isUpdatingFromPublisherKey] = false; } }; publisher.onAssign(onAssign); state.unsubscribePublisher = () => { publisher.offAssign(onAssign); if ((component as any)[publisherKey] === publisher) { (component as any)[publisherKey] = null; } }; state.currentPath = resolvedPath; (component as any)[publisherKey] = publisher; }; const refreshSubscription = () => { if (isDynamicPath) { const resolution = resolveDynamicPath(component, path); if (!resolution.ready) { subscribeToPath(null); return; } subscribeToPath(resolution.path); return; } subscribeToPath(path); }; if (isDynamicPath) { for (const dependency of dynamicDependencies) { const unsubscribe = registerDynamicPropertyWatcher( bindDynamicWatchKeys.watcherStore, bindDynamicWatchKeys.hooked, component, dependency, () => refreshSubscription() ); state.cleanupWatchers.push(unsubscribe); } } refreshSubscription(); }); (target as ConnectedComponent).__onDisconnected__((component) => { const state = (component as any)[stateKey]; if (!state) return; if (state.unsubscribePublisher) { state.unsubscribePublisher(); state.unsubscribePublisher = null; } state.cleanupWatchers.forEach((cleanup: () => void) => cleanup()); state.cleanupWatchers = []; state.currentPath = null; (component as any)[publisherKey] = null; }); }; } /** * Bidirectional binding to a publisher path. Subscribes to changes and optionally reflects writes back. * Accepts either a string path (legacy) or DataProviderKey<T> for type-safe binding. * Supports dynamic paths: use placeholders like "users.${userIndex}" in the path or DataProviderKey. * * @example * // String path (legacy): * @bind("demoData.firstName") * @state() * firstName = ""; * * @example * // DataProviderKey with type validation: * const dataKey = new DataProviderKey("data"); * @bind(dataKey.count, { reflect: true }) * @state() * count: number = 0; */ export function bind(path: string, options?: { reflect?: boolean }): (target: unknown, propertyKey: string) => void; export function bind( key: DataProviderKey, options?: { reflect?: boolean }, ): ( target: DataProviderKeyHost & { [P in K]?: T | null | undefined }, propertyKey: K, ) => void; export function bind( pathOrKey: string | DataProviderKey, options?: { reflect?: boolean }, ): (target: unknown, propertyKey: string) => void { const path = hasPath(pathOrKey) ? pathOrKey.path : pathOrKey; return bindImpl(path, options); }