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('')
+
+ // the row of id 1 now sits last, so its write must land there
+ rows[0]({
+ id: 1,
+ name: 'Satanic'
+ })
+
+ expect(container.innerHTML).toBe('')
+ 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('')
+ 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('')
+ })
+
+ 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('')
+
+ items([1, 2])
+
+ expect(container.innerHTML).toBe('')
+ 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('')
+ 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('')
+
+ // 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('')
+
+ items([first, second])
+
+ expect(container.innerHTML).toBe('')
+ 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('')
+ 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('')
+ 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('')
+ 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('')
+ })
+
+ 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('')
+ })
+
+ 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('')
+ 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('')
+ 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('')
+ 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('')
+ }
+
+ 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
}