diff --git a/packages/agera/.size-limit.json b/packages/agera/.size-limit.json index f8a0eda8..ff5e19b1 100644 --- a/packages/agera/.size-limit.json +++ b/packages/agera/.size-limit.json @@ -3,24 +3,24 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "2.47 kB" + "limit": "2.63 kB" }, { "name": "Signal", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.59 kB" + "limit": "1.61 kB" }, { "name": "Minimal set", "path": "dist/index.js", "import": "{ signal, computed, effect }", - "limit": "1.75 kB" + "limit": "1.8 kB" }, { "name": "Popular set", "path": "dist/index.js", "import": "{ signal, computed, effect, mountable, onMounted }", - "limit": "1.88 kB" + "limit": "1.92 kB" } ] diff --git a/packages/agera/README.md b/packages/agera/README.md index 2f2b9fae..2721c00c 100644 --- a/packages/agera/README.md +++ b/packages/agera/README.md @@ -147,33 +147,6 @@ const stop = effectScope(() => { stop() // stop all effects ``` -### `deferScope` - -Also there is a possibility to create a defer scope. - -```ts -import { signal, deferScope, effectScope, effect } from 'agera' - -const $a = signal(0) -const $b = signal(0) -// All scopes will run immediately, but effects run is delayed -const start = deferScope(() => { - effect(() => { - console.log('A:', $a()) - }) - - effectScope(() => { - effect(() => { - console.log('B:', $b()) - }) - }) -}, true) // marks scope as lazy -// start all effects -const stop = start() - -stop() // stop all effects -``` - ### `subscribe` `subscribe` subscribes to accessor changes. Callback will be called immediately with the current value and on every subsequent change. Will trigger accessor mount if applicable. diff --git a/packages/agera/src/effect.spec.ts b/packages/agera/src/effect.spec.ts index 091372b6..64bd1bb1 100644 --- a/packages/agera/src/effect.spec.ts +++ b/packages/agera/src/effect.spec.ts @@ -5,6 +5,7 @@ import { expect } from 'vitest' import { + type DeferredScope, computed, effect, effectScope, @@ -15,6 +16,9 @@ import { mountable, onMounted, deferScope, + boundDeferScope, + startScope, + stopScope, observe } from './index.js' @@ -1455,8 +1459,7 @@ describe('agera', () => { subs() } }) - let start = confition(tab) - let stop: () => void + let scope = confition(tab) effect((warmup) => { const tab = $tab() @@ -1465,16 +1468,16 @@ describe('agera', () => { if (!warmup) { log.push(`body tab effect render ${tab}`) - start = confition(tab) - stop = start() + scope = confition(tab) } else { log.push(`body tab initial effect render ${tab}`) - stop = start() } + startScope(scope) + return () => { log.push('body tab destroy') - stop() + stopScope(scope) } }) } @@ -1493,7 +1496,7 @@ describe('agera', () => { footer() } - const run = deferScope(page) + const scope = deferScope(page) expect(log).toEqual([ 'page', @@ -1506,7 +1509,7 @@ describe('agera', () => { log.length = 0 - const stop = run() + startScope(scope) expect(log).toEqual([ 'page mount', @@ -1570,7 +1573,7 @@ describe('agera', () => { ]) log.length = 0 - stop() + stopScope(scope) expect(log).toEqual([ 'page destroy', @@ -1582,13 +1585,428 @@ describe('agera', () => { ]) }) + it('should run nested scopes before own effects', () => { + const log: string[] = [] + const scope = deferScope(() => { + effect(() => { + log.push('own-1') + }) + effectScope(() => { + effect(() => { + log.push('nested-a') + }) + effectScope(() => { + effect(() => { + log.push('nested-a-deep') + }) + }) + effect(() => { + log.push('nested-b') + }) + }) + effect(() => { + log.push('own-2') + }) + effectScope(() => { + effect(() => { + log.push('nested-c') + }) + }) + }) + + log.length = 0 + + startScope(scope) + + expect(log).toEqual([ + 'nested-a-deep', + 'nested-a', + 'nested-b', + 'nested-c', + 'own-1', + 'own-2' + ]) + + stopScope(scope) + }) + + it('should start linked deferred scopes with parent before own effects', () => { + const log: string[] = [] + const scope = deferScope(() => { + effect(() => { + log.push('own-1') + }) + + boundDeferScope()(() => { + effect(() => { + log.push('content') + }) + }) + + effect(() => { + log.push('own-2') + }) + }) + + log.length = 0 + + startScope(scope) + + expect(log).toEqual([ + 'content', + 'own-1', + 'own-2' + ]) + + stopScope(scope) + }) + + it('should stop linked deferred scope with parent scope', () => { + const log: string[] = [] + const scope = deferScope(() => { + boundDeferScope()(() => { + effect(() => { + log.push('content') + + return () => log.push('content destroy') + }) + }) + }) + + startScope(scope) + + expect(log).toEqual(['content']) + + stopScope(scope) + + expect(log).toEqual(['content', 'content destroy']) + }) + + it('should dispose linked scope stopped before parent start', () => { + const log: string[] = [] + let content: DeferredScope + const scope = deferScope(() => { + content = boundDeferScope()(() => { + effect(() => { + log.push('content') + }) + }) + }) + + // stop before the parent start must discard the scope, not start it + stopScope(content!) + + startScope(scope) + + expect(log).toEqual([]) + + stopScope(scope) + + expect(log).toEqual([]) + }) + + it('should stop linked scope disposed after parent start', () => { + const log: string[] = [] + let content: DeferredScope + const scope = deferScope(() => { + content = boundDeferScope()(() => { + effect(() => { + log.push('content') + + return () => log.push('content destroy') + }) + }) + }) + + startScope(scope) + + expect(log).toEqual(['content']) + + log.length = 0 + stopScope(content!) + + expect(log).toEqual(['content destroy']) + + stopScope(scope) + + expect(log).toEqual(['content destroy']) + }) + + it('should destroy nested scope effects before own effects', () => { + const log: string[] = [] + const scope = deferScope(() => { + effect(() => () => { + log.push('own-1 destroy') + }) + boundDeferScope()(() => { + effect(() => () => { + log.push('nested destroy') + }) + }) + effect(() => () => { + log.push('own-2 destroy') + }) + }) + + startScope(scope) + + log.length = 0 + stopScope(scope) + + expect(log).toEqual([ + 'nested destroy', + 'own-1 destroy', + 'own-2 destroy' + ]) + }) + + it('should destroy trailing nested scope before own effects', () => { + const log: string[] = [] + const scope = deferScope(() => { + effect(() => () => { + log.push('own destroy') + }) + boundDeferScope()(() => { + effect(() => () => { + log.push('nested destroy') + }) + }) + }) + + startScope(scope) + + log.length = 0 + stopScope(scope) + + expect(log).toEqual(['nested destroy', 'own destroy']) + }) + + it('should start and stop deferred scope with same handle', () => { + const log: string[] = [] + const scope = deferScope(() => { + effect(() => { + log.push('run') + + return () => log.push('destroy') + }) + }) + + startScope(scope) + + expect(log).toEqual(['run']) + + stopScope(scope) + + expect(log).toEqual(['run', 'destroy']) + }) + + it('should not warm up effect stopped during deferred start', () => { + const log: string[] = [] + const scope = deferScope(() => { + const stopFirst = effect(() => { + log.push('first') + + return () => log.push('first destroy') + }) + + effectScope(() => { + effect(() => { + log.push('stopper') + stopFirst() + }) + }) + }) + + log.length = 0 + + startScope(scope) + + // 'first' is stopped from pass 1 before its warmup and must not resurrect + expect(log).toEqual(['stopper']) + + stopScope(scope) + + expect(log).toEqual(['stopper']) + }) + + it('should start linked scope without lazy parent via startScope', () => { + const log: string[] = [] + const scope = boundDeferScope()(() => { + effect(() => { + log.push('run') + + return () => log.push('destroy') + }) + }) + + // scopes are created lazy and started explicitly + expect(log).toEqual([]) + + startScope(scope) + + expect(log).toEqual(['run']) + + stopScope(scope) + + expect(log).toEqual(['run', 'destroy']) + }) + + it('should start linked scope when parent is already started', () => { + const log: string[] = [] + let linked: (fn: () => void) => DeferredScope + const scope = deferScope(() => { + linked = boundDeferScope() + }) + + startScope(scope) + + const late = linked!(() => { + effect(() => { + log.push('late') + + return () => log.push('late destroy') + }) + }) + + expect(log).toEqual([]) + + startScope(late) + + expect(log).toEqual(['late']) + + // parent teardown must reach the scope created after the start + stopScope(scope) + + expect(log).toEqual(['late', 'late destroy']) + }) + + it('should run destroy of effect disposed during its own warmup', () => { + const log: string[] = [] + let stopSelf!: () => void + const scope = deferScope(() => { + const inner = deferScope(() => { + effect(() => { + log.push('run') + stopSelf() + + return () => log.push('destroy') + }) + }) + + stopSelf = () => stopScope(inner) + + effect(() => { + startScope(inner) + }) + }) + + startScope(scope) + + // the destroy returned by the disposed-during-warmup effect must run + expect(log).toEqual(['run', 'destroy']) + + stopScope(scope) + }) + + it('should not fire mounted for scope discarded before start', () => { + const $num = mountable(signal(0)) + const callback = vi.fn() + + onMounted($num, callback) + + const scope = deferScope(() => { + effect(() => { + $num() + }, true) + }) + + stopScope(scope) + startScope(deferScope(() => { /* flush mounted queue */ })) + + expect(callback).not.toHaveBeenCalled() + expect($num.node.subsCount).toBe(0) + }) + + it('should not crash batch flush after scope stop empties its parent', () => { + const $source = signal(0) + let child: DeferredScope | undefined + + effect(() => { + if (child === undefined) { + child = boundDeferScope()(() => { + $source() + }) + startScope(child) + } + }) + + expect(() => { + batch(() => { + $source(1) + stopScope(child!) + }) + }).not.toThrow() + }) + + it('should unlink deps created after disposal during warmup', () => { + const mountedEvents: boolean[] = [] + const cleanupEvents: string[] = [] + const $source = mountable(signal(1)) + let scope: DeferredScope | undefined = undefined + + onMounted($source, (mounted) => { + mountedEvents.push(mounted) + }) + + const $derived = computed(() => { + stopScope(scope!) + + return $source() * 2 + }) + + scope = deferScope(() => { + effect(() => { + $derived() + + return () => cleanupEvents.push('effect cleanup') + }) + }) + + startScope(scope) + + expect(cleanupEvents).toEqual(['effect cleanup']) + expect(mountedEvents).toEqual([]) + expect($source.node.subsCount).toBe(0) + }) + + it('should not start linked scope under stopped parent', () => { + const log: string[] = [] + let linked: (fn: () => void) => DeferredScope + const scope = deferScope(() => { + linked = boundDeferScope() + }) + + startScope(scope) + stopScope(scope) + + const late = linked!(() => { + effect(() => { + log.push('late') + }) + }) + + startScope(late) + + expect(log).toEqual([]) + }) + it('should trigger signal activation after start', () => { const $num = mountable(signal(0)) const callback = vi.fn() onMounted($num, callback) - const start = deferScope(() => { + const scope = deferScope(() => { $num() effect(() => { @@ -1598,11 +2016,11 @@ describe('agera', () => { expect(callback).not.toHaveBeenCalled() - const stop = start() + startScope(scope) expect(callback).toHaveBeenCalledTimes(1) - stop() + stopScope(scope) }) it('should trigger computed dep activation after start', () => { @@ -1612,7 +2030,7 @@ describe('agera', () => { onMounted($num, callback) - const start = deferScope(() => { + const scope = deferScope(() => { $double() effect(() => { @@ -1622,11 +2040,11 @@ describe('agera', () => { expect(callback).not.toHaveBeenCalled() - const stop = start() + startScope(scope) expect(callback).toHaveBeenCalledTimes(1) - stop() + stopScope(scope) }) it('should trigger computed activation after start', () => { @@ -1638,7 +2056,7 @@ describe('agera', () => { onMounted($num, callback) onMounted($double, computedCallback) - const start = deferScope(() => { + const scope = deferScope(() => { $double() effect(() => { @@ -1649,18 +2067,18 @@ describe('agera', () => { expect(callback).not.toHaveBeenCalled() expect(computedCallback).not.toHaveBeenCalled() - const stop = start() + startScope(scope) expect(callback).toHaveBeenCalledTimes(1) expect(computedCallback).toHaveBeenCalledTimes(1) - stop() + stopScope(scope) }) it('should ignore update', () => { const $num = signal(0) const onEffect = vi.fn() - const start = deferScope(() => { + const scope = deferScope(() => { effect(() => { onEffect($num()) }) @@ -1672,11 +2090,11 @@ describe('agera', () => { expect(onEffect).not.toHaveBeenCalled() - const stop = start() + startScope(scope) expect(onEffect).toHaveBeenCalledWith(2) - stop() + stopScope(scope) }) it('should not break child effects', () => { @@ -1689,17 +2107,16 @@ describe('agera', () => { const createItem = (i: number) => effect(() => { onChange($items()[i]) }) - const startScope = deferScope(() => { + const itemsScope = deferScope(() => { $items().forEach((_, i) => { createItem(i) }) }) - let stopScope const stop = effect((warmup) => { $items() if (warmup) { - stopScope = startScope() + startScope(itemsScope) } else { effectScope(() => {}) } @@ -1724,7 +2141,7 @@ describe('agera', () => { [6] ]) - stopScope!() + stopScope(itemsScope) stop() }) @@ -1737,7 +2154,7 @@ describe('agera', () => { ]) const $index = signal(0) let destroyItemEffect: () => void - const loopStart = deferScope(() => { + const loopScope = deferScope(() => { logs.push('loop scope init') const $item = computed(() => $items()[$index()]) @@ -1751,7 +2168,6 @@ describe('agera', () => { expect(logs).toEqual(['loop scope init']) - let loopDestroy: () => void const itemsDestroy = effect((warmup) => { logs.push('items effect') @@ -1759,7 +2175,7 @@ describe('agera', () => { if (warmup) { logs.push('items effect warmup') - loopDestroy = loopStart() + startScope(loopScope) } else { logs.push('items effect update') $index(1) @@ -1783,7 +2199,7 @@ describe('agera', () => { expect(logs).toEqual(['items effect', 'items effect update']) destroyItemEffect!() - loopDestroy!() + stopScope(loopScope) itemsDestroy() }) }) diff --git a/packages/agera/src/effect.ts b/packages/agera/src/effect.ts index 969a3109..6965acb5 100644 --- a/packages/agera/src/effect.ts +++ b/packages/agera/src/effect.ts @@ -7,14 +7,20 @@ import { effect, effectScope, deferScope, + boundDeferScope, + startScope, + stopScope, untracked } from './internals/system.js' import { noMount } from './internals/lifecycle.js' export { effect, + boundDeferScope, effectScope, - deferScope + deferScope, + startScope, + stopScope } function singleEffect( diff --git a/packages/agera/src/internals/lifecycle.ts b/packages/agera/src/internals/lifecycle.ts index e1c0919f..398b9855 100644 --- a/packages/agera/src/internals/lifecycle.ts +++ b/packages/agera/src/internals/lifecycle.ts @@ -1,5 +1,4 @@ import type { - WritableSignal, Stack, ReactiveNode, Link, @@ -11,14 +10,14 @@ import { LazyMode } from './flags.js' -type MountedListener = Stack> +type MountedListener = Stack let mountedListeners: MountedListener | undefined let mountedListenersTail: MountedListener | undefined -function queueMounted(onMounted: WritableSignal): void { +function queueMounted(node: ReadableNode): void { const listener: MountedListener = { - value: onMounted, + value: node, prev: undefined } @@ -44,7 +43,11 @@ export function notifyMounted( mountedListenersTail = undefined do { - listener.value(true) + // The subscriber may be gone before the flush: stay unmounted then + if (listener.value.subsCount > 0) { + listener.value.mounted!(true) + } + listener = listener.prev! } while (listener !== undefined) } @@ -71,7 +74,7 @@ export function incrementEffectCount(dep: ReactiveNode | ReadableNode): void { } if (dep.subsCount === 1 && 'mounted' in dep) { - queueMounted(dep.mounted!) + queueMounted(dep) } } } diff --git a/packages/agera/src/internals/system.ts b/packages/agera/src/internals/system.ts index 14be07f6..78cbcc2c 100644 --- a/packages/agera/src/internals/system.ts +++ b/packages/agera/src/internals/system.ts @@ -13,7 +13,8 @@ import type { Destroy, Compute, NewValue, - Morph + Morph, + DeferredScope } from './types.js' import { NoneFlag, @@ -561,31 +562,6 @@ export function effectScope(fn: () => void): Destroy { return effectScopeOper.bind(e) } -/** - * Defer scope creation to delay its effects execution. - * @param fn - The deferred scope function to run. - * @returns A function to start effects. - */ -export function deferScope(fn: () => void): () => Destroy { - const e: ReactiveNode = { - deps: undefined, - depsTail: undefined, - subs: undefined, - subsTail: undefined, - flags: MutableFlag, - modes: ScopeMode | LazyMode - } - const prevSub = pushActiveSub(e) - - try { - fn() - } finally { - popActiveSub(prevSub) - } - - return deferScopeOper.bind(e) -} - /** * Manually trigger signals update propagation. * @param fn - Function with signal reads to trigger. @@ -660,6 +636,16 @@ function warmupEffect(e: EffectNode): void { try { e.destroy = e.fn(true) || undefined + + // Stopping is total: the effect reached STOPPED while this body was + // still running, so finish the teardown the stop could not do - run the + // destroy it just returned, and drop the links it made after it died, + // because nothing will ever reach them again + if (e.flags === NoneFlag) { + destroyEffect(e) + e.depsTail = undefined + purgeDeps(e) + } } finally { popActiveSub(prevSub) e.flags &= ~RecursedCheckFlag @@ -834,56 +820,267 @@ function effectOper(this: EffectNode): void { effectScopeOper.call(this) } +// The STOPPED transition, shared by effect and scope disposal: make the node +// terminal, destroy what it owns, detach it from its position function effectScopeOper(this: ReactiveNode): void { this.depsTail = undefined this.flags = NoneFlag + // Spend the deferral token. This single line is what turns every stale + // reference - a start walk cursor, a retained handle, a link made during + // the teardown - into a no-op instead of a resurrection + this.modes &= ~LazyMode purgeDeps(this) const sub = this.subs if (sub !== undefined) { + const parent = sub.sub + unlink(sub) + + // Stopping is total: a scope node holds no value, so one emptied by this + // stop must not stay dirty or pending - `checkDirty` would descend into + // deps that are gone + if (parent.deps === undefined && parent.modes & ScopeMode) { + parent.flags &= ~(DirtyFlag | PendingFlag) + } } } -function runDeferredEffects(link: Link): void { - do { - const dep = link.dep - const nextDep = link.nextDep +function purgeDeps(sub: ReactiveNode) { + const depsTail = sub.depsTail + let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps - if (dep.modes & LazyMode) { - dep.modes &= ~LazyMode + while (dep !== undefined) { + dep = unlink(dep, sub) + } +} - if (dep.modes & ScopeMode) { - if (dep.deps !== undefined) { - runDeferredEffects(dep.deps) - } - } else { +// #region Defer scopes +// +// A deferred scope is an ordinary effect scope whose effects are captured +// but not warmed up: `deferScope` runs the body, `startScope` releases it, +// `stopScope` discards it. The layer introduces no node kind and no new +// field - the whole state machine is one mode bit on a scope node: +// +// LazyMode set LAZY body ran, nothing was warmed up yet +// LazyMode clear STARTED effects are live (flags & MutableFlag) +// LazyMode clear STOPPED effects are gone (flags === NoneFlag) +// +// LAW 1 - LazyMode is a one-shot token. +// Minted in exactly one place, `deferScope`. Inherited everywhere else by a +// single rule shared with `effect` and `effectScope`: a node created at a +// lazy position is created lazy, so a subtree defers as one. Spent by +// exactly one of two claimants - `startScope`, which keeps the promise and +// warms the subtree up, or the STOPPED transition (`effectScopeOper`), +// which revokes it. Spending is always `modes &= ~LazyMode`. +// Every guard below is this one rule meeting an already spent token: +// - `startScope` on a started or on a stopped scope - nothing to claim; +// - a scope or an effect stopped by a sibling in the middle of the start +// walk - the stop claimed it, the walk steps over it; +// - a retained handle or a stale cursor into a stopped node - the same. +// A node may therefore be stopped at any moment, including from inside the +// walk that is starting it, and no participant has to know about it. +// Two edges sit outside the contract: stopping a node from inside its own +// still-running body leaves whatever the body creates after that point +// live (imperative self-teardown mid-body is not a supported move), and a +// lazy scope discarded by `unwatched` keeps an unspent token over already +// purged deps, so a later start finds nothing to do. +// +// LAW 2 - stopping is total. +// `stopScope` is defined on a scope in any state, at any moment, and has to +// leave a graph the core can still traverse. Its two obligations live with +// the STOPPED transition itself rather than here, because plain effect +// disposal owes exactly the same: +// - `effectScopeOper`: a scope emptied by the stop drops DirtyFlag and +// PendingFlag - a scope node holds no value, so `checkDirty` would +// descend into deps that no longer exist; +// - `warmupEffect`: an effect stopped while its own body is still running +// finishes the teardown it missed - the destroy it returns is run, and +// the links it made after it died are dropped. +// +// ORDER - both directions walk the same list, children first. +// A scope's `deps` is its capture list in creation order, holding nested +// scopes (`effectScope` and `deferScope` alike) and own effects; a scope +// created without a parent joins no capture list and is reached only +// through its handle. Start walks +// it twice - nested scopes, then own effects - so an effect body observes a +// subtree that is already live. Stop walks it once for nested scopes and +// leaves the own effects to `purgeDeps`, so a destroy observes a subtree +// that is already gone. Both walks may be edited under themselves: `unlink` +// leaves the removed link's `nextDep` intact, so a cursor survives the +// removal of the node it is standing on. +// +// POSITION - `boundDeferScope` captures a site, not a scope. Its anchor is +// an empty scope node that stays where it was created, so the scopes +// swapped through it keep their place among the siblings; it inherits the +// state of that site, so they follow the parent's start and stop. +// + +function createScope(parent: ReactiveNode | undefined): ReactiveNode { + const e: ReactiveNode = { + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + flags: MutableFlag, + modes: ScopeMode + } + + if (parent !== undefined) { + link(e, parent, 0) + // Inherit the state of the position, exactly as `effect` and + // `effectScope` do, so a subtree defers and starts as one + e.modes |= parent.modes & LazyMode + } + + return e +} + +/** + * Defer scope creation to delay its effects execution. + * @internal + * @param fn - The deferred scope function to run. + * @param parent - Optional parent scope node to link the created scope to. + * @returns Deferred scope handle. + */ +export function deferScope(fn: () => void, parent?: ReactiveNode): DeferredScope { + const e = createScope(parent) + const prevSub = pushActiveSub(e) + + // The single mint of the token: a deferred scope is lazy wherever it is + // created, and everything the body creates below it inherits that + e.modes |= LazyMode + + try { + fn() + } finally { + popActiveSub(prevSub) + } + + return e as unknown as DeferredScope +} + +function startDeferred(e: ReactiveNode): void { + const deps = e.deps + + if (deps !== undefined) { + let nested: Link | undefined = deps + let own: Link | undefined = deps + + // Nested scopes claim their token and release their own capture list + // first, so an effect body below observes a subtree that is already live + do { + const dep = nested.dep + + nested = nested.nextDep + + if ((dep.modes & (LazyMode | ScopeMode)) === (LazyMode | ScopeMode)) { + dep.modes &= ~LazyMode + startDeferred(dep) + } + } while (nested !== undefined) + + // Then the effects captured directly here. Whatever still holds a token + // is an effect: nested scopes were spent by the pass above, and anything + // stopped meanwhile - by a sibling warmed up in this very walk - was + // spent by the stop and is stepped over instead of being resurrected + do { + const dep = own.dep + + own = own.nextDep + + if (dep.modes & LazyMode) { + dep.modes &= ~LazyMode warmupEffect(dep as EffectNode) } - } + } while (own !== undefined) + } +} - link = nextDep! - } while (link !== undefined) +/** + * Start deferred effects of the scope. Does nothing if the scope is already + * started or stopped, or if it is linked to a parent that has not started + * yet or is already stopped. + * @internal + * @param scope - Deferred scope handle. + * @returns The same scope handle. + */ +export function startScope(scope: DeferredScope): DeferredScope { + const e = scope as unknown as ReactiveNode + const parent = e.subs?.sub + + if ( + e.modes & LazyMode + && ( + // A linked scope starts with its position: never ahead of a parent + // that is still LAZY, never under one that is already STOPPED + parent === undefined + || !(parent.modes & LazyMode) && parent.flags & MutableFlag + ) + ) { + e.modes &= ~LazyMode + + // What the lazy body queued for mounting belongs to this moment - the + // scope became live now, before any of its deferred effects queue more + notifyMounted(activeSub) + startDeferred(e) + } + + return scope } -function deferScopeOper(this: ReactiveNode): Destroy { - this.modes &= ~LazyMode +/** + * Stop the scope and destroy its effects. + * @internal + * @param scope - Deferred scope handle. + */ +export function stopScope(scope: DeferredScope): void { + const e = scope as unknown as ReactiveNode + // Nested scopes go first, so a destroy observes a subtree that is already + // gone. Unlike the start walk this takes every scope child, LAZY or + // STARTED: stopping is total, and a child left to `purgeDeps` below would + // be torn down in list order and would keep an unspent token + let nested = e.deps - notifyMounted(activeSub) + while (nested !== undefined) { + const dep = nested.dep - if (this.deps !== undefined) { - runDeferredEffects(this.deps) + nested = nested.nextDep + + if (dep.modes & ScopeMode) { + stopScope(dep as unknown as DeferredScope) + } } - return effectScopeOper.bind(this) + // The other half - destroying the effects captured directly here - is the + // `purgeDeps` of the shared STOPPED transition + effectScopeOper.call(e) } -function purgeDeps(sub: ReactiveNode) { - const depsTail = sub.depsTail - let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps +/** + * Capture the current scope as a position and create deferred scopes at it. + * The anchor keeps that position among the siblings for the lifetime of the + * capture site, and passes the parent's start and stop down to every scope + * created through it. + * Capture inside an effect: an anchor captured in an effect body is torn + * down by the effect re-run in the deps order, not children-first. + * The optional second argument of the factory is a sibling scope to replace: + * it is stopped before the new scope body runs. + * Created scopes are not started: use `startScope`. + * @internal + * @returns Function to create linked deferred scopes. + */ +export function boundDeferScope() { + const anchor = createScope(activeSub) - while (dep !== undefined) { - dep = unlink(dep, sub) + return (fn: () => void, replace?: DeferredScope): DeferredScope => { + if (replace !== undefined) { + stopScope(replace) + } + + return deferScope(fn, anchor) } } + +// #endregion diff --git a/packages/agera/src/internals/types.ts b/packages/agera/src/internals/types.ts index b8f1c6ba..cf6c6e9b 100644 --- a/packages/agera/src/internals/types.ts +++ b/packages/agera/src/internals/types.ts @@ -99,3 +99,9 @@ export interface SignalNode extends WritableNode { value: T pendingValue: T } + +/** + * Opaque deferred scope handle. + * @internal + */ +export type DeferredScope = DefineVirtualFlags<'scope', true> diff --git a/packages/kida/.size-limit.json b/packages/kida/.size-limit.json index 3661744c..01c15832 100644 --- a/packages/kida/.size-limit.json +++ b/packages/kida/.size-limit.json @@ -3,18 +3,18 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "3.93 kB" + "limit": "4.11 kB" }, { "name": "Signal", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.59 kB" + "limit": "1.62 kB" }, { "name": "Popular set", "path": "dist/index.js", "import": "{ signal, record, computed, effect, mountable, onMount }", - "limit": "2.23 kB" + "limit": "2.26 kB" } ] diff --git a/packages/kida/README.md b/packages/kida/README.md index 492c20db..5f4b95cd 100644 --- a/packages/kida/README.md +++ b/packages/kida/README.md @@ -167,33 +167,6 @@ const stop = effectScope(() => { stop() // stop all effects ``` -### `deferScope` - -Also there is a possibility to create a defer scope. - -```ts -import { signal, deferScope, effectScope, effect } from 'kida' - -const $a = signal(0) -const $b = signal(0) -// All scopes will run immediately, but effects run is delayed -const start = deferScope(() => { - effect(() => { - console.log('A:', $a()) - }) - - effectScope(() => { - effect(() => { - console.log('B:', $b()) - }) - }) -}, true) // marks scope as lazy -// start all effects -const stop = start() - -stop() // stop all effects -``` - ### `onMountEffect` `onMountEffect` accepts a signal as a first argument to start effect on this [signal mount](#lifecycles). diff --git a/packages/kida/src/di.spec.ts b/packages/kida/src/di.spec.ts index dbb9f038..c44e75db 100644 --- a/packages/kida/src/di.spec.ts +++ b/packages/kida/src/di.spec.ts @@ -6,6 +6,8 @@ import { } from 'vitest' import { deferScope, + startScope, + stopScope, effect } from 'agera' import { @@ -257,7 +259,7 @@ describe('kida', () => { return 42 }) let value - const start = deferScope(() => { + const scope = deferScope(() => { value = inject(Factory$, context) value = inject(Factory$, context) }) @@ -267,13 +269,13 @@ describe('kida', () => { expect(fn).toHaveBeenCalledTimes(1) expect(destroy).not.toHaveBeenCalled() - const stop = start() + startScope(scope) expect(Factory$).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1) expect(destroy).not.toHaveBeenCalled() - stop() + stopScope(scope) expect(Factory$).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1) diff --git a/packages/kida/src/di.ts b/packages/kida/src/di.ts index 139f6fb0..660b4538 100644 --- a/packages/kida/src/di.ts +++ b/packages/kida/src/di.ts @@ -68,13 +68,14 @@ export function getContext() { } /** - * Run a function within an injection context. + * Run a function within an injection context without untracking. + * Reads inside will be tracked by the current tracking scope. * @param context - The injection context. * @param fn - The function to run. * @param args - The arguments to pass to the function. * @returns The return value of the function. */ -export function run( +export function unsafeRun( context: InjectionContext | undefined, fn: T, ...args: Parameters @@ -84,12 +85,27 @@ export function run( currentContext = context try { - return untracked(() => fn(...args as unknown[])) + return fn(...args as unknown[]) } finally { currentContext = parentContext } } +/** + * Run a function within an injection context. + * @param context - The injection context. + * @param fn - The function to run. + * @param args - The arguments to pass to the function. + * @returns The return value of the function. + */ +export function run( + context: InjectionContext | undefined, + fn: T, + ...args: Parameters +): ReturnType { + return untracked(() => unsafeRun(context, fn, ...args)) +} + /** * Provide a dependency. * @param injectable - The injectable function or class to associate with the value. diff --git a/packages/nanoviews/.size-limit.json b/packages/nanoviews/.size-limit.json index d64bcd5b..862a5094 100644 --- a/packages/nanoviews/.size-limit.json +++ b/packages/nanoviews/.size-limit.json @@ -3,12 +3,12 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "7 kB" + "limit": "7.13 kB" }, { "name": "Average usage", "path": "dist/index.js", "import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }", - "limit": "4.26 kB" + "limit": "4.33 kB" } ] diff --git a/packages/nanoviews/src/component/context.ts b/packages/nanoviews/src/component/context.ts index f1a007ff..80efb97d 100644 --- a/packages/nanoviews/src/component/context.ts +++ b/packages/nanoviews/src/component/context.ts @@ -3,6 +3,7 @@ import { InjectionContext, getContext, run, + unsafeRun, provide, inject, isFunction @@ -46,7 +47,7 @@ export function context(providersOrFn: InjectionProvider[] | (() => R), maybe fn = maybeFn! } - return run(new InjectionContext(providers, currentContext), fn) + return unsafeRun(new InjectionContext(providers, currentContext), fn) } /** @@ -55,5 +56,5 @@ export function context(providersOrFn: InjectionProvider[] | (() => R), maybe * @returns The return value of the function. */ export function isolate(fn: () => R): R { - return run(undefined, fn) + return unsafeRun(undefined, fn) } diff --git a/packages/nanoviews/src/internals/effects.spec.ts b/packages/nanoviews/src/internals/effects.spec.ts new file mode 100644 index 00000000..a27e3e78 --- /dev/null +++ b/packages/nanoviews/src/internals/effects.spec.ts @@ -0,0 +1,756 @@ +import { + describe, + it, + expect +} from 'vitest' +import { render } from '@nanoviews/testing-library' +import { + signal, + effect, + batch, + deferScope, + startScope, + stopScope, + inject, + getContext +} from 'kida' +import { + div, + span +} from '../elements/elements.js' +import { if_ } from '../flow/if.js' +import { for_ } from '../flow/for.js' +import { context } from '../component/context.js' + +describe('nanoviews', () => { + describe('internals', () => { + describe('effects', () => { + describe('mount order', () => { + it('should run condition content effects before parent effects', () => { + const log: string[] = [] + + function Child() { + effect(() => { + log.push('child') + }) + + return span()('child') + } + + function Parent() { + effect(() => { + log.push('parent') + }) + + return div()( + if_(signal(true))( + () => Child() + ) + ) + } + + render(() => Parent()) + + expect(log).toEqual(['child', 'parent']) + }) + + it('should run loop item effects in order before parent effects', () => { + const log: string[] = [] + + function Row($item: () => number) { + effect(() => { + log.push(`row ${$item()}`) + }) + + return span()(String($item())) + } + + function Parent() { + effect(() => { + log.push('parent') + }) + + return div()( + for_(signal([1, 2]))( + $item => Row($item) + ) + ) + } + + render(() => Parent()) + + expect(log).toEqual([ + 'row 1', + 'row 2', + 'parent' + ]) + }) + + it('should not run content effects before mount', () => { + const log: string[] = [] + + function Child() { + effect(() => { + log.push('child') + }) + + return span()('child') + } + + const scope = deferScope(() => { + div()( + if_(signal(true))( + () => Child() + ) + ) + }) + + expect(log).toEqual([]) + + startScope(scope) + + expect(log).toEqual(['child']) + + stopScope(scope) + }) + + it('should keep sibling blocks start order on condition change before mount', () => { + const log: string[] = [] + const $condition = signal(true) + const Block = (name: string) => { + effect(() => { + log.push(name) + }) + + return span()(name) + } + const scope = deferScope(() => { + div()( + if_($condition)( + () => Block('then'), + () => Block('else') + ), + if_(signal(true))( + () => Block('sibling') + ) + ) + }) + + // swap before mount must not move the block to the parent deps tail + $condition(false) + + startScope(scope) + + expect(log).toEqual(['else', 'sibling']) + + stopScope(scope) + }) + + it('should keep sibling blocks destroy order after condition change', () => { + const log: string[] = [] + const $condition = signal(true) + const Block = (name: string) => { + effect(() => () => { + log.push(`${name} destroy`) + }) + + return span()(name) + } + const scope = deferScope(() => { + div()( + if_($condition)( + () => Block('then'), + () => Block('else') + ), + if_(signal(true))( + () => Block('sibling') + ) + ) + }) + + startScope(scope) + + $condition(false) + + log.length = 0 + stopScope(scope) + + expect(log).toEqual(['else destroy', 'sibling destroy']) + }) + + it('should destroy previous content effects before new content effects run', () => { + const log: string[] = [] + const $condition = signal(true) + const Block = (name: string) => { + effect(() => { + log.push(`${name} run`) + + return () => log.push(`${name} destroy`) + }) + + return span()(name) + } + const scope = deferScope(() => { + div()( + if_($condition)( + () => Block('then'), + () => Block('else') + ) + ) + }) + + startScope(scope) + + log.length = 0 + // previous content cleanup must run before the new content setup + $condition(false) + + expect(log).toEqual(['then destroy', 'else run']) + + stopScope(scope) + }) + + it('should destroy previous content before rendering the new one', () => { + const log: string[] = [] + const $condition = signal(true) + const Block = (name: string) => { + log.push(`${name} render`) + effect(() => { + log.push(`${name} run`) + + return () => log.push(`${name} destroy`) + }) + + return span()(name) + } + const scope = deferScope(() => { + div()( + if_($condition)( + () => Block('then'), + () => Block('else') + ) + ) + }) + + startScope(scope) + + log.length = 0 + $condition(false) + + expect(log).toEqual([ + 'then destroy', + 'else render', + 'else run' + ]) + + stopScope(scope) + }) + + it('should destroy content effects while their DOM is attached', () => { + const $condition = signal(true) + let connectedAtDestroy: boolean | undefined + + function Then() { + const el = span()('then') + + effect(() => () => { + connectedAtDestroy = el.isConnected + }) + + return el + } + + render(() => div()( + if_($condition)( + () => Then(), + () => span()('else') + ) + )) + + $condition(false) + + expect(connectedAtDestroy).toBe(true) + }) + + it('should stop rows when the branch is hidden during rows start', () => { + const log: string[] = [] + const $show = signal(true) + const $items = signal([1, 2]) + const $tick = signal(0) + let armed = false + + function Row($item: () => number) { + const id = $item() + + effect(() => { + log.push(`row ${id} run ${$tick()}`) + + if (id === 1 && armed) { + batch(() => $show(false)) + } + + return () => log.push(`row ${id} destroy`) + }) + + return span()(String(id)) + } + + render(() => div()( + if_($show)( + () => span()( + for_($items, item => item)( + $item => Row($item) + ) + ), + () => span()('hidden') + ) + )) + + $items([]) + armed = true + log.length = 0 + // the branch is torn down from inside the first row start: + // the teardown is immediate - the running row is destroyed and + // the remaining rows never start + $items([1, 2]) + armed = false + + expect(log).toEqual([ + 'row 1 run 0', + 'row 1 destroy' + ]) + + log.length = 0 + $tick(1) + + expect(log).toEqual([]) + }) + + it('should reset rows on empty transition before mount', () => { + const log: string[] = [] + const $items = signal([1, 2]) + + function Row($item: () => number) { + const initial = $item() + + effect(() => { + log.push(`row ${initial} run`) + + return () => log.push(`row ${initial} destroy`) + }) + + return span()(String(initial)) + } + + let el!: HTMLElement + const scope = deferScope(() => { + el = div()( + for_($items, item => item)( + $item => Row($item) + ) + ) + }) + + // pre-mount transition to empty must discard the stale rows + $items([]) + + startScope(scope) + + expect(log).toEqual([]) + + $items([1, 3]) + + expect(log).toEqual(['row 1 run', 'row 3 run']) + expect(el.textContent).toBe('13') + + stopScope(scope) + }) + + it('should stop rows when the loop is stopped before it is started', () => { + const log: string[] = [] + const $items = signal([1, 2]) + const $cond = signal(true) + + function Row($item: () => number) { + const initial = $item() + + return span()( + if_($cond)( + () => { + log.push(`row ${initial} then`) + + return span()('T') + }, + () => { + log.push(`row ${initial} else`) + + return span()('F') + } + ) + ) + } + + const scope = deferScope(() => { + div()( + for_($items, item => item)( + $item => Row($item) + ) + ) + }) + + log.length = 0 + stopScope(scope) + + // the nested condition swappers of the discarded rows must be gone + $cond(false) + + expect(log).toEqual([]) + }) + + it('should destroy removed rows before starting created rows', () => { + const log: string[] = [] + const $items = signal([1, 2]) + + function Row($item: () => number) { + const initial = $item() + + effect(() => { + log.push(`row ${initial} run`) + + return () => log.push(`row ${initial} destroy`) + }) + + return span()(String(initial)) + } + + const scope = deferScope(() => { + div()( + for_($items, item => item)( + $item => Row($item) + ) + ) + }) + + startScope(scope) + + log.length = 0 + $items([1, 3]) + + expect(log).toEqual([ + 'row 2 destroy', + 'row 3 run' + ]) + + stopScope(scope) + }) + + it('should not leak loop context into effects flushed after reconcile', () => { + const $items = signal([1]) + const $other = signal(0) + const observed: unknown[] = [] + const stopOuter = effect(() => { + $other() + observed.push(getContext()) + }) + const scope = deferScope(() => { + context([], () => { + div()( + for_($items, (item) => { + // signal write from the tracker queues the outer effect for the flush + $other($other() + 1) + + return item + })( + $item => span()(String($item())) + ) + ) + }) + }) + + startScope(scope) + + observed.length = 0 + $items([1, 2]) + + // the outer effect must not observe the loop DI context + expect(observed).toEqual([undefined]) + + stopScope(scope) + stopOuter() + }) + + it('should keep injection context in track function on list updates', () => { + function Key() { + return (item: number) => item + } + + const $items = signal([1]) + const scope = deferScope(() => { + context([], () => { + div()( + for_($items, item => inject(Key)(item))( + $item => span()(String($item())) + ) + ) + }) + }) + + startScope(scope) + + // the reconcile update path must observe the loop DI context + $items([1, 2]) + + stopScope(scope) + }) + + it('should not run replaced content effects on condition change before mount', () => { + const log: string[] = [] + const $condition = signal(true) + + function Then() { + effect(() => { + log.push('then') + }) + + return span()('then') + } + + function Else() { + effect(() => { + log.push('else') + }) + + return span()('else') + } + + const scope = deferScope(() => { + div()( + if_($condition)( + () => Then(), + () => Else() + ) + ) + }) + + // swap before start: the replaced branch must never run or resurrect + $condition(false) + + startScope(scope) + + expect(log).toEqual(['else']) + + stopScope(scope) + }) + + it('should destroy content effects on condition change', () => { + const log: string[] = [] + const $condition = signal(true) + const $dep = signal(0) + + function Child() { + effect(() => { + log.push(`child ${$dep()}`) + + return () => log.push('child destroy') + }) + + return span()('child') + } + + render(() => div()( + if_($condition)( + () => Child() + ) + )) + + expect(log).toEqual(['child 0']) + + log.length = 0 + $condition(false) + + expect(log).toEqual(['child destroy']) + + log.length = 0 + $dep(1) + + expect(log).toEqual([]) + }) + + it('should destroy condition content effects before parent effects', () => { + const log: string[] = [] + + function Child() { + effect(() => () => { + log.push('child destroy') + }) + + return span()('child') + } + + const scope = deferScope(() => { + effect(() => () => { + log.push('parent destroy') + }) + div()( + if_(signal(true))( + () => Child() + ) + ) + }) + + startScope(scope) + + log.length = 0 + stopScope(scope) + + expect(log).toEqual(['child destroy', 'parent destroy']) + }) + + it('should destroy row effects in visual order after reorder', () => { + const log: string[] = [] + const $items = signal([1, 2, 3]) + + function Row($item: () => number) { + const initial = $item() + + effect(() => () => { + log.push(`row ${initial} destroy`) + }) + + return span()(String(initial)) + } + + const scope = deferScope(() => { + div()( + for_($items, item => item)( + $item => Row($item) + ) + ) + }) + + startScope(scope) + + // reorder: visual order becomes 3, 1, 2 + $items([3, 1, 2]) + stopScope(scope) + + expect(log).toEqual([ + 'row 3 destroy', + 'row 1 destroy', + 'row 2 destroy' + ]) + }) + + it('should destroy surviving row effects on unmount after reconcile', () => { + const log: string[] = [] + const $items = signal([1, 2]) + + function Row($item: () => number) { + effect(() => { + log.push(`row ${$item()}`) + + return () => log.push(`row ${$item()} destroy`) + }) + + return span()(String($item())) + } + + const scope = deferScope(() => { + div()( + for_($items, item => item)( + $item => Row($item) + ) + ) + }) + + startScope(scope) + + expect(log).toEqual(['row 1', 'row 2']) + + log.length = 0 + // reorder with both rows surviving the keyed reconcile + $items([2, 1]) + + expect(log).toEqual([]) + + stopScope(scope) + + expect(log.sort()).toEqual(['row 1 destroy', 'row 2 destroy']) + }) + + it('should defer effects created within injection context', () => { + const log: string[] = [] + + function Child() { + effect(() => { + log.push('child') + + return () => log.push('child destroy') + }) + + return span()('child') + } + + const scope = deferScope(() => { + context([], () => div()(Child())) + }) + + expect(log).toEqual([]) + + startScope(scope) + + expect(log).toEqual(['child']) + + log.length = 0 + stopScope(scope) + + expect(log).toEqual(['child destroy']) + }) + + it('should run nested condition content effects before all parent effects', () => { + const log: string[] = [] + + function Inner() { + effect(() => { + log.push('inner') + }) + + return span()('inner') + } + + function Middle() { + effect(() => { + log.push('middle') + }) + + return div()( + if_(signal(true))( + () => Inner() + ) + ) + } + + function Outer() { + effect(() => { + log.push('outer') + }) + + return div()( + if_(signal(true))( + () => Middle() + ) + ) + } + + render(() => Outer()) + + expect(log).toEqual([ + 'inner', + 'middle', + 'outer' + ]) + }) + }) + }) + }) +}) diff --git a/packages/nanoviews/src/internals/effects.ts b/packages/nanoviews/src/internals/effects.ts index 24593bc5..6152683b 100644 --- a/packages/nanoviews/src/internals/effects.ts +++ b/packages/nanoviews/src/internals/effects.ts @@ -1,43 +1,31 @@ import { type Accessor, - type Destroy, + type DeferredScope, effect, - deferScope, + boundDeferScope, + startScope, getContext, - run, + unsafeRun, untracked } from 'kida' import type { EffectScopeSwapperCallback } from './types/index.js' export function deferScopeBindContext(context = getContext()) { - return (fn => deferScope(() => run(context, fn))) as typeof deferScope + const factory = boundDeferScope() + + // Render under the injection context, start strictly outside of it + return (fn: () => void, replace?: DeferredScope): DeferredScope => startScope(unsafeRun(context, factory, fn, replace)) } export function effectScopeSwapper( $signal: Accessor, callback: EffectScopeSwapperCallback ) { - let prevValue: T | undefined - let start: (() => Destroy) | undefined - let stop: Destroy | undefined + let prev: DeferredScope | undefined - effect((warmup) => { + effect(() => { const value = $signal() - stop = untracked(() => callback(stop, value, prevValue)) - - if (warmup) { - start = stop as () => Destroy - stop = undefined - } - - prevValue = value + prev = untracked(() => callback(prev, value)) }, true) - - effect(() => { - stop = start!() - start = undefined - - return () => stop!() - }) } diff --git a/packages/nanoviews/src/internals/flow/decide.ts b/packages/nanoviews/src/internals/flow/decide.ts index 712cb123..8285be5b 100644 --- a/packages/nanoviews/src/internals/flow/decide.ts +++ b/packages/nanoviews/src/internals/flow/decide.ts @@ -1,7 +1,7 @@ import { type Accessor, type ValueOrAccessor, - type Destroy, + type DeferredScope, isAccessor } from 'kida' import type { Child } from '../types/index.js' @@ -26,29 +26,18 @@ export function reactiveDecide( fragment.append(start, end) + // The replaced scope is destroyed first, while its DOM is still + // attached; then the body removes it and renders the new content effectScopeSwapper($condition, ( - destroyPrev: Destroy | undefined, + destroyPrev: DeferredScope | undefined, condition: T - ) => { + ) => deferScope(() => { if (destroyPrev !== undefined) { - destroyPrev() removeBetween(start, end) } - const runEffects = deferScope( - () => insertChildBeforeAnchor(decider(condition), end) - ) - - // Rerender on condition change in effect - if (destroyPrev !== undefined) { - // Should return effect stop function - return runEffects() - } - - // First render, before effects run - // Should return effect start function - return runEffects - }) + insertChildBeforeAnchor(decider(condition), end) + }, destroyPrev)) return fragment } diff --git a/packages/nanoviews/src/internals/flow/loop.ts b/packages/nanoviews/src/internals/flow/loop.ts index fdf9ee26..5d53c9ee 100644 --- a/packages/nanoviews/src/internals/flow/loop.ts +++ b/packages/nanoviews/src/internals/flow/loop.ts @@ -2,13 +2,19 @@ import { type ReadableSignal, type Accessor, type WritableSignal, + type DeferredScope, signal, - effectScope, + effect, + deferScope, + startScope, + stopScope, + getContext, + unsafeRun, + untracked, atIndex, batch } from 'kida' import type { - Destroy, Child, EmptyValue } from '../types/index.js' @@ -31,11 +37,12 @@ interface LoopItem { l: ChildNode | EmptyValue n: LoopItem | undefined p: LoopItem | undefined - d: Destroy + d: DeferredScope } interface LoopItemsList { f: LoopItem | undefined + s: boolean } type LookupMap = Map @@ -73,9 +80,11 @@ function link( function move( item: LoopItem, - anchor: ChildNode + anchorItem: LoopItem | undefined, + fallback: ChildNode ) { if (!isEmpty(item.f)) { + const anchor = getAnchor(anchorItem, fallback) const nextStart = item.l!.nextSibling! let node = item.f @@ -147,7 +156,7 @@ function reconcile( const b = matched[matched.length - 1] for (j = 0; j < matched.length; j += 1) { - move(matched[j], getAnchor(start, anchor)) + move(matched[j], start, anchor) } for (j = 0; j < stashed.length; j += 1) { @@ -166,7 +175,7 @@ function reconcile( stashed = [] } else { seen.delete(item) - move(item, getAnchor(current, anchor)) + move(item, current, anchor) link(itemsList, item.p, item.n) link(itemsList, item, prev === undefined ? itemsList.f : prev.n) @@ -213,7 +222,7 @@ function reconcile( } function destroyLoopItem(itemsList: LoopItemsList, item: LoopItem, lookupMap: LookupMap) { - item.d() + stopScope(item.d) if (!isEmpty(item.f)) { remove(item.f, item.l!) @@ -238,10 +247,10 @@ function createEachBlock( l: undefined, n: undefined, p: undefined, - d: undefined as Destroy | undefined + d: undefined as DeferredScope | undefined } - item.d = effectScope(() => insertChildBeforeAnchor( + item.d = deferScope(() => insertChildBeforeAnchor( each_(atIndex($items, $index), $index), anchor, item @@ -258,85 +267,115 @@ export function loop( ): Child { const start = createTextNode() const end = createTextNode() - const deferScope = deferScopeBindContext() + const context = getContext() + const periodScope = deferScopeBindContext(context) const fragment = document.createDocumentFragment() const blocksMap: LookupMap = new Map() const itemsList: LoopItemsList = { - f: undefined + f: undefined, + s: false + } + // The loop owns its rows: they are started and stopped + // in the itemsList order, which mirrors the visual order. + // The start is deferred with the period (effect(ownRows)), the teardown + // is held by an eager effect (effect(holdRows, true)) so it exists even + // when the period is stopped before it ever started + const startRows = () => { + itemsList.s = true + + for (let item = itemsList.f; item !== undefined; item = item.n) { + startScope(item.d) + } + } + const stopRows = () => { + itemsList.s = false + + for (let item = itemsList.f; item !== undefined; item = item.n) { + stopScope(item.d) + } + + blocksMap.clear() + itemsList.f = undefined + } + const ownRows = () => { + untracked(startRows) + } + const holdRows = () => stopRows + // Clear the previous period DOM; its rows are already stopped - + // stopping the period destroyed holdRows, whose teardown ran stopRows + const resetPeriod = (destroyPrev?: DeferredScope) => { + if (destroyPrev !== undefined) { + removeBetween(start, end) + } } let isPlaceholder = false fragment.append(start, end) effectScopeSwapper($items, ( - destroyPrev: Destroy | undefined, - items: unknown[], - prevItems: unknown[] | undefined + destroyPrev: DeferredScope | undefined, + items: unknown[] ) => { const itemsCount = items.length - const prevItemsCount = prevItems?.length - if (itemsCount && prevItemsCount) { + if (itemsCount && destroyPrev !== undefined && !isPlaceholder) { // [...m] -> [...n] - // swap - return deferScope(() => { - batch(() => reconcile( - itemsList, - blocksMap, - $items, - each_, - track, - end, - items - )) - })() - } - - const shouldRender = itemsCount || !isPlaceholder - - if (shouldRender && destroyPrev !== undefined) { - destroyPrev() - removeBetween(start, end) - blocksMap.clear() - itemsList.f = undefined - } + // reconcile within the persistent period under the loop context; + // the context is restored before the trailing flush of the batch + batch(() => unsafeRun( + context, + reconcile, + itemsList, + blocksMap, + $items, + each_, + track, + end, + items + )) + + if (itemsList.s) { + // Rows created by the reconcile start only now, after the removed + // rows were destroyed; startScope is a no-op on the started ones + for (let item = itemsList.f; item !== undefined; item = item.n) { + startScope(item.d) + } + } - let runEffects - - if (itemsCount) { - // [] -> [...n] - isPlaceholder = false - runEffects = deferScope(() => { - reconcile( - itemsList, - blocksMap, - $items, - each_, - track, - end, - items - ) - }) - } else if (!isPlaceholder) { - // ([...n] | []) -> [] - isPlaceholder = true - runEffects = deferScope( - () => insertChildBeforeAnchor(else_?.(), end) - ) + return destroyPrev } - if (shouldRender) { - // swap - if (destroyPrev !== undefined) { - // oxlint-disable-next-line typescript/no-unnecessary-type-assertion - destroyPrev = runEffects!() - } else { - // initial - destroyPrev = runEffects! - } + if (!itemsCount && isPlaceholder) { + // [] -> [] + return destroyPrev! } - return destroyPrev! + isPlaceholder = !itemsCount + + // The previous period is destroyed first, while its DOM is still + // attached; then the body removes it and renders the new content + return periodScope( + itemsCount + ? () => { + resetPeriod(destroyPrev) + effect(holdRows, true) + effect(ownRows) + reconcile( + itemsList, + blocksMap, + $items, + each_, + track, + end, + items + ) + } + : () => { + resetPeriod(destroyPrev) + insertChildBeforeAnchor(else_?.(), end) + }, + destroyPrev + ) }) return fragment diff --git a/packages/nanoviews/src/internals/types/effects.ts b/packages/nanoviews/src/internals/types/effects.ts index 4df6a9fb..de65f9b2 100644 --- a/packages/nanoviews/src/internals/types/effects.ts +++ b/packages/nanoviews/src/internals/types/effects.ts @@ -1,7 +1,6 @@ -import type { Destroy } from 'kida' +import type { DeferredScope } from 'kida' export type EffectScopeSwapperCallback = ( - destroyPrev: Destroy | undefined, - value: T, - prevValue: T | undefined -) => (() => Destroy) | Destroy + destroyPrev: DeferredScope | undefined, + value: T +) => DeferredScope diff --git a/packages/nanoviews/src/mount.ts b/packages/nanoviews/src/mount.ts index bfeae468..b67f7e43 100644 --- a/packages/nanoviews/src/mount.ts +++ b/packages/nanoviews/src/mount.ts @@ -1,4 +1,10 @@ -import { deferScope } from 'kida' +import { + type DeferredScope, + batch, + deferScope, + startScope, + stopScope +} from 'kida' import { type Child, type MaybeDestroy, @@ -18,11 +24,17 @@ export function mount(app: () => Child, target: ParentNode) { target.__mp = true let unmount: MaybeDestroy - const start = deferScope(() => unmount = mountChild(target, app())) - const destroy = start() + let scope!: DeferredScope + + // Batch defers render- and destroy-time signal writes until the phase completes + batch( + () => startScope( + scope = deferScope(() => unmount = mountChild(target, app())) + ) + ) return () => { - destroy() + batch(() => stopScope(scope)) unmount?.() } } diff --git a/packages/store/.size-limit.json b/packages/store/.size-limit.json index a2abdd5f..678034df 100644 --- a/packages/store/.size-limit.json +++ b/packages/store/.size-limit.json @@ -3,18 +3,18 @@ "name": "All publics", "path": "dist/index.js", "import": "*", - "limit": "5.34 kB" + "limit": "5.5 kB" }, { "name": "Signal", "path": "dist/index.js", "import": "{ signal }", - "limit": "1.59 kB" + "limit": "1.62 kB" }, { "name": "Popular set", "path": "dist/index.js", "import": "{ signal, record, computed, effect, mountable, onMount }", - "limit": "2.23 kB" + "limit": "2.26 kB" } ] diff --git a/todo.txt b/todo.txt index 4f4b9b7c..9d2860ac 100644 --- a/todo.txt +++ b/todo.txt @@ -5,8 +5,7 @@ ## Agera/Kida/Store - latest util? computed(latest($searchQuery, $searchParam)) -- lazy scope to start and stop methods? for example for resort in loop -- later: resort effects +- rework signal lifecycle/mounted mechanism holistically (lifecycle.ts): pending mounted queue, activation counts, noMount - current subsCount guard in notifyMounted is a spot fix ## 🔥🔥🔥 Query 🔥🔥🔥 @@ -45,9 +44,10 @@ - hooks/effects naming convention -- check old version -> defer effects wroked bottom->top, now it works top -> bottom -> its ok for stores, not ok for views - wrap into untracked needed parts +- error boundaries: cleanup half-created scope on render throw (stopScope in catch) - rows/content are not parent-linked, teardown must be explicit - for loop array or empty check for else +- for_ crashes on duplicate track keys (pre-existing, loop.ts reconcile matched/stashed) - rework return slot$ to look like element/component? fn.prop is faster than {f,p} - util to transform static props to signal props? - rename decide? export as public - as Dynamic in solidjs