From e6d1775346baf24fd79ed4bee28b40ddd7e7d775 Mon Sep 17 00:00:00 2001 From: dangreen Date: Wed, 19 Aug 2026 16:27:04 +0400 Subject: [PATCH] perf(nanoviews): wake only the rows whose value changed The rows of a loop shared one subscription to the items array through `atIndex`, so a write to any element woke every row on screen. A row now owns its value signal and the reconcile pushes into it: a write reaches only the rows whose value actually moved, and the face in front of the row carries a write back to its own place in the array, found by the row's key and index instead of by a search. A read-only array costs no face at all. The reconcile walks the list with counters and pointers instead of building `matched` and `stashed` arrays for every run, deletes a removed suffix in one crossing rather than item by item, and rewinds only within a budget of one pass. Every row now holds at least one DOM node, so a row that rendered nothing still has a place to be moved to and inserted before, which takes the emptiness guard out of `move`. All four `nanoviews` size pins come down. It also closes a hole the rows made reachable. Content may write the very signal its block swaps on - a row that normalises the value it was handed, a branch that refuses to be shown. The write is made while the swap runs, and a running effect cannot be re-queued by its own propagation, so the block kept content that was already contradicted and the corrective content was rendered but never started. Waking it takes a second reader that is idle at that moment: the loop starts its rows from an effect of its own over the same array the swap reads, which serves as that reader and also keeps the rows off the swap's own stack, and `decide` carries one on the condition. The `batch` around the reconcile goes with them - the swap already runs from the flush, so the batch added nothing but a trailing flush of its own, and that flush drained the queue onto the one stack where the write is lost. --- packages/nanoviews/.size-limit.json | 8 +- packages/nanoviews/src/flow/for.spec.ts | 806 +++++++++++++++++- packages/nanoviews/src/flow/if.spec.ts | 101 ++- .../nanoviews/src/internals/flow/decide.ts | 7 + packages/nanoviews/src/internals/flow/loop.ts | 452 +++++----- 5 files changed, 1177 insertions(+), 197 deletions(-) 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('
  • Yatoro
  • Larl
') + + names[1]('Collapse') + + expect(container.innerHTML).toBe('
  • Yatoro
  • Collapse
') + 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 }