diff --git a/lab/functional-lit/stateful.ts b/lab/functional-lit/stateful.ts index cc7f2e270c04bd3f66e846ca535fdd92f46b971d..bc936fdd81ae328955a095478f8ea8214ca30c08 100644 --- a/lab/functional-lit/stateful.ts +++ b/lab/functional-lit/stateful.ts @@ -4,16 +4,21 @@ import { directive, PartType } from 'lit/directive.js'; import { type Observable } from 'rxjs'; import type { Renderable } from './renderable.js'; +interface Hook { + cleanup?: Function | void; + value?: any; + deps?: any; +} + // no memory leak due to weakmap -const componentHooks: WeakMap<StatefulDirective, { cleanup?: Function | void; value?: any; deps?: any }[]> = - new WeakMap(); +const componentHooks: Record<number, Hook[]> = {}; let currentComponent: StatefulDirective | null = null; let hookIndex = 0; let isRendering = false; -function setCurrentComponent(component: StatefulDirective, resumeFromIndex = 0) { +function setCurrentComponent(component: StatefulDirective, id: number) { currentComponent = component; - hookIndex = resumeFromIndex; + hookIndex = 0; } function getCurrentComponent() { @@ -28,11 +33,11 @@ function useState<T>(initialState: T): [T, (newState: T) => void] { const currentHookIndex = hookIndex++; // Initialize hooks array for component if not exists - if (!componentHooks.has(component)) { - componentHooks.set(component, []); + if (!componentHooks[component.id]) { + componentHooks[component.id] = []; } - const hooks = componentHooks.get(component)!; + const hooks = componentHooks[component.id]!; // If hook doesn't exist, initialize it if (currentHookIndex >= hooks.length) { @@ -47,12 +52,7 @@ function useState<T>(initialState: T): [T, (newState: T) => void] { 'Try to move the state update to a place where it is not called during a render like a lifecycle method or a event handler.', ); hooks[currentHookIndex] = { value: newState }; - // Trigger re-render (in Lit, this would be a property change) - if (component && typeof component.hookUpdated === 'function') { - (component as StatefulDirective).hookUpdated(); - } else { - throw new Error("We're not in a hookable scope"); - } + component.hookUpdated(); }; return [hooks[currentHookIndex]?.value, setState]; @@ -64,11 +64,11 @@ function useEffect(useEffectCallback: () => void | Teardown, deps?: any[]) { const currentHookIndex = hookIndex++; // Initialize hooks array for component if not exists - if (!componentHooks.has(component)) { - componentHooks.set(component, []); + if (!componentHooks[component.id]) { + componentHooks[component.id] = []; } - const hooks = componentHooks.get(component)!; + const hooks = componentHooks[component.id]!; // If no previous deps or deps have changed const hasDepsChanged = @@ -95,33 +95,42 @@ function useEffect(useEffectCallback: () => void | Teardown, deps?: any[]) { } } -function useMemo<T>(computeValue: () => [T, () => any] | [T] | void, deps?: any[]): T | undefined { +function _useMemo<T>(computeValue: (hook: Hook) => [T, () => any] | [T] | void, deps?: any[]): Hook { const currentComponent = getCurrentComponent(); const currentHookIndex = hookIndex++; // Ensure hooks array exists for current component - if (!componentHooks.has(currentComponent)) { - componentHooks.set(currentComponent, []); + if (!componentHooks[currentComponent.id]) { + componentHooks[currentComponent.id] = []; } - const hooks = componentHooks.get(currentComponent)!; + const hooks = componentHooks[currentComponent.id]; + if (!hooks) throw new Error('hooks of current component not found, should not happen'); // Check if hook already exists and deps haven't changed if (deps && hooks[currentHookIndex]?.deps?.every((dep: any, index: number) => dep === deps[index])) { - return hooks[currentHookIndex].value; + return hooks[currentHookIndex]; } - // Compute new value - const [newValue, cleanup] = computeValue() ?? []; + // cleanup old hook and create new + hooks[currentHookIndex]?.cleanup?.(); - // Store the computed value and dependencies - hooks[currentHookIndex] = { - value: newValue, + const hook: Hook = { deps, - cleanup, }; + const [newValue, cleanup] = computeValue(hook) ?? []; + hook.value = newValue; + hook.cleanup = cleanup; + + hooks[currentHookIndex] = hook; + + return hooks[currentHookIndex]; +} - return newValue; +function useMemo<T>(computeValue: () => [T, () => any] | [T], deps?: any[]): T; +function useMemo(computeValue: () => void, deps?: any[]): void; +function useMemo<T>(computeValue: () => [T, () => any] | [T] | void, deps?: any[]): T | undefined { + return _useMemo(computeValue, deps).value; } /** @@ -130,43 +139,31 @@ function useMemo<T>(computeValue: () => [T, () => any] | [T] | void, deps?: any[ * the function will be called in an infinite loop, which is definitely not what you want. */ function useObservable<T>(observable: () => Observable<T>, deps: any[]): T | undefined { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!deps) throw new Error('useObservable requires deps'); - /** - * we need the setValueInternal becuase - * - synchronous initial values - * - no endless loops (we cannot call setValue synchronously) - * - make sure value is always set synchronously (if synchronous initial value) even with other hooks inside the closure - * - * ``` - * stateful(() => { - const selectedLang = useObservable(() => globalLanguageSubject.asObservable(), []); - const design = useObservable(() => DesignSystem.currentTheme, []); - - console.log("selected Lang: ", selectedLang, design); // -> needs language and design set on every log - }) - * ``` - * */ - - let setValueInternal: (newState: T | undefined) => void; - let valueInternal: T | undefined; - - useMemo(() => { + return _useMemo(hook => { + const component = getCurrentComponent(); + let reqFrameReference: number; const subscription = observable().subscribe(value => { - valueInternal = value; - return requestAnimationFrame(() => setValueInternal(value)); + console.log('subscription fired', deps); + hook.value = value; + reqFrameReference = requestAnimationFrame(() => component.hookUpdated()); }); - return [valueInternal, () => subscription.unsubscribe()]; - }, deps); - - const [value, setValue] = useState<T | undefined>(valueInternal ?? undefined); - - valueInternal = value; - setValueInternal = setValue; - - return value; + return [ + hook.value, + () => { + cancelAnimationFrame(reqFrameReference); + subscription.unsubscribe(); + }, + ]; + }, deps).value; } +// * this represents the part, not the stateful component +// notably, if you render another stateful directive into the same part +// it will not call the constructor and `this` is object identical between both. +// Therefore we use an additional ID instead of relying on a WeakMap of `this` class StatefulDirective extends AsyncDirective { constructor(partInfo: PartInfo) { super(partInfo); @@ -177,7 +174,9 @@ class StatefulDirective extends AsyncDirective { renderFn!: Function; props?: any; - render(value: Function, props: any) { + id!: number; + render(value: Function, props: any, id: number) { + this.id = id; this.renderFn = value; this.props = props; @@ -195,7 +194,7 @@ class StatefulDirective extends AsyncDirective { // signal updates this.dispose = effect(() => { - setCurrentComponent(this); + setCurrentComponent(this, this.id); isRendering = true; result = this.renderFn(this.props); if (updateFromDirective) { @@ -211,12 +210,12 @@ class StatefulDirective extends AsyncDirective { return result; } - override update(_part: ChildPart, props: [Function, any]): unknown { + override update(_part: ChildPart, props: [Function, any, number]): unknown { // this exposes the current state of the properties to the parent element, therefore making it inspectable // @ts-expect-error this property doesn't exist and that's OK since it's just an outlet for debugging _part.parentNode.__STATE = props; - return this.render(props[0], props[1]); + return this.render(props[0], props[1], props[2]); } // hook updates @@ -227,7 +226,8 @@ class StatefulDirective extends AsyncDirective { override disconnected(): void { super.disconnected(); - componentHooks.get(this)?.forEach(hook => hook.cleanup?.()); + componentHooks[this.id]?.forEach(hook => hook.cleanup?.()); + delete componentHooks[this.id]; this.dispose?.(); } @@ -239,9 +239,13 @@ class StatefulDirective extends AsyncDirective { } const statefulDirective = directive(StatefulDirective); -export const stateful = - <P extends void | { [key: string]: any }>(fn: (props: P) => Renderable) => - (props: P) => - statefulDirective(fn, props); +// * theoretically, we could have an issue after Number.MAX_SAFE_INTEGER stateful directives. That's unrealistic though. +let idCounter = 0; +export const stateful = <P extends void | { [key: string]: any }>(fn: (props: P) => Renderable) => { + const id = idCounter++; + return (props: P) => { + return statefulDirective(fn, props, id); + }; +}; export { getCurrentComponent, setCurrentComponent, useEffect, useMemo, useObservable, useState };