watch-selector
Version:
Runs a function when a selector is added to dom
323 lines (279 loc) • 8.52 kB
text/typescript
// Enhanced state management for Watch v5
import type { TypedState, CleanupFunction } from '../types';
import { getCurrentContext } from './context';
// Global state storage per element
const elementStates = new WeakMap<HTMLElement, Record<string, any>>();
// Get state for current element
function getElementState(): Record<string, any> {
const context = getCurrentContext();
if (!context) {
throw new Error('State functions can only be called within a generator context');
}
const element = context.element;
if (!elementStates.has(element)) {
elementStates.set(element, {});
}
return elementStates.get(element)!;
}
// Basic state functions
export function getState<T = any>(key: string): T {
const state = getElementState();
return state[key] as T;
}
export function setState<T = any>(key: string, value: T): void {
const state = getElementState();
state[key] = value;
}
export function updateState<T = any>(key: string, updater: (current: T) => T): void {
const state = getElementState();
const current = state[key] as T;
state[key] = updater(current);
}
export function hasState(key: string): boolean {
const state = getElementState();
return key in state;
}
export function deleteState(key: string): void {
const state = getElementState();
delete state[key];
}
/**
* # createTypedState() - Create a Typed State Manager
*
* Create a typed state manager that provides a clean API for managing
* a specific state key with full type safety.
*
* ## Usage
*
* ```typescript
* watch('.counter', function* () {
* // Create typed state managers
* const count = createTypedState<number>('count');
* const items = createTypedState<string[]>('items');
*
* // Initialize if needed
* count.init(0);
* items.init([]);
*
* // Use the state
* yield text(`Count: ${count.get()}`);
*
* yield click(() => {
* count.update(n => n + 1);
* yield text(`Count: ${count.get()}`);
* });
* });
* ```
*
* ## Benefits
*
* - **Type Safety**: Full TypeScript support with generics
* - **Clean API**: Methods instead of separate function calls
* - **Encapsulation**: State key is encapsulated within the manager
* - **Consistency**: Uniform interface for all state operations
*
* @param key - State key to manage
* @param initialValue - Optional initial value
* @returns Typed state manager object
*/
export function createTypedState<T>(key: string, _initialValue?: T): TypedState<T> {
return {
get(): T {
const state = getElementState();
return state[key] as T;
},
set(value: T): void {
const state = getElementState();
state[key] = value;
},
update(fn: (current: T) => T): void {
const state = getElementState();
const current = state[key] as T;
state[key] = fn(current);
},
init(value: T): void {
const state = getElementState();
if (!(key in state)) {
state[key] = value;
}
}
};
}
/**
* # createState() - Create an Auto-Initialized State Manager
*
* Create a typed state manager that automatically initializes with a default value.
* This is a convenience function that combines `createTypedState` with automatic
* initialization.
*
* ## Usage
*
* ```typescript
* watch('.todo-list', function* () {
* // Auto-initialized state managers
* const todos = createState<Todo[]>('todos', []);
* const filter = createState<string>('filter', 'all');
* const editingId = createState<string | null>('editingId', null);
*
* // State is already initialized, ready to use
* const currentTodos = todos.get();
* const currentFilter = filter.get();
*
* yield renderTodos(currentTodos, currentFilter);
* });
* ```
*
* ## Comparison with createTypedState
*
* ```typescript
* // With createTypedState (manual initialization)
* const count = createTypedState<number>('count');
* count.init(0);
*
* // With createState (auto-initialization)
* const count = createState<number>('count', 0);
* ```
*
* @param key - State key to manage
* @param initialValue - Initial value for the state
* @returns Typed state manager object with initialized state
*/
export function createState<T>(key: string, initialValue: T): TypedState<T> {
const typedState = createTypedState<T>(key);
// Auto-initialize if not already set
if (!hasState(key)) {
typedState.set(initialValue);
}
return typedState;
}
// Computed state with dependency tracking
let computedCounter = 0;
export function createComputed<T>(
fn: () => T,
dependencies: string[] = []
): () => T {
const computedId = ++computedCounter;
const resultKey = `__computed_${computedId}`;
const depsKey = `__computed_deps_${computedId}`;
return function(): T {
const state = getElementState();
// Get current dependency values
const currentDeps = dependencies.map(dep => state[dep]);
const lastDeps = state[depsKey] as any[];
// Check if dependencies changed
const depsChanged = !lastDeps ||
currentDeps.length !== lastDeps.length ||
!currentDeps.every((dep, i) => dep === lastDeps[i]);
if (depsChanged) {
// Recompute
const result = fn();
state[resultKey] = result;
state[depsKey] = currentDeps;
return result;
}
// Return cached result
return state[resultKey] as T;
};
}
// Reactive state that triggers callbacks on change
const stateWatchers = new Map<string, Set<(newValue: any, oldValue: any) => void>>();
export function watchState<T>(
key: string,
callback: (newValue: T, oldValue: T) => void
): CleanupFunction {
if (!stateWatchers.has(key)) {
stateWatchers.set(key, new Set());
}
const watchers = stateWatchers.get(key)!;
watchers.add(callback);
return () => {
watchers.delete(callback);
if (watchers.size === 0) {
stateWatchers.delete(key);
}
};
}
// Enhanced setState that triggers watchers
export function setStateReactive<T>(key: string, value: T): void {
const oldValue = getState<T>(key);
setState(key, value);
// Trigger watchers
const watchers = stateWatchers.get(key);
if (watchers) {
watchers.forEach(callback => {
try {
callback(value, oldValue);
} catch (e) {
console.error('Error in state watcher:', e);
}
});
}
}
// Batch state updates
export function batchStateUpdates(updates: () => void): void {
// Disable watchers temporarily
const originalWatchers = new Map(stateWatchers);
stateWatchers.clear();
try {
updates();
} finally {
// Restore watchers and trigger them
for (const [key, watchers] of originalWatchers) {
stateWatchers.set(key, watchers);
}
}
}
// Persist state to localStorage
export function createPersistedState<T>(
key: string,
initialValue: T,
storageKey?: string
): TypedState<T> {
const actualStorageKey = storageKey || `watch_state_${key}`;
// Try to load from localStorage
let storedValue: T = initialValue;
try {
const stored = localStorage.getItem(actualStorageKey);
if (stored !== null) {
storedValue = JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load persisted state:', e);
}
const typedState = createState(key, storedValue);
// Override set to persist
const originalSet = typedState.set;
typedState.set = function(value: T): void {
originalSet.call(this, value);
try {
localStorage.setItem(actualStorageKey, JSON.stringify(value));
} catch (e) {
console.warn('Failed to persist state:', e);
}
};
return typedState;
}
// Clear all state for current element
export function clearAllState(): void {
const context = getCurrentContext();
if (!context) {
throw new Error('clearAllState can only be called within a generator context');
}
elementStates.delete(context.element);
}
// Debug helpers
export function debugState(): Record<string, any> {
return { ...getElementState() };
}
export function logState(prefix = 'State:'): void {
console.log(prefix, debugState());
}
/**
* Gets a read-only snapshot of an element's state.
* This is an internal helper for the WatchController's introspection feature.
* It does not require a generator context.
* @internal
*/
export function getElementStateSnapshot(element: HTMLElement): Readonly<Record<string, any>> {
return Object.freeze({ ...(elementStates.get(element) || {}) });
}