diff --git a/packages/nanoviews/src/flow/if.spec.ts b/packages/nanoviews/src/flow/if.spec.ts index 2acbfb16..57db06e5 100644 --- a/packages/nanoviews/src/flow/if.spec.ts +++ b/packages/nanoviews/src/flow/if.spec.ts @@ -9,8 +9,13 @@ import { render } from '@nanoviews/testing-library' import { type WritableSignal, type ReadableSignal, - signal + signal, + effect } from 'kida' +import { + b, + i +} from '../elements/elements.js' import * as Stories from './if.stories.js' import { if_ } from './if.js' @@ -55,6 +60,100 @@ describe('nanoviews', () => { expect(container.innerHTML).toBe('
') }) + it('should render a write to the condition made by the branch it selected', () => { + const $open = signal(false) + const $allowed = signal(false) + const $text = signal('closed') + const { container } = render(() => if_($open)( + () => { + // the branch refuses to be shown, so the write reaches the + // condition from an effect the swap itself started + effect(() => { + if (!$allowed()) { + $open(false) + } + }) + + return b()('open') + }, + () => i()($text) + )) + + $open(true) + + expect(container.innerHTML).toBe('
closed
') + + // the branch the write brought back is live, not merely rendered + $text('shut') + + expect(container.innerHTML).toBe('
shut
') + }) + + it('should render a write to the condition made while the branch renders', () => { + const $open = signal(false) + const $tick = signal(0) + const runs: number[] = [] + const { container } = render(() => if_($open)( + () => { + $open(false) + + return b()('open') + }, + () => { + // an effect of the branch the write brought back: unlike a + // binding it runs only if that branch was started + effect(() => { + runs.push($tick()) + }) + + return i()('closed') + } + )) + + $open(true) + + expect(container.innerHTML).toBe('
closed
') + expect(runs).toEqual([0, 0]) + + $tick(1) + + expect(runs).toEqual([0, 0, 1]) + }) + + it('should start the branch brought up by a write made on mount', () => { + const $open = signal(true) + const $allowed = signal(false) + const $tick = signal(0) + const runs: number[] = [] + const { container } = render(() => if_($open)( + () => { + effect(() => { + if (!$allowed()) { + $open(false) + } + }) + + return b()('open') + }, + () => { + // an effect of the branch the mount-time write brought up: + // unlike a binding it runs only if that branch was started + effect(() => { + runs.push($tick()) + }) + + return i()('closed') + } + )) + + expect(container.innerHTML).toBe('
closed
') + expect(runs).toEqual([0]) + + $tick(1) + + expect(runs).toEqual([0, 1]) + }) + it('should keep signal type in branches', () => { const $value = signal('truthy') diff --git a/packages/nanoviews/src/internals/effects.ts b/packages/nanoviews/src/internals/effects.ts index 6152683b..3d50bf63 100644 --- a/packages/nanoviews/src/internals/effects.ts +++ b/packages/nanoviews/src/internals/effects.ts @@ -13,8 +13,9 @@ import type { EffectScopeSwapperCallback } from './types/index.js' export function deferScopeBindContext(context = getContext()) { 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)) + // Render under the injection context; starting the scope is not the + // renderer's move + return (fn: () => void, replace?: DeferredScope): DeferredScope => unsafeRun(context, factory, fn, replace) } export function effectScopeSwapper( @@ -23,9 +24,14 @@ export function effectScopeSwapper( ) { let prev: DeferredScope | undefined + // The swap starts what it returns. Before the mount walk the scope's + // anchor is still lazy and the start is a no-op, so the walk keeps the + // block's position; after it the swapped-in content comes up on the + // swap's own stack, so a swap correcting a write made mid-render leaves + // no scope behind that is rendered but never started effect(() => { const value = $signal() - prev = untracked(() => callback(prev, value)) + prev = untracked(() => startScope(callback(prev, value))) }, true) } diff --git a/packages/nanoviews/src/internals/flow/decide.ts b/packages/nanoviews/src/internals/flow/decide.ts index 8285be5b..5a31b09f 100644 --- a/packages/nanoviews/src/internals/flow/decide.ts +++ b/packages/nanoviews/src/internals/flow/decide.ts @@ -2,6 +2,7 @@ import { type Accessor, type ValueOrAccessor, type DeferredScope, + effect, isAccessor } from 'kida' import type { Child } from '../types/index.js' @@ -39,6 +40,12 @@ export function reactiveDecide( insertChildBeforeAnchor(decider(condition), end) }, destroyPrev)) + // The echo: a branch that writes the condition back does it from inside + // the running swapper, which cannot be re-queued by its own propagation. + // This second subscriber is idle at that moment, so its read settles the + // condition and re-queues the parked swapper for the corrective swap + effect(() => void $condition(), true) + return fragment }