Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion packages/nanoviews/src/flow/if.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -55,6 +60,100 @@ describe('nanoviews', () => {
expect(container.innerHTML).toBe('<div></div>')
})

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('<div><i>closed</i></div>')

// the branch the write brought back is live, not merely rendered
$text('shut')

expect(container.innerHTML).toBe('<div><i>shut</i></div>')
})

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('<div><i>closed</i></div>')
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('<div><i>closed</i></div>')
expect(runs).toEqual([0])

$tick(1)

expect(runs).toEqual([0, 1])
})

it('should keep signal type in branches', () => {
const $value = signal<string | null>('truthy')

Expand Down
12 changes: 9 additions & 3 deletions packages/nanoviews/src/internals/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
Expand All @@ -23,9 +24,14 @@ export function effectScopeSwapper<T>(
) {
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)
}
7 changes: 7 additions & 0 deletions packages/nanoviews/src/internals/flow/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type Accessor,
type ValueOrAccessor,
type DeferredScope,
effect,
isAccessor
} from 'kida'
import type { Child } from '../types/index.js'
Expand Down Expand Up @@ -39,6 +40,12 @@ export function reactiveDecide<T>(
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
}

Expand Down