diff --git a/packages/agera/src/signal.spec.ts b/packages/agera/src/signal.spec.ts index 8d469465..7c4bd677 100644 --- a/packages/agera/src/signal.spec.ts +++ b/packages/agera/src/signal.spec.ts @@ -9,6 +9,7 @@ import { computed, signal, effect, + untracked, isSignal, createSignal, trigger @@ -77,6 +78,50 @@ describe('agera', () => { stop() }) + it('should not rerun an effect that writes its own dependency directly', () => { + const $tick = signal(0) + const $data = signal(0) + let runs = 0 + const stop = effect(() => { + $tick() + + const data = $data() + + runs++ + + $data(data + 1) + }) + + $tick(1) + + expect(runs).toBe(2) + expect($data()).toBe(2) + + stop() + }) + + it('should settle an effect that feeds itself instead of running away', () => { + const $tick = signal(0) + const $data = signal(0) + let runs = 0 + const stop = effect(() => { + $tick() + + const data = $data() + + runs++ + + untracked(() => $data(data + 1)) + }) + + $tick(1) + + expect(runs).toBe(2) + expect($data()).toBe(2) + + stop() + }) + describe('computed', () => { it('should correctly propagate changes through computed signals', () => { const src = signal(0) diff --git a/packages/kida/src/internals/child.spec.ts b/packages/kida/src/internals/child.spec.ts index 377213c9..c34f3fb8 100644 --- a/packages/kida/src/internals/child.spec.ts +++ b/packages/kida/src/internals/child.spec.ts @@ -6,9 +6,11 @@ import { } from 'vitest' import { signal, + mountable, effect } from 'agera' import { assignKey } from './utils.js' +import { onMount } from './lifecycle.js' import { child } from './child.js' describe('kida', () => { @@ -152,6 +154,81 @@ describe('kida', () => { off() }) + + it('should not rerun an effect that writes the child it reads', () => { + const $map = signal({ + a: 'clean' + }) + const $a = child($map, 'a', assignKey) + let runs = 0 + // an effect that normalises the child it reads settles: a writer is + // never woken by its own write + const stop = effect(() => { + const value = $a() + + runs++ + + if (runs > 10) { + throw new Error('runaway') + } + + if (value !== value.trim()) { + $a(value.trim()) + } + }) + + runs = 0 + + $map({ + a: ' dirty ' + }) + + expect(runs).toBe(1) + expect($map()).toEqual({ + a: 'dirty' + }) + + stop() + }) + + it('should not fire mount listeners from inside an effect that writes a child', () => { + const log: string[] = [] + const $map = signal({ + a: 1 + }) + const $a = child($map, 'a', assignKey) + const $mountable = mountable(signal('x')) + + onMount($mountable, () => { + log.push('mounted') + }) + + // reaches the mountable signal only once the write has landed, so + // the flush the write triggers is what makes it live + const stopReader = effect(() => { + if ($map().a === 2) { + $mountable() + } + }) + const stopWriter = effect(() => { + log.push('start') + + if ($a() === 1) { + $a(2) + } + + log.push('end') + }) + + expect(log).toEqual([ + 'start', + 'end', + 'mounted' + ]) + + stopWriter() + stopReader() + }) }) }) }) diff --git a/packages/kida/src/internals/child.ts b/packages/kida/src/internals/child.ts index 7fd5d9be..cfc6abdf 100644 --- a/packages/kida/src/internals/child.ts +++ b/packages/kida/src/internals/child.ts @@ -39,12 +39,18 @@ function childCompute(this: ChildNode) { function childOper(this: ChildNode, ...value: [NewValue]) { if (value.length) { - untracked(() => { - const parent = this.p() - const key = $get(this.k) + // Only the reads are untracked: the write itself stays in the caller's + // context, so an effect that writes a child it reads is exempted from + // its own write instead of being re-run by it + let parent!: AnyObject + let key!: PropertyKey - this.p(this.sv(parent, key, nextValue(parent[key], value[0]))) + untracked(() => { + parent = this.p() + key = $get(this.k) }) + + this.p(this.sv(parent, key, nextValue(parent[key], value[0]))) } else { return computedOper.call(this) } diff --git a/packages/nanoviews/.size-limit.json b/packages/nanoviews/.size-limit.json index b845a637..b72bfdca 100644 --- a/packages/nanoviews/.size-limit.json +++ b/packages/nanoviews/.size-limit.json @@ -23,6 +23,6 @@ "name": "Average usage (Brotli)", "path": "dist/index.js", "import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }", - "limit": "4 kB" + "limit": "4.05 kB" } ]