diff --git a/packages/debugger/src/createTrackedSignal.ts b/packages/debugger/src/createTrackedSignal.ts new file mode 100644 index 00000000..cc6299fe --- /dev/null +++ b/packages/debugger/src/createTrackedSignal.ts @@ -0,0 +1,90 @@ +import { + createSignal, + type Accessor, + type Setter, + type Signal, + type SignalOptions +} from 'solid-js' + +export interface TrackedSignalOptions extends SignalOptions { + name?: string + maxTransitionsPerTick?: number + onStorm?: (details: StormDetails) => void + throwOnStorm?: boolean +} + +export interface StormDetails { + signalName: string + transitionsInTick: number + lastValue: T + nextValue: T + stack?: string +} + +const isProduction = typeof process !== 'undefined' && process.env?.NODE_ENV === 'production' + +export function createTrackedSignal( + initialValue: T, + options: TrackedSignalOptions = {} +): Signal { + const [read, rawSet] = createSignal(initialValue, options) + + if (isProduction) { + return [read, rawSet] + } + + const signalName = options.name ?? `signal_${Math.random().toString(36).slice(2, 8)}` + const maxTransitions = options.maxTransitionsPerTick ?? 50 + + let transitionCount = 0 + let resetScheduled = false + + const scheduleReset = (): void => { + if (resetScheduled) return + resetScheduled = true + + queueMicrotask(() => { + transitionCount = 0 + resetScheduled = false + }) + } + + const trackedSet: Setter = ((valueOrUpdater: unknown) => { + transitionCount += 1 + scheduleReset() + + const previousValue = read() + let nextValue: T + + if (typeof valueOrUpdater === 'function') { + nextValue = (valueOrUpdater as (prev: T) => T)(previousValue) + } else { + nextValue = valueOrUpdater as T + } + + if (transitionCount > maxTransitions) { + const details: StormDetails = { + signalName, + transitionsInTick: transitionCount, + lastValue: previousValue, + nextValue, + stack: new Error().stack + } + + if (options.onStorm) { + options.onStorm(details) + } else { + const message = `[Solid DevTools] Reactive storm detected on '${signalName}'. Exceeded ${maxTransitions} transitions in a single microtask tick.` + if (options.throwOnStorm) { + throw new Error(message) + } else { + console.warn(message, details) + } + } + } + + return rawSet(nextValue as Parameters[0]) + }) as Setter + + return [read, trackedSet] +} diff --git a/packages/debugger/src/index.ts b/packages/debugger/src/index.ts index f041eedc..17ff94ca 100644 --- a/packages/debugger/src/index.ts +++ b/packages/debugger/src/index.ts @@ -21,3 +21,8 @@ export { onOwnerCleanup, onParentCleanup, } from './main/utils.ts' +export { + createTrackedSignal, + type TrackedSignalOptions, + type StormDetails, +} from './createTrackedSignal.ts' diff --git a/packages/debugger/test/tracked-signal.test.ts b/packages/debugger/test/tracked-signal.test.ts new file mode 100644 index 00000000..f55e4e7d --- /dev/null +++ b/packages/debugger/test/tracked-signal.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { createEffect, createRoot } from 'solid-js' +import { createTrackedSignal, type StormDetails } from '../src/createTrackedSignal' + +describe('createTrackedSignal', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('behaves identically to standard signal for normal updates', () => { + createRoot(dispose => { + const [count, setCount] = createTrackedSignal(0, { name: 'testCount' }) + + expect(count()).toBe(0) + setCount(5) + expect(count()).toBe(5) + setCount(prev => prev + 1) + expect(count()).toBe(6) + + expect(warnSpy).not.toHaveBeenCalled() + dispose() + }) + }) + + it('triggers storm alert when transition threshold is exceeded in a single tick', () => { + createRoot(dispose => { + const onStorm = vi.fn() + const [, setCount] = createTrackedSignal(0, { + name: 'stormLoopSignal', + maxTransitionsPerTick: 10, + onStorm + }) + + for (let i = 1; i <= 15; i++) { + setCount(i) + } + + expect(onStorm).toHaveBeenCalled() + const details: StormDetails = onStorm.mock.calls[0][0] + expect(details.signalName).toBe('stormLoopSignal') + expect(details.transitionsInTick).toBe(11) + expect(details.nextValue).toBe(11) + + dispose() + }) + }) + + it('detects circular updates inside reactive effects', async () => { + const onStorm = vi.fn() + + createRoot(dispose => { + const [a, setA] = createTrackedSignal(0, { + name: 'ping', + maxTransitionsPerTick: 20, + onStorm + }) + const [b, setB] = createTrackedSignal(0, { + name: 'pong', + maxTransitionsPerTick: 20 + }) + + createEffect(() => { + const valA = a() + if (valA < 30) { + setB(valA + 1) + } + }) + + createEffect(() => { + const valB = b() + if (valB < 30) { + setA(valB + 1) + } + }) + + dispose() + }) + + await vi.runAllTicksAsync() + expect(onStorm).toHaveBeenCalled() + }) + + it('resets transition counters after microtask boundary', async () => { + const onStorm = vi.fn() + + await createRoot(async dispose => { + const [, setCount] = createTrackedSignal(0, { + maxTransitionsPerTick: 5, + onStorm + }) + + for (let i = 1; i <= 4; i++) setCount(i) + expect(onStorm).not.toHaveBeenCalled() + + await vi.runAllTicksAsync() + + for (let i = 5; i <= 8; i++) setCount(i) + expect(onStorm).not.toHaveBeenCalled() + + dispose() + }) + }) +})