diff --git a/src/primitives/Lazy.tsx b/src/primitives/Lazy.tsx index 0fcedc4..f76bdd1 100644 --- a/src/primitives/Lazy.tsx +++ b/src/primitives/Lazy.tsx @@ -4,15 +4,51 @@ import * as s from 'solid-js'; type LazyProps = lng.NewOmit & { each: T | undefined | null | false; + + /** Initial visible item count before user navigation. */ upCount: number; + + /** + * Items to keep rendered ahead of the current selection. When selection + * moves within this distance of the rendered edge, the next item is + * scheduled to mount. Default depends on the scroll mode. + */ buffer?: number; + + /** + * Milliseconds to wait after a navigation key press before mounting the + * next item. Should match the scroll animation duration — mounting items + * mid-animation causes jank. If the user presses again before this timer + * fires (faster than the animation), an item is added synchronously to + * keep the rendered window ahead of selection. + */ delay?: number; + + /** Render `upCount` items synchronously on mount instead of ramping up. */ sync?: boolean; + + /** Continue mounting items in the background past `upCount`. */ eagerLoad?: boolean; + + /** Skip refocusing the container when `each.length` changes. */ noRefocus?: boolean; + children: (item: s.Accessor, index: number) => s.JSX.Element; }; +// Lifecycle when props.each changes: +// 1. items memo invalidates → reconciles → new ElementNodes mounted (sync) +// 2. refocus effect runs (microtask) → calls viewRef.setFocus() +// 3. setFocus queues runPostMutation +// 4. post-mutation: delete-flush → layout → setActiveElement +// Children are always mounted before focus is applied; do not wrap setFocus +// in queueMicrotask — it only adds a redundant defer. +// +// Navigation key handler (updateOffset): +// - On each onRight/onDown, if selection is within `buffer` of the rendered +// edge, mount one more item. With `delay` set, the mount is deferred until +// the scroll animation completes so it doesn't drop a frame mid-animation. +// If the user out-paces `delay`, the mount fires synchronously instead. function createLazy( component: s.ValidComponent, props: LazyProps, @@ -20,9 +56,17 @@ function createLazy( ) { // Need at least one item so it can be focused const [offset, setOffset] = s.createSignal(props.sync ? props.upCount : 0); - let timeoutId: ReturnType | null = null; + let preloadTimer: ReturnType | null = null; + let navDelayTimer: ReturnType | null = null; + let disposed = false; let viewRef!: lngp.NavigableElement; - let itemLength: number = 0; + let itemLength = 0; + + s.onCleanup(() => { + disposed = true; + if (preloadTimer) clearTimeout(preloadTimer); + if (navDelayTimer) clearTimeout(navDelayTimer); + }); const buffer = s.createMemo(() => { if (typeof props.buffer === 'number') { @@ -44,39 +88,54 @@ function createLazy( if (!props.sync || props.eagerLoad) { s.createEffect(() => { - if (props.each) { - const loadItems = () => { - let count = s.untrack(offset); - if (count < props.upCount) { - setOffset(count + 1); - timeoutId = setTimeout(loadItems, 16); // ~60fps - count++; - } else if (props.eagerLoad) { - const maxOffset = props.each ? props.each.length : 0; - if (count >= maxOffset) return; - setOffset((prev) => Math.min(prev + 1, maxOffset)); - lng.scheduleTask(loadItems); - } - }; - loadItems(); + if (!props.each) return; + // Cancel any in-flight preload chain from a prior effect run before + // starting a new one — otherwise two chains share state and race. + if (preloadTimer) { + clearTimeout(preloadTimer); + preloadTimer = null; } + const loadItems = () => { + if (disposed) return; + const count = s.untrack(offset); + if (count < props.upCount) { + setOffset(count + 1); + preloadTimer = setTimeout(loadItems, 16); // ~60fps + } else if (props.eagerLoad) { + const maxOffset = props.each ? props.each.length : 0; + if (count >= maxOffset) return; + setOffset((prev) => Math.min(prev + 1, maxOffset)); + lng.scheduleTask(loadItems); + } + }; + loadItems(); }); } - const items: s.Accessor = s.createMemo(() => { - if (Array.isArray(props.each)) { - if (itemLength != props.each.length) { - itemLength = props.each.length; - if (viewRef && !viewRef.noRefocus && lng.hasFocus(viewRef)) { - queueMicrotask(() => viewRef.setFocus()); + // Refocus when each.length changes. Side effect kept out of the items memo + // (memos must be pure — Solid may skip evaluation when there are no readers). + s.createEffect(() => { + if (!Array.isArray(props.each)) { + itemLength = 0; + return; + } + const len = props.each.length; + if (itemLength !== len) { + itemLength = len; + if (viewRef && !viewRef.noRefocus && lng.hasFocus(viewRef)) { + // Clamp selected so a shrunk list doesn't refocus a disposed index. + if (typeof viewRef.selected === 'number' && viewRef.selected >= len) { + viewRef.selected = Math.max(0, len - 1); } + viewRef.setFocus(); } - return props.each.slice(0, offset()); } - itemLength = 0; - return []; }); + const items: s.Accessor = s.createMemo(() => + Array.isArray(props.each) ? props.each.slice(0, offset()) : [], + ); + function lazyScrollToIndex(this: lngp.NavigableElement, index: number) { setOffset(Math.max(index, 0) + buffer()) queueMicrotask(() => viewRef.scrollToIndex(index)); @@ -85,24 +144,34 @@ function createLazy( const updateOffset = (_event: KeyboardEvent, container: lng.ElementNode) => { const maxOffset = props.each ? props.each.length : 0; const selected = container.selected || 0; - const numChildren = container.children.length; - if (offset() >= maxOffset || selected < numChildren - buffer()) return; + 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; + + const bump = () => setOffset((prev) => Math.min(prev + 1, maxOffset)); + // No animation to hide behind — mount immediately. if (!props.delay) { - setOffset((prev) => Math.min(prev + 1, maxOffset)); + bump(); return; } - if (timeoutId) { - clearTimeout(timeoutId); - //Moving faster than the delay so need to go sync - setOffset((prev) => Math.min(prev + 1, maxOffset)); + // A delayed mount from the previous press hasn't fired yet, which means + // the user is navigating faster than the scroll animation. Drop the wait + // (we can't smooth a frame the user is already past) and mount now. + if (navDelayTimer) { + clearTimeout(navDelayTimer); + bump(); } - timeoutId = setTimeout(() => { - setOffset((prev) => Math.min(prev + 1, maxOffset)); - timeoutId = null; - }, props.delay ?? 0); + // Schedule the trailing mount to land after the scroll animation completes, + // so a single item mount doesn't jank the in-flight animation. + navDelayTimer = setTimeout(() => { + bump(); + navDelayTimer = null; + }, props.delay); }; const handler = keyHandler(updateOffset);