diff --git a/packages/nanoviews/.size-limit.json b/packages/nanoviews/.size-limit.json index b72bfdca..123147ac 100644 --- a/packages/nanoviews/.size-limit.json +++ b/packages/nanoviews/.size-limit.json @@ -4,25 +4,25 @@ "gzip": true, "path": "dist/index.js", "import": "*", - "limit": "7.7 kB" + "limit": "7.6 kB" }, { "name": "All publics (Brotli)", "path": "dist/index.js", "import": "*", - "limit": "6.85 kB" + "limit": "6.8 kB" }, { "name": "Average usage (Gzip)", "gzip": true, "path": "dist/index.js", "import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }", - "limit": "4.4 kB" + "limit": "4.25 kB" }, { "name": "Average usage (Brotli)", "path": "dist/index.js", "import": "{ fragment, div, form, input, button, label, classList$, if_, for_, value$, $$children, effect }", - "limit": "4.05 kB" + "limit": "3.95 kB" } ] diff --git a/packages/nanoviews/src/flow/for.spec.ts b/packages/nanoviews/src/flow/for.spec.ts index 8484e69a..a846485b 100644 --- a/packages/nanoviews/src/flow/for.spec.ts +++ b/packages/nanoviews/src/flow/for.spec.ts @@ -9,7 +9,24 @@ import { render, screen } from '@nanoviews/testing-library' -import { signal } from 'kida' +import { + type WritableSignal, + signal, + computed, + effect, + untracked, + isWritable, + record +} from 'kida' +import { + ul, + li +} from '../elements/elements.js' +import { fragment } from '../elements/fragment.js' +import { + trackById, + for_ +} from './for.js' import * as Stories from './for.stories.js' const { @@ -18,6 +35,23 @@ const { EntitiesValue } = composeStories(Stories) +interface Player { + id: number + name: string +} + +function createPlayer(id: number): Player { + return { + id, + name: String(id) + } +} + +// Deterministic, so a failure prints a seed and a step that reproduce it +function createRandom(seed: number) { + return () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff +} + describe('nanoviews', () => { describe('logic', () => { describe('for', () => { @@ -276,6 +310,776 @@ describe('nanoviews', () => { expect(screen.getByText('Rue')).toBe(listItems[3]) expect(screen.getByText('Miposhka')).toBe(listItems[4]) }) + + it('should insert a node before a row that rendered nothing', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 3, + name: null + }, + { + id: 4, + name: 'Larl' + } + ]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + return $name() ? li()($name) : null + } + ) + )) + + expect(container.innerHTML).toBe('
') + + items([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Collapse' + }, + { + id: 3, + name: null + }, + { + id: 4, + name: 'Larl' + } + ]) + + expect(container.innerHTML).toBe('
') + }) + + it('should move a multi node row across a row that rendered nothing', () => { + const items = signal([ + { + id: 1, + name: null + }, + { + id: 2, + name: 'Larl' + }, + { + id: 3, + name: 'Yatoro' + } + ]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + return $name() ? fragment(li()($name), li()('*')) : null + } + ) + )) + + expect(container.innerHTML).toBe('
') + + items([ + { + id: 3, + name: 'Yatoro' + }, + { + id: 1, + name: null + }, + { + id: 2, + name: 'Larl' + } + ]) + + expect(container.innerHTML).toBe('
') + }) + + it('should write a row back into the items array', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + } + ]) + const names: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + names.push($name) + + return li()($name) + } + ) + )) + + expect(container.innerHTML).toBe('
') + + names[1]('Collapse') + + expect(container.innerHTML).toBe('
') + expect(items()).toEqual([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Collapse' + } + ]) + }) + + it('should write a row back at its current index after a reorder', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + }, + { + id: 3, + name: 'Collapse' + } + ]) + const rows: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + rows.push(item as WritableSignal) + + return li()(record(item).$name) + } + ) + )) + + items([ + { + id: 3, + name: 'Collapse' + }, + { + id: 2, + name: 'Larl' + }, + { + id: 1, + name: 'Yatoro' + } + ]) + + expect(container.innerHTML).toBe('
  • Collapse
  • Larl
  • Yatoro
') + + // the row of id 1 now sits last, so its write must land there + rows[0]({ + id: 1, + name: 'Satanic' + }) + + expect(container.innerHTML).toBe('
  • Collapse
  • Larl
  • Satanic
') + expect(items().map(({ id }) => id)).toEqual([3, 2, 1]) + expect(items()[2].name).toBe('Satanic') + }) + + it('should keep a read-only items array untouched when a row is written', () => { + const source = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + } + ]) + const items = computed(() => source()) + const rows: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + rows.push(item as WritableSignal) + + return li()(record(item).$name) + } + ) + )) + + rows[1]({ + id: 2, + name: 'Collapse' + }) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Collapse
') + expect(source()[1].name).toBe('Larl') + + // the next update from the source replaces the local row value + source([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + } + ]) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Larl
') + }) + + it('should hand out a read-only row for a read-only items array', () => { + const source = signal([ + { + id: 1, + name: 'Yatoro' + } + ]) + const items = computed(() => source()) + let writable = true + + render(() => ul()( + for_(items, trackById)( + (item) => { + writable = isWritable(item) + + return li()(record(item).$name) + } + ) + )) + + expect(writable).toBe(false) + }) + + it('should remove a run of rows from the end', () => { + const items = signal([1, 2, 3, 4, 5]) + const destroyed: number[] = [] + const { container } = render(() => ul()( + for_(items, id => id)( + (item) => { + const id = item() + + effect(() => () => destroyed.push(id)) + + return li()(() => String(item())) + } + ) + )) + + expect(container.innerHTML).toBe('
  • 1
  • 2
  • 3
  • 4
  • 5
') + + items([1, 2]) + + expect(container.innerHTML).toBe('
  • 1
  • 2
') + expect(destroyed).toEqual([3, 4, 5]) + }) + + it('should remove a row from the middle', () => { + const items = signal([1, 2, 3, 4, 5]) + const { container } = render(() => ul()( + for_(items, id => id)( + item => li()(() => String(item())) + ) + )) + const kept = [screen.getByText('1'), screen.getByText('5')] + + items([1, 2, 4, 5]) + + expect(container.innerHTML).toBe('
  • 1
  • 2
  • 4
  • 5
') + expect(screen.getByText('1')).toBe(kept[0]) + expect(screen.getByText('5')).toBe(kept[1]) + }) + + it('should reverse a long list without recreating nodes', () => { + const source = Array.from( + { + length: 30 + }, + (_, i) => i + 1 + ) + const items = signal(source) + const { container } = render(() => ul()( + for_(items, id => id)( + item => li()(() => String(item())) + ) + )) + const nodes = source.map(id => screen.getByText(String(id))) + + items([...source].reverse()) + + expect(container.innerHTML).toBe(`
    ${[...source].reverse().map(id => `
  • ${id}
  • `).join('')}
`) + + source.forEach((id, index) => { + expect(screen.getByText(String(id))).toBe(nodes[index]) + }) + }) + + it('should store a function row value instead of calling it', () => { + let calls = 0 + const first = () => { + calls++ + + return 'first' + } + const second = () => { + calls++ + + return 'second' + } + const items = signal([first, second]) + const { container } = render(() => ul()( + for_(items, (_, index) => index)( + item => li()(() => (item() === first ? 'Yatoro' : 'Larl')) + ) + )) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Larl
') + + // the reconcile pushes the new value into the row, and a value that + // happens to be a function must be stored, not invoked as a reducer + items([second, first]) + + expect(container.innerHTML).toBe('
  • Larl
  • Yatoro
') + + items([first, second]) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Larl
') + expect(calls).toBe(0) + }) + + it('should drop a write from a row removed out of the middle', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + }, + { + id: 3, + name: 'Collapse' + } + ]) + const rows: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + rows.push(item as WritableSignal) + + return li()(record(item).$name) + } + ) + )) + + items([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 3, + name: 'Collapse' + } + ]) + + // whatever the removed row still holds - a debounce, a response - + // fires only now, and must not land anywhere + rows[1]({ + id: 2, + name: 'Miposhka' + }) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Collapse
') + expect(items()).toEqual([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 3, + name: 'Collapse' + } + ]) + }) + + it('should drop a write from a row removed off the end', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + }, + { + id: 3, + name: 'Collapse' + } + ]) + const rows: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + rows.push(item as WritableSignal) + + return li()(record(item).$name) + } + ) + )) + + items([ + { + id: 1, + name: 'Yatoro' + } + ]) + + rows[2]({ + id: 3, + name: 'Miposhka' + }) + + expect(container.innerHTML).toBe('
  • Yatoro
') + expect(items()).toEqual([ + { + id: 1, + name: 'Yatoro' + } + ]) + }) + + it('should drop a write from a row whose key was reused by a new row', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + } + ]) + const rows: WritableSignal[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + rows.push(item as WritableSignal) + + return li()(record(item).$name) + } + ) + )) + + // the list empties, so every row is torn down at once + items([]) + // and the same key comes back on a row that is not the same row + items([ + { + id: 1, + name: 'Larl' + } + ]) + + rows[0]({ + id: 1, + name: 'Miposhka' + }) + + expect(container.innerHTML).toBe('
  • Larl
') + expect(items()).toEqual([ + { + id: 1, + name: 'Larl' + } + ]) + }) + + it('should render a write made by a row created during an update', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + } + ]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + // the row normalises its own value, so the write reaches the + // items array from a row effect the update itself started + effect(() => { + const name = $name() + + if (name !== name.trim()) { + $name(name.trim()) + } + }) + + return li()($name) + } + ) + )) + + items([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: ' Larl ' + } + ]) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Larl
') + }) + + it('should render a write made by a row that survived an update', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + } + ]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + effect(() => { + const name = $name() + + if (name !== name.trim()) { + $name(name.trim()) + } + }) + + return li()($name) + } + ) + )) + + // the row is not created here - the update only rewrites its value, + // and the effect that answers runs on the same reconcile + items([ + { + id: 1, + name: ' Larl ' + } + ]) + + expect(container.innerHTML).toBe('
  • Larl
') + }) + + it('should render a write made by a row while it renders', () => { + const items = signal([ + { + id: 1, + name: 'Yatoro' + } + ]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + const name = untracked($name) + + // the row normalises what it was handed from its own body, so + // the write reaches the array from inside the update's render + if (name !== name.trim()) { + $name(name.trim()) + } + + return li()($name) + } + ) + )) + + items([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: ' Larl ' + } + ]) + + expect(container.innerHTML).toBe('
  • Yatoro
  • Larl
') + expect(untracked(items)).toEqual([ + { + id: 1, + name: 'Yatoro' + }, + { + id: 2, + name: 'Larl' + } + ]) + }) + + it('should render a write made by a row the placeholder gave way to', () => { + const items = signal([]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { $name } = record(item) + + effect(() => { + const name = $name() + + if (name !== name.trim()) { + $name(name.trim()) + } + }) + + return li()($name) + }, + () => li()('nobody') + ) + )) + + // the row is born on the swap out of the placeholder, not on a + // reconcile: a different path into the same running swapper + items([ + { + id: 1, + name: ' Larl ' + } + ]) + + expect(container.innerHTML).toBe('
  • Larl
') + expect(untracked(items)).toEqual([ + { + id: 1, + name: 'Larl' + } + ]) + }) + + it('should keep the placeholder across a write that leaves the array empty', () => { + const items = signal([]) + const runs: number[] = [] + const { container } = render(() => ul()( + for_(items, trackById)( + item => li()(record(item).$name), + () => { + // the placeholder is rendered once: a write that leaves the + // array empty must not tear it down and build it again + effect(() => { + runs.push(runs.length) + }) + + return li()('nobody') + } + ) + )) + + expect(runs).toEqual([0]) + + items([]) + + expect(container.innerHTML).toBe('
  • nobody
') + expect(runs).toEqual([0]) + }) + + describe('fuzz', () => { + // A named test pins a shape someone thought of; these walk sequences + // nobody did. Both invariants are checked after every step: the rows + // stand in the array's order, and every row that is on screen has had + // its effect run - a row rendered but never started keeps the right + // DOM and silently answers nothing + it.each([1, 7, 42, 1234])( + 'should render and start every row, seed %i', + (seed) => { + const steps = 150 + const random = createRandom(seed) + const pick = (n: number) => Math.floor(random() * n) + const started = new Set() + const items = signal([]) + const { container } = render(() => ul()( + for_(items, trackById)( + (item) => { + const { id } = untracked(item) + + effect(() => { + started.add(id) + }) + + return li()(String(id)) + }, + () => li()('nobody') + ) + )) + let model: Player[] = [] + let nextId = 0 + + for (let step = 0; step < steps; step++) { + const next = model.slice() + const operation = pick(6) + + if (operation === 0) { + next.splice(pick(next.length + 1), 0, createPlayer(nextId++)) + } else if (operation === 1) { + const at = pick(next.length + 1) + + for (let count = 1 + pick(3); count--;) { + next.splice(at, 0, createPlayer(nextId++)) + } + } else if (operation === 2 && next.length) { + next.splice(pick(next.length), 1 + pick(3)) + } else if (operation === 3 && next.length > 1) { + const run = next.splice(pick(next.length), 1 + pick(3)) + + next.splice(pick(next.length + 1), 0, ...run) + } else if (operation === 4 && next.length > 1) { + next.reverse() + } else { + if (next.length) { + next.splice(pick(next.length), 1 + pick(2)) + } + + next.splice(pick(next.length + 1), 0, createPlayer(nextId++)) + } + + model = next + items(model.map(player => ({ + ...player + }))) + + const where = `seed ${seed}, step ${step}` + const ids = model.map(player => player.id) + + if (ids.length) { + expect([...container.querySelectorAll('li')].map(node => Number(node.textContent)), where).toEqual(ids) + } else { + expect(container.innerHTML, where).toBe('
  • nobody
') + } + + expect(ids.filter(id => !started.has(id)), where).toEqual([]) + } + } + ) + }) }) }) }) 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/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 } diff --git a/packages/nanoviews/src/internals/flow/loop.ts b/packages/nanoviews/src/internals/flow/loop.ts index ae42ef54..f8eb26e9 100644 --- a/packages/nanoviews/src/internals/flow/loop.ts +++ b/packages/nanoviews/src/internals/flow/loop.ts @@ -2,7 +2,10 @@ import { type ReadableSignal, type Accessor, type WritableSignal, + type NewValue, type DeferredScope, + NoneFlag, + WritableMode, signal, effect, deferScope, @@ -11,13 +14,12 @@ import { getContext, unsafeRun, untracked, - atIndex, - batch + createSignal, + isWritable, + nextValue, + assignIndex } from 'kida' -import type { - Child, - EmptyValue -} from '../types/index.js' +import type { Child } from '../types/index.js' import { deferScopeBindContext, effectScopeSwapper @@ -29,20 +31,60 @@ import { removeBetween } from '../elements/child.js' -interface LoopItem { +// The list is a chain of items in visual order headed by the list itself, so +// a splice is always the same two writes with no head to special case +interface LoopLink { + /** + * Next item. + */ + n: LoopItem | undefined +} + +// The item is the row: the face handed to `each_` is bound to it, so the +// write back into the array finds its place with the key and the tracker +// straight off the item and the value signal keeps the shape `signal` gave it +interface LoopItem extends LoopLink { + /** + * Tracking key. + */ k: unknown + /** + * Index signal. + */ i: WritableSignal - f: ChildNode | EmptyValue - l: ChildNode | EmptyValue - n: LoopItem | undefined - p: LoopItem | undefined + /** + * Value signal - what the reconcile writes. + */ + v: WritableSignal + /** + * The items array. + */ + a: WritableSignal | undefined + /** + * First and last DOM node of the row. + */ + f: ChildNode + l: ChildNode + /** + * Previous item. + */ + p: LoopLink + /** + * Deferred scope of the row. + */ d: DeferredScope + /** + * Writability of the face. + */ + modes: number } -interface LoopItemsList { - f: LoopItem | undefined - s: boolean - c: LoopItem[] | undefined +interface LoopItemsList extends LoopLink { + /** + * First row a reconcile created that still has to be started. Everything + * it made is at or after this one, so the start walks from here. + */ + c: LoopItem | undefined } type LookupMap = Map @@ -54,48 +96,53 @@ type AnyEach = ( type UnknownTrack = (item: unknown, index: number) => unknown -function getAnchor( - item: LoopItem | undefined, - fallback: ChildNode -) { - return item?.f ?? fallback -} - -function link( - itemsList: LoopItemsList, - prev: LoopItem | undefined, - next: LoopItem | undefined, - insert?: LoopItem -): void { - if (prev === undefined) { - itemsList.f = insert ?? next +// The row owns its value: the reconcile pushes it in, so a write to the +// items array wakes only the rows whose value actually changed. The face in +// front of the item carries the write back to the array, and the raw signal +// under `v` is what the reconcile writes +function rowOper(this: LoopItem, ...value: [NewValue]) { + if (value.length) { + const $items = this.a + + // A destroyed row keeps no array to write into: its index means nothing + // any more, and the position it used to name may already belong to a row + // created after it died + if ($items !== undefined) { + let items!: unknown[] + let index!: number + + untracked(() => { + items = $items() + index = this.i() + }) + + $items(assignIndex(items, index, nextValue(items[index], value[0]))) + } } else { - prev.n = insert ?? next + return this.v() } +} + +function link(prev: LoopLink, next: LoopItem | undefined): void { + prev.n = next if (next !== undefined) { - next.p = insert ?? prev + next.p = prev } } -function move( - item: LoopItem, - anchorItem: LoopItem | undefined, - fallback: ChildNode -) { - if (item.f) { - const anchor = getAnchor(anchorItem, fallback) - const nextStart = item.l!.nextSibling! - let node = item.f +// Every row holds at least one node, so the range is never empty +function move(item: LoopItem, anchor: ChildNode) { + const nextStart = item.l.nextSibling + let node: ChildNode = item.f - while (node !== nextStart) { - const next = node.nextSibling! + do { + const next = node.nextSibling! - anchor.before(node) + anchor.before(node) - node = next - } - } + node = next + } while (node !== nextStart) } // oxlint-disable-next-line eslint/max-params @@ -109,11 +156,28 @@ function reconcile( nextItems: unknown[] ) { const { length } = nextItems + // The cursor stands at `prev.n` the whole way: an item is placed by + // splicing it in there, a skipped one is stashed and stepped over + let prev: LoopLink = itemsList + let current = itemsList.n let seen: Set | undefined - let matched: LoopItem[] = [] - let stashed: LoopItem[] = [] - let prev: LoopItem | undefined - let current = itemsList.f + // The stash is the run of `stashed` items at `start`, the matched ones the + // run of `matched` items at `first` right behind it + let start!: LoopItem + let first!: LoopItem + let stashed = 0 + let matched = 0 + // Rewinding to the stash re-walks it, so the walks stay linear in total + // only while what they rewind over fits one pass over the list + let budget = length + // A read-only items array has nothing to write back to, so its rows are + // the bare value signal and cost no face - one question for the whole pass + const writable = isWritable($items) + // A write to a signal is a reducer when it is a function, so the value the + // reconcile pushes into a row travels through this slot: a row whose value + // is a function is stored, not called - and one slot serves the whole pass + let rawValue: unknown + const raw = () => rawValue for (let i = 0, value: unknown, key: unknown, item: LoopItem | undefined; i < length; i++) { value = nextItems[i] @@ -121,30 +185,53 @@ function reconcile( item = lookupMap.get(key) if (item === undefined) { - item = createEachBlock($items, each_, key, i, getAnchor(current, anchor)) - item.p = prev - item.n = prev === undefined ? itemsList.f : prev.n + // A row is born here whole: its two signals, the face over the item + // when the array can take writes back, and the deferred scope whose + // body renders it and lands its DOM range on the item itself + const $index = signal(i) + const $value = signal(value) + const insertAnchor = current !== undefined ? current.f : anchor + const row = item = { + k: key, + i: $index, + v: $value, + a: $items, + f: undefined, + l: undefined, + n: undefined, + p: undefined, + d: undefined, + modes: WritableMode + } as unknown as LoopItem + let $row: Accessor = $value + + if (writable) { + $row = createSignal(rowOper, row as never) as Accessor + } else { + // A read-only items array has nothing to write back to, so the row is + // the bare value signal - and it must not answer that it is writable, + // or a child of it would be handed a setter that writes nowhere + $value.node.modes = NoneFlag + } - lookupMap.set(key, item) + row.d = deferScope(() => { + insertChildBeforeAnchor(each_($row, $index), insertAnchor, row) - // Only a started loop has rows to start, and only the rows created - // right here need it - the surviving ones are already started - if (itemsList.s) { - (itemsList.c ??= []).push(item) - } + // Every row holds a place in the DOM, so a row that rendered nothing + // still has one to be moved to, inserted before and removed with + if (!row.f) { + insertAnchor.before(row.f = row.l = createTextNode()) + } + }) - link( - itemsList, - prev, - item.n, - item - ) + lookupMap.set(key, item) + itemsList.c ??= item - matched = [] - stashed = [] + link(item, current) + link(prev, item) + matched = stashed = 0 prev = item - current = item.n continue } @@ -152,58 +239,64 @@ function reconcile( item.i(i) } + if (item.v.node.pendingValue !== value) { + rawValue = value + item.v(raw) + } + if (item !== current) { if (seen !== undefined && seen.has(item)) { - if (matched.length < stashed.length) { - const [start] = stashed - let j - - prev = start.p - - const [a] = matched - const b = matched[matched.length - 1] - - for (j = 0; j < matched.length; j++) { - move(matched[j], start, anchor) + // Fewer items were matched than stashed, so carrying the matched run + // back in front of the stash beats carrying the stash out one by one + // - as long as re-walking the stash is still within budget + if (matched < stashed && (budget -= stashed) > 0) { + const last = prev as LoopItem + let node = first + + for (let j = stashed, s = start; j--; s = s.n!) { + seen.delete(s) } - for (j = 0; j < stashed.length; j++) { - seen.delete(stashed[j]) + for (let j = matched; j--; node = node.n!) { + move(node, start.f) } - link(itemsList, a.p, b.n) - link(itemsList, prev, a) - link(itemsList, b, start) + link(first.p, last.n) + link(start.p, first) + link(last, start) current = start - prev = b + prev = last i -= 1 + matched = stashed = 0 + continue + } - matched = [] - stashed = [] - } else { - seen.delete(item) - move(item, current, anchor) - - link(itemsList, item.p, item.n) - link(itemsList, item, prev === undefined ? itemsList.f : prev.n) - link(itemsList, prev, item) + seen.delete(item) + move(item, current !== undefined ? current.f : anchor) - prev = item - } + link(item.p, item.n) + link(item, current) + link(prev, item) + prev = item continue } - matched = [] - stashed = [] + matched = stashed = 0 + start = current! while (current !== undefined && current.k !== key) { (seen ??= new Set()).add(current) - stashed.push(current) + stashed++ current = current.n } + // The key is neither ahead of the cursor nor stashed, so it was placed + // already: the same key twice in one list. The lookup holds one row per + // key and cannot place it twice, so the pass walks on with a stash it + // will not match and throws a step later - a loud failure, and not a + // list silently rendered one row short if (current === undefined) { continue } @@ -211,65 +304,40 @@ function reconcile( item = current } - // `matched` is only read from the `seen` branch, and every path that - // defines `seen` resets it first - if (seen !== undefined) { - matched.push(item) + if (seen !== undefined && !matched++) { + first = item } prev = item current = item.n } - if (current !== undefined || seen !== undefined) { - if (seen !== undefined) { - seen.forEach(block => destroyLoopItem(itemsList, block, lookupMap)) - } - - while (current !== undefined) { - prev = current - current = current.n - destroyLoopItem(itemsList, prev, lookupMap) + if (seen !== undefined) { + for (const item of seen) { + item.a = undefined + stopScope(item.d) + remove(item.f, item.l) + lookupMap.delete(item.k) + link(item.p, item.n) } } -} -function destroyLoopItem(itemsList: LoopItemsList, item: LoopItem, lookupMap: LookupMap) { - stopScope(item.d) + // Whatever the cursor did not reach is a suffix of the list, and the list + // is the visual order: one splice cuts it off, one crossing deletes it + if (current !== undefined) { + const from = current.f - if (item.f) { - remove(item.f, item.l!) - } + prev.n = undefined - lookupMap.delete(item.k) - link(itemsList, item.p, item.n) -} + do { + current.a = undefined + stopScope(current.d) + lookupMap.delete(current.k) + current = current.n + } while (current !== undefined) -function createEachBlock( - $items: Accessor, - each_: AnyEach, - key: unknown, - i: number, - anchor: ChildNode -): LoopItem { - const $index = signal(i) - const item = { - k: key, - i: $index, - f: undefined, - l: undefined, - n: undefined, - p: undefined, - d: undefined as DeferredScope | undefined + remove(from, anchor.previousSibling!) } - - item.d = deferScope(() => insertChildBeforeAnchor( - each_(atIndex($items, $index), $index), - anchor, - item - )) - - return item as LoopItem } export function loop( @@ -285,42 +353,35 @@ export function loop( const fragment = document.createDocumentFragment() const blocksMap: LookupMap = new Map() const itemsList: LoopItemsList = { - f: undefined, - s: false, + n: undefined, c: undefined } - // 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 + // The loop owns its rows: they are started and stopped in the itemsList + // order, which mirrors the visual order. The start is an effect of its own + // over the same array the swap reads, and that second reader is what makes + // a row's write back into the array land: the write is made while the swap + // runs, and a running effect cannot be re-queued by its own propagation, so + // it takes an idle subscriber to settle the array and re-queue the parked + // swap. Starting from here also keeps the rows a reconcile made off the + // swap's own stack. The teardown is held by an eager effect in the period + // body, 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) { + // Only a reconcile that made a row has anything to start, and never + // anything in front of the first row it made + for (let item = itemsList.c; item !== undefined; item = item.n) { startScope(item.d) } + + itemsList.c = undefined } const stopRows = () => { - itemsList.s = false - - for (let item = itemsList.f; item !== undefined; item = item.n) { + for (let item = itemsList.n; item !== undefined; item = item.n) { + item.a = undefined 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) - } + itemsList.n = itemsList.c = undefined } let isPlaceholder = false @@ -334,9 +395,12 @@ export function loop( if (itemsCount && destroyPrev !== undefined && !isPlaceholder) { // [...m] -> [...n] - // reconcile within the persistent period under the loop context; - // the context is restored before the trailing flush of the batch - batch(() => unsafeRun( + // Reconcile within the persistent period under the loop context. The + // swap runs from the flush and from nowhere else, so the writes below + // are already deferred: a batch here would add nothing but its own + // trailing flush, and that flush would drain the queue onto the swap's + // own stack - the one place a write back into the array is lost + unsafeRun( context, reconcile, itemsList, @@ -346,19 +410,7 @@ export function loop( track, end, items - )) - - const created = itemsList.c - - // The rows the reconcile created start only now, after the removed - // ones were destroyed - if (created !== undefined) { - itemsList.c = undefined - - for (let i = 0, len = created.length; i < len; i++) { - startScope(created[i].d) - } - } + ) return destroyPrev } @@ -375,9 +427,14 @@ export function loop( return periodScope( itemsCount ? () => { - resetPeriod(destroyPrev) - effect(holdRows, true) - effect(ownRows) + // Clear the previous period DOM; its rows are already stopped - + // stopping the period destroyed holdRows, whose teardown ran + // stopRows + if (destroyPrev !== undefined) { + removeBetween(start, end) + } + + effect(() => stopRows, true) reconcile( itemsList, blocksMap, @@ -389,12 +446,25 @@ export function loop( ) } : () => { - resetPeriod(destroyPrev) + if (destroyPrev !== undefined) { + removeBetween(start, end) + } + insertChildBeforeAnchor(else_?.(), end) }, destroyPrev ) }) + // The start effect keeps the loop's own position among the siblings, so + // the rows still come up before the effects of whatever holds them. It + // subscribes after the swap on purpose: the array notifies its readers in + // subscription order, and the rows have to be there before anything starts + // them + periodScope(() => effect(() => { + $items() + startRows() + })) + return fragment }