diff --git a/packages/nanoviews/.size-limit.json b/packages/nanoviews/.size-limit.json index 123147ac..13bbffe1 100644 --- a/packages/nanoviews/.size-limit.json +++ b/packages/nanoviews/.size-limit.json @@ -10,7 +10,7 @@ "name": "All publics (Brotli)", "path": "dist/index.js", "import": "*", - "limit": "6.8 kB" + "limit": "6.75 kB" }, { "name": "Average usage (Gzip)", diff --git a/packages/nanoviews/src/flow/for.spec.ts b/packages/nanoviews/src/flow/for.spec.ts index a846485b..5d448e68 100644 --- a/packages/nanoviews/src/flow/for.spec.ts +++ b/packages/nanoviews/src/flow/for.spec.ts @@ -53,7 +53,7 @@ function createRandom(seed: number) { } describe('nanoviews', () => { - describe('logic', () => { + describe('flow', () => { describe('for', () => { it('should handle static array', () => { const { container } = render(StaticValue()) diff --git a/packages/nanoviews/src/flow/if.spec.ts b/packages/nanoviews/src/flow/if.spec.ts index 57db06e5..75087e13 100644 --- a/packages/nanoviews/src/flow/if.spec.ts +++ b/packages/nanoviews/src/flow/if.spec.ts @@ -26,7 +26,7 @@ const { } = composeStories(Stories) describe('nanoviews', () => { - describe('logic', () => { + describe('flow', () => { describe('if', () => { it('should handle static value', () => { const { container } = render(StaticValue()) diff --git a/packages/nanoviews/src/flow/if.ts b/packages/nanoviews/src/flow/if.ts index 587cf8eb..57497077 100644 --- a/packages/nanoviews/src/flow/if.ts +++ b/packages/nanoviews/src/flow/if.ts @@ -2,12 +2,12 @@ import { isAccessor, boolean } from 'kida' -import { - type TruthyValueOrSignal, - type FalsyValueOrSignal, - type Child, - decide +import type { + TruthyValueOrSignal, + FalsyValueOrSignal, + Child } from '../internals/index.js' +import { swap_ } from './swap.js' /** * Decide which child to render based on condition @@ -24,7 +24,7 @@ export function if_($value: T) { return ( then_: (value: TruthyValueOrSignal) => Child, else_?: (value: FalsyValueOrSignal) => Child - ) => decide( + ) => swap_( isAccessor($value) ? boolean($value) : $value as boolean, confition => ( confition diff --git a/packages/nanoviews/src/flow/index.ts b/packages/nanoviews/src/flow/index.ts index ae181205..9242369d 100644 --- a/packages/nanoviews/src/flow/index.ts +++ b/packages/nanoviews/src/flow/index.ts @@ -1,3 +1,4 @@ +export * from './swap.js' export * from './if.js' export * from './switch.js' export * from './for.js' diff --git a/packages/nanoviews/src/flow/swap.spec.ts b/packages/nanoviews/src/flow/swap.spec.ts new file mode 100644 index 00000000..5fa92b52 --- /dev/null +++ b/packages/nanoviews/src/flow/swap.spec.ts @@ -0,0 +1,49 @@ +import { + describe, + it, + expect, + vi +} from 'vitest' +import { render } from '@nanoviews/testing-library' +import { signal } from 'kida' +import { + b, + i +} from '../elements/elements.js' +import { swap_ } from './swap.js' + +describe('nanoviews', () => { + describe('flow', () => { + describe('swap', () => { + it('should render a static value without subscribing', () => { + const render_ = vi.fn((value: string) => b()(value)) + const { container } = render(() => swap_('static', render_)) + + expect(container.innerHTML).toBe('
static
') + expect(render_).toHaveBeenCalledTimes(1) + }) + + it('should build the child anew on every change', () => { + const $tab = signal('list') + const { container } = render(() => swap_( + $tab, + tab => (tab === 'list' ? b()(tab) : i()(tab)) + )) + const [first] = container.getElementsByTagName('b') + + expect(container.innerHTML).toBe('
list
') + + $tab('grid') + + expect(container.innerHTML).toBe('
grid
') + + // the child is rebuilt, not updated: the node the first value made + // is gone rather than reused + $tab('list') + + expect(container.innerHTML).toBe('
list
') + expect(container.getElementsByTagName('b')[0]).not.toBe(first) + }) + }) + }) +}) diff --git a/packages/nanoviews/src/flow/swap.ts b/packages/nanoviews/src/flow/swap.ts new file mode 100644 index 00000000..3f0fd512 --- /dev/null +++ b/packages/nanoviews/src/flow/swap.ts @@ -0,0 +1,26 @@ +import { + type ValueOrAccessor, + isAccessor +} from 'kida' +import { + type Child, + swap +} from '../internals/index.js' + +/** + * Render a child decided by a value. Unlike a binding, which updates content + * in place, the child is built anew every time the value changes. + * @param $value - Static value or store + * @param render - Function that returns child for the value + * @returns Block that renders the child and swaps it on change + */ +export function swap_( + $value: ValueOrAccessor, + render: (value: T) => Child +) { + if (isAccessor($value)) { + return swap($value, render) + } + + return render($value) +} diff --git a/packages/nanoviews/src/flow/switch.spec.ts b/packages/nanoviews/src/flow/switch.spec.ts index 32119379..3fc88d5f 100644 --- a/packages/nanoviews/src/flow/switch.spec.ts +++ b/packages/nanoviews/src/flow/switch.spec.ts @@ -14,7 +14,7 @@ const { } = composeStories(Stories) describe('nanoviews', () => { - describe('logic', () => { + describe('flow', () => { describe('switch', () => { it('should handle static value', () => { const { container } = render(StaticValue()) diff --git a/packages/nanoviews/src/flow/switch.ts b/packages/nanoviews/src/flow/switch.ts index b93a5d7f..ae3635da 100644 --- a/packages/nanoviews/src/flow/switch.ts +++ b/packages/nanoviews/src/flow/switch.ts @@ -2,10 +2,8 @@ import type { MaybeAccessorValue, ValueOrAccessor } from 'kida' -import { - type Child, - decide -} from '../internals/index.js' +import type { Child } from '../internals/index.js' +import { swap_ } from './swap.js' export type SwitchCase = readonly [T | typeof default_, () => Child] @@ -25,7 +23,7 @@ export function switch_($value: ValueOrAccessor) { return (...cases: SwitchCase[]) => { const casesMap = new Map Child>(cases) - return decide($value, value => ( + return swap_($value, value => ( casesMap.has(value) ? casesMap.get(value)!() : casesMap.get(default_)?.() diff --git a/packages/nanoviews/src/internals/flow/decide.ts b/packages/nanoviews/src/internals/flow/decide.ts deleted file mode 100644 index 5a31b09f..00000000 --- a/packages/nanoviews/src/internals/flow/decide.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - type Accessor, - type ValueOrAccessor, - type DeferredScope, - effect, - isAccessor -} from 'kida' -import type { Child } from '../types/index.js' -import { - deferScopeBindContext, - effectScopeSwapper -} from '../effects.js' -import { createTextNode } from '../elements/text.js' -import { - insertChildBeforeAnchor, - removeBetween -} from '../elements/child.js' - -export function reactiveDecide( - $condition: Accessor, - decider: (value: T) => Child -) { - const start = createTextNode() - const end = createTextNode() - const deferScope = deferScopeBindContext() - const fragment = document.createDocumentFragment() - - 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: DeferredScope | undefined, - condition: T - ) => deferScope(() => { - if (destroyPrev !== undefined) { - removeBetween(start, end) - } - - 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 -} - -/** - * Dinamicly decide which child to render based on condition - * @param $condition - Static value or store - * @param decider - Function that returns child based on condition - * @returns Block that renders decided child - */ -export function decide( - $condition: ValueOrAccessor, - decider: (value: T) => Child -) { - if (isAccessor($condition)) { - return reactiveDecide($condition, decider) - } - - return decider($condition) -} diff --git a/packages/nanoviews/src/internals/flow/index.ts b/packages/nanoviews/src/internals/flow/index.ts index 2885e84a..571fca7f 100644 --- a/packages/nanoviews/src/internals/flow/index.ts +++ b/packages/nanoviews/src/internals/flow/index.ts @@ -1,2 +1,2 @@ -export * from './decide.js' +export * from './swap.js' export * from './loop.js' diff --git a/packages/nanoviews/src/internals/flow/decide.spec.ts b/packages/nanoviews/src/internals/flow/swap.spec.ts similarity index 92% rename from packages/nanoviews/src/internals/flow/decide.spec.ts rename to packages/nanoviews/src/internals/flow/swap.spec.ts index 1bc04be8..c38613ac 100644 --- a/packages/nanoviews/src/internals/flow/decide.spec.ts +++ b/packages/nanoviews/src/internals/flow/swap.spec.ts @@ -12,12 +12,12 @@ import { context, inject } from '../../component/context.js' -import { decide } from './decide.js' +import { swap } from './swap.js' describe('nanoviews', () => { describe('internals', () => { - describe('logic', () => { - describe('decide', () => { + describe('flow', () => { + describe('swap', () => { it('should save context', () => { const $value = signal(true) const ThemeContext = () => 'light' @@ -26,7 +26,7 @@ describe('nanoviews', () => { const decider = vi.fn(value => (value ? ComponentA() : ComponentB())) const Swapper = () => context( [provide(ThemeContext, 'dark')], - () => decide($value, decider) + () => swap($value, decider) ) const { container } = render(Swapper) diff --git a/packages/nanoviews/src/internals/flow/swap.ts b/packages/nanoviews/src/internals/flow/swap.ts new file mode 100644 index 00000000..e87e5160 --- /dev/null +++ b/packages/nanoviews/src/internals/flow/swap.ts @@ -0,0 +1,48 @@ +import { + type Accessor, + type DeferredScope, + effect +} from 'kida' +import type { Child } from '../types/index.js' +import { + deferScopeBindContext, + effectScopeSwapper +} from '../effects.js' +import { createTextNode } from '../elements/text.js' +import { + insertChildBeforeAnchor, + removeBetween +} from '../elements/child.js' + +export function swap( + $value: Accessor, + render: (value: T) => Child +) { + const start = createTextNode() + const end = createTextNode() + const deferScope = deferScopeBindContext() + const fragment = document.createDocumentFragment() + + 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($value, ( + destroyPrev: DeferredScope | undefined, + value: T + ) => deferScope(() => { + if (destroyPrev !== undefined) { + removeBetween(start, end) + } + + insertChildBeforeAnchor(render(value), end) + }, destroyPrev)) + + // The echo: content that writes the value 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 value + // and re-queues the parked swapper for the corrective swap + effect(() => void $value(), true) + + return fragment +} diff --git a/todo.txt b/todo.txt index cdb379ac..0c1950d9 100644 --- a/todo.txt +++ b/todo.txt @@ -45,10 +45,9 @@ - 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) +- for_ duplicate track keys: unsupported by design, must keep failing loudly - the crash lands a step away from the cause, so it needs a clear throw at the point of detection - 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 - typed effectattrs - additional arg for record in for$? as$ - controls addEventListener to events system