diff --git a/src/primitives/Lazy.tsx b/src/primitives/Lazy.tsx index d61a3d01..c8314dc4 100644 --- a/src/primitives/Lazy.tsx +++ b/src/primitives/Lazy.tsx @@ -144,11 +144,17 @@ function createLazy( const updateOffset = (_event: KeyboardEvent, container: lng.ElementNode) => { const maxOffset = props.each ? props.each.length : 0; const selected = container.selected || 0; - const rendered = offset(); // == container.children.length - - // Already mounted everything, or still far enough from the rendered edge - // that the buffer covers the next selection — no work to do. - if (rendered >= maxOffset || selected < rendered - buffer()) return; + // The rendered edge is the actual child count, not offset(): the two + // diverge when an item renders empty (no node inserted) or renders + // multiple top-level nodes. `selected` indexes into children, so the + // proximity check must use child units. + const rendered = container.children.length; + + // Already mounted everything (offset is in data units — a diverged child + // count must neither stop mounting early nor keep it running forever), + // or still far enough from the rendered edge that the buffer covers the + // next selection — no work to do. + if (offset() >= maxOffset || selected < rendered - buffer()) return; const bump = () => setOffset((prev) => Math.min(prev + 1, maxOffset)); diff --git a/src/primitives/VirtualGrid.tsx b/src/primitives/VirtualGrid.tsx index 800fab1a..779b3771 100644 --- a/src/primitives/VirtualGrid.tsx +++ b/src/primitives/VirtualGrid.tsx @@ -3,8 +3,13 @@ import * as lng from '@solidtv/solid'; import * as lngp from '@solidtv/solid/primitives'; import { List } from '@solid-primitives/list'; import * as utils from '../utils.js'; +// Imported directly, not via lngp: this call runs at module init, and the +// barrel's withScrolling binding is not yet initialized when the barrel is +// the import entry (circular init — index.ts re-exports withScrolling after +// VirtualGrid). +import { withScrolling } from './utils/withScrolling.js'; -const columnScroll = lngp.withScrolling(false); +const columnScroll = withScrolling(false); const rowStyles: lng.NodeStyles = { display: 'flex', diff --git a/tests/lazy.test.tsx b/tests/lazy.test.tsx new file mode 100644 index 00000000..7d17be03 --- /dev/null +++ b/tests/lazy.test.tsx @@ -0,0 +1,89 @@ +import * as v from 'vitest'; +import * as lng from '@solidtv/solid'; +import { LazyColumn } from '../src/primitives/Lazy.jsx'; +import { renderer } from './setup.js'; + +const wait = (ms = 10) => new Promise((r) => setTimeout(r, ms)); + +// updateOffset runs before moveSelection in the chained onDown handler, the +// same order propagateKeyPress invokes it with (handler.call(elm, e, elm)). +const pressDown = (col: lng.ElementNode) => + (col as any).onDown.call(col, { key: 'ArrowDown' } as KeyboardEvent, col); + +v.describe('Lazy components with empty content', () => { + v.test('keeps mounting past items that render empty (#26)', async () => { + let col!: lng.ElementNode; + // Odd items render nothing: 10 data items, 5 rendered children total. + const each = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + const dispose = renderer.render(() => ( + + + {(item) => ( + + + + )} + + + )); + await wait(); + + col.setFocus(); + await wait(); + + // Walk to the end of the list. 10 data items and one mount per press — + // a dozen presses is more than enough when the guard terminates + // correctly, and few enough to finish fast if it doesn't. + for (let i = 0; i < 12; i++) { + pressDown(col); + await wait(1); + } + + v.assert.equal( + col.children.length, + 5, + 'all non-empty items mounted despite empty siblings', + ); + v.assert.equal(col.selected, 4, 'selection reached the last child'); + + dispose(); + }); + + v.test('multi-node item templates do not stall mounting early', async () => { + let col!: lng.ElementNode; + // Each item renders two top-level nodes, so children.length runs at + // twice the item count and reaches each.length before all items mount. + const each = [0, 1, 2, 3, 4, 5]; + + const dispose = renderer.render(() => ( + + + {() => ( + <> + + + + )} + + + )); + await wait(); + + col.setFocus(); + await wait(); + + for (let i = 0; i < 16; i++) { + pressDown(col); + await wait(1); + } + + v.assert.equal( + col.children.length, + 12, + 'every item mounted even though children outnumber data items', + ); + + dispose(); + }); +});