diff --git a/packages/devtools-host/src/App.tsx b/packages/devtools-host/src/App.tsx index b856492..b0101fc 100644 --- a/packages/devtools-host/src/App.tsx +++ b/packages/devtools-host/src/App.tsx @@ -3,14 +3,7 @@ import { twMerge } from "tailwind-merge" import { Flame } from "./icon/Flame" import { useAnchorPos } from "./hooks/useAnchorPos" import { useEffectDeep, useSpring } from "@kaioken-core/hooks" -import { - signal, - Transition, - useCallback, - useEffect, - useLayoutEffect, - useRef, -} from "kaioken" +import { signal, Transition, useEffect, useLayoutEffect, useRef } from "kaioken" import { useDevTools } from "./hooks/useDevtools" import { InspectComponent } from "./components/InspectComponent" import { PageInfo } from "./icon/PageInfo" diff --git a/packages/lib/src/dom.ts b/packages/lib/src/dom.ts index f273cdb..ad6e4b1 100644 --- a/packages/lib/src/dom.ts +++ b/packages/lib/src/dom.ts @@ -182,10 +182,14 @@ function updateDom(vNode: VNode) { (vNode.cleanups[key](), delete vNode.cleanups[key]) } if (Signal.isSignal(nextProps[key])) { - const unsub = nextProps[key].subscribe((v) => { + const cb: ((v: any) => void) & { vNodeFunc?: boolean } = (v: any) => { setProp(vNode, dom, key, v, unwrap(vNode.prev?.props[key])) emitGranularSignalChange(nextProps[key]) - }) + } + if (__DEV__) { + cb.vNodeFunc = true + } + const unsub = nextProps[key].subscribe(cb) ;(vNode.cleanups ??= {})[key] = unsub return setProp( vNode, @@ -214,10 +218,14 @@ function emitGranularSignalChange(signal: Signal) { } function subTextNode(vNode: VNode, textNode: Text, signal: Signal) { - const unsub = signal.subscribe((v) => { + const cb: ((v: any) => void) & { vNodeFunc?: boolean } = (v) => { textNode.nodeValue = v emitGranularSignalChange(signal) - }) + } + if (__DEV__) { + cb.vNodeFunc = true + } + const unsub = signal.subscribe(cb) ;(vNode.cleanups ??= {})["nodeValue"] = unsub } diff --git a/packages/lib/src/signal.ts b/packages/lib/src/signal.ts index 6e6209c..b82a79e 100644 --- a/packages/lib/src/signal.ts +++ b/packages/lib/src/signal.ts @@ -5,16 +5,19 @@ import { node } from "./globals.js" import { useHook } from "./hooks/utils.js" import { getVNodeAppContext, + isVNode, sideEffectsEnabled, traverseApply, } from "./utils.js" -let signalToTrackingMap: - | Map, Map, Function>> - | undefined +type SignalDependency = { + effectId: string + unsubs: Map, Function> +} +let computedToDependenciesMap: Map, SignalDependency> | undefined if (__DEV__) { - signalToTrackingMap = new Map() + computedToDependenciesMap = new Map() } export const signal = (initial: T, displayName?: string) => { @@ -56,8 +59,8 @@ export const computed = ( getter ) const subs = new Map, Function>() - appliedTrackedSignals(computed, subs) - signalToTrackingMap?.set(computed, subs) + const id = crypto.randomUUID() + appliedTrackedSignals(computed, subs, id) return computed } else { @@ -66,6 +69,7 @@ export const computed = ( { signal: undefined as any as Signal, subs: null as any as Map, Function>, + id: null as any as ReturnType, }, ({ hook, isInit }) => { if (isInit) { @@ -82,13 +86,13 @@ export const computed = ( }), } } + hook.id = crypto.randomUUID() hook.subs = new Map() hook.signal = Signal.makeReadonly( new Signal(null as T, displayName), getter ) - appliedTrackedSignals(hook.signal, hook.subs) - signalToTrackingMap?.set(hook.signal, hook.subs) + appliedTrackedSignals(hook.signal, hook.subs, hook.id) } return hook.signal @@ -97,6 +101,86 @@ export const computed = ( } } +class WatchEffect { + protected id: string + protected getter: () => (() => void) | void + protected subs: Map, Function> + protected cleanup?: CleanupInstance + protected isRunning?: boolean + protected [$HMR_ACCEPT]?: HMRAccept + + constructor(getter: () => (() => void) | void) { + this.id = crypto.randomUUID() + this.getter = getter + this.subs = new Map() + this.isRunning = false + + this[$HMR_ACCEPT] = { + provide: () => this, + inject: (prev) => { + if (prev.isRunning) return + this.stop() + }, + destroy: () => { + this.stop() + }, + } + } + + start() { + if (this.isRunning) { + return + } + + this.isRunning = true + queueMicrotask(() => { + if (this.isRunning) { + this.cleanup = appliedTrackedEffects(this.getter, this.subs, this.id) + } + }) + } + + stop() { + if (!this.isRunning) { + return + } + + effectQueue.delete(this.id) + this.subs.forEach((fn) => fn()) + this.subs.clear() + this.cleanup?.call?.() + this.isRunning = false + } +} + +export const watch = (getter: () => (() => void) | void) => { + if (!node.current) { + const watcher = new WatchEffect(getter) + watcher.start() + + return watcher + } else { + return useHook( + "useWatch", + { + watcher: null as any as WatchEffect, + }, + ({ hook, isInit }) => { + if (isInit) { + hook.watcher = new WatchEffect(getter) + hook.watcher.start() + + hook.cleanup = () => { + hook.watcher.stop() + } + } + + return hook.watcher + } + ) + } +} + export function unwrap(value: unknown) { return Signal.isSignal(value) ? value.peek() : value } @@ -109,7 +193,9 @@ export interface SignalLike { peek(): T subscribe(callback: (value: T) => void): () => void } -type SignalSubscriber = Kaioken.VNode | Function +export type SignalSubscriber = + | Kaioken.VNode + | (Function & { vNodeFunc?: boolean }) export class Signal { [$SIGNAL] = true @@ -128,14 +214,21 @@ export class Signal { }, inject: (prev) => { this.sneak(prev.value) - Signal.subscribers(prev).forEach((sub) => - Signal.subscribers(this).add(sub) - ) - if (signalToTrackingMap!.get(prev)) { - const subs = new Map, Function>() - appliedTrackedSignals(this, subs) - signalToTrackingMap!.set(this, subs) + + Signal.subscribers(prev).forEach((sub) => { + if (isVNode(sub) || sub.vNodeFunc) { + Signal.subscribers(this).add(sub) + } + }) + + if (computedToDependenciesMap!.get(prev)) { + const unsubs = + computedToDependenciesMap?.get(this)?.unsubs ?? + new Map, Function>() + const { effectId } = computedToDependenciesMap!.get(prev)! + appliedTrackedSignals(this, unsubs, effectId) } + window.__kaioken?.apps.forEach((app) => { traverseApply(app.rootNode!, (vNode) => { if (typeof vNode.type !== "function") return @@ -145,17 +238,22 @@ export class Signal { vNode.subs[idx] = this }) }) - this.notify() }, destroy: () => { - signalToTrackingMap!.forEach((subs) => { - const unsub = subs.get(this) + // cleanups and delete everything that is dependent on this signal + computedToDependenciesMap!.forEach(({ unsubs }) => { + const unsub = unsubs.get(this) if (unsub) { unsub() - subs.delete(this) + unsubs.delete(this) } }) - signalToTrackingMap!.delete(this) + + // cleans up all the signals own deps + computedToDependenciesMap!.get(this)?.unsubs.forEach((unsub) => { + unsub() + }) + computedToDependenciesMap!.delete(this) Signal.subscribers(this).clear() }, } satisfies HMRAccept> @@ -251,34 +349,131 @@ export class Signal { let isTracking = false let trackedSignals: Signal[] = [] +const effectQueue = new Map() const appliedTrackedSignals = ( computedSignal: ReadonlySignal, - subs: Map, Function> + subs: Map, Function>, + effectId: string ) => { + if (effectQueue.has(effectId)) { + effectQueue.delete(effectId) + } const getter = Signal.getComputedGetter(computedSignal) // NOTE: DO NOT call the signal notify method, UNTIL THE TRACKING PROCESS IS DONE isTracking = true computedSignal.sneak(getter()) isTracking = false - if (node.current && !sideEffectsEnabled()) return + + if (node.current && !sideEffectsEnabled()) { + trackedSignals = [] + return + } for (const [sig, unsub] of subs) { if (trackedSignals.includes(sig)) continue unsub() subs.delete(sig) } + const cb = () => { + if (!effectQueue.has(effectId)) { + queueMicrotask(() => { + if (effectQueue.has(effectId)) { + const func = effectQueue.get(effectId)! + func() + } + }) + } + + effectQueue.set(effectId, () => { + appliedTrackedSignals(computedSignal, subs, effectId) + computedSignal.notify() + }) + } trackedSignals.forEach((dependencySignal) => { if (subs.get(dependencySignal)) return - const unsub = dependencySignal.subscribe(() => { - appliedTrackedSignals(computedSignal, subs) + + const unsub = dependencySignal.subscribe(cb) + subs.set(dependencySignal, unsub) + }) + + if (computedToDependenciesMap) { + computedToDependenciesMap.set(computedSignal, { + effectId, + unsubs: subs, }) + } + + trackedSignals = [] +} + +type CleanupInstance = { + call?(): void +} + +const appliedTrackedEffects = ( + getter: () => (() => void) | void, + subs: Map, Function>, + effectId: string, + cleanupInstance?: CleanupInstance +) => { + const cleanup = cleanupInstance ?? ({} as CleanupInstance) + if (effectQueue.has(effectId)) { + effectQueue.delete(effectId) + } + isTracking = true + const func = getter() + if (func) cleanup.call = func + isTracking = false + + if (node.current && !sideEffectsEnabled()) { + trackedSignals = [] + + return cleanup + } + + for (const [sig, unsub] of subs) { + if (trackedSignals.includes(sig)) continue + unsub() + subs.delete(sig) + } + + const cb = () => { + if (!effectQueue.has(effectId)) { + queueMicrotask(() => { + if (effectQueue.has(effectId)) { + const func = effectQueue.get(effectId)! + func() + } + }) + } + + effectQueue.set(effectId, () => { + cleanup.call?.() + appliedTrackedEffects(getter, subs, effectId, cleanup) + }) + } + + trackedSignals.forEach((dependencySignal) => { + if (subs.get(dependencySignal)) return + const unsub = dependencySignal.subscribe(cb) subs.set(dependencySignal, unsub) }) trackedSignals = [] - computedSignal.notify() + return cleanup +} + +export const tick = () => { + const keys = [...effectQueue.keys()] + keys.forEach((id) => { + const func = effectQueue.get(id) + if (func) { + func() + effectQueue.delete(id) + } + }) } const onSignalValueObserved = (signal: Signal) => { diff --git a/packages/vite-plugin-kaioken/src/index.ts b/packages/vite-plugin-kaioken/src/index.ts index c005ea9..2fb38b7 100644 --- a/packages/vite-plugin-kaioken/src/index.ts +++ b/packages/vite-plugin-kaioken/src/index.ts @@ -203,12 +203,15 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { createAliasBuilder("kaioken", "signal") const { addAliases: addComputedAliases, nodeContainsAliasCall: isComputed } = createAliasBuilder("kaioken", "computed") + const { addAliases: addWatchAliases, nodeContainsAliasCall: isWatch } = + createAliasBuilder("kaioken", "watch") for (const node of nodes) { if (node.type === "ImportDeclaration") { addCreateStoreAliases(node) addSignalAliases(node) addComputedAliases(node) + addWatchAliases(node) continue } @@ -230,6 +233,10 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { addHotVarNames(node, hotVarNames) continue } + + if (findNode(node, isWatch)) { + addHotVarNames(node, hotVarNames) + } } return Array.from(hotVarNames) } diff --git a/sandbox/csr/src/components/SignalsExample.tsx b/sandbox/csr/src/components/SignalsExample.tsx index f5e7de2..b08b8d4 100644 --- a/sandbox/csr/src/components/SignalsExample.tsx +++ b/sandbox/csr/src/components/SignalsExample.tsx @@ -1,4 +1,4 @@ -import { signal, computed, Route, Router, Link } from "kaioken" +import { signal, computed, Route, Router, Link, watch } from "kaioken" const count = signal(0, "count") const isTracking = signal(false, "isTracking") @@ -6,9 +6,14 @@ const double = computed(() => { if (isTracking.value) { return count.value * 2 } + return 0 }, "double") +const watcher = watch(() => { + console.log("double 123", double.value) +}) + export function SignalsExample() { return (
@@ -30,20 +35,19 @@ export function SignalsExample() { const GlobalComputedExample = () => { console.log("GlobalComputedExample") - const divId = signal("test") - const onInc = () => { + const refTest = signal(null) + const onInc = async () => { count.value += 1 - divId.value += "|test" } const onSwitch = () => { isTracking.value = !isTracking.value - console.log("calling on switch metohd") + console.log("calling on switch method") } return ( -
-

Count: {count}

+
+

count: {count}

Double: {double}

is tracking: {`${isTracking}`}

@@ -53,6 +57,12 @@ const GlobalComputedExample = () => { + +
) } @@ -62,7 +72,7 @@ const LocalComputedExample = () => { const localIsTracking = signal(false, "local is tracking") const localDouble = computed(() => { if (localIsTracking.value) { - return localCount.value * 2 + return localCount.value * 100 } return 0