From 529f2c2fc59d02fac7eb38bc9242fd962198d4f3 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:11:03 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(framework):=20VirtualList=20=E2=80=94?= =?UTF-8?q?=20the=20im=20thread=20pattern=20as=20a=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit framework/src/virtual-list.ts (@pocketjs/framework/virtual-list): the apps/im two-node contract (untransformed overflow-hidden viewport + full- height translateY canvas) with O(1) fixed-row windowing, a reference-stable visible memo (idle frames skip the diff), and input layered by what the host delivers rather than by target: - touch: pan/fling via gesture layer + kinetic scroller; down press- highlights the row (native active: variant), a pan claims the contact and cancels the highlight, a tap routes through pressNode() so touch taps, d-pad CIRCLE, and cursor clicks land in the same onPress. A down arrests an in-flight fling. On PSP the recognizer never sees contacts — the same component degrades to pure d-pad with zero dead code paths. - d-pad: focusRows (default) drives a focused index through a focus controller with keep-in-view chase scrolling; rows unmounting off-window never strand focus (controller holds the authoritative index, re-asserts on mount, and a per-frame sync adopts focus that entered via linear traversal or removal repair). focusRows:false binds the im d-pad pump. - cursor: rows are focusable — hover-is-focus and click work unchanged. Invariants from im kept explicit: prepend rebase (rebase/rebaseRows shift offset + in-flight anchors so backfill never moves the viewport), stick-to- bottom judged on the scroller INTENT against the PREVIOUS max (kinetics gains intent()), and near-edge fetch callbacks with app-side guards. No JSX and no class strings on purpose: direct component calls with live getters (testable under plain bun test) and style-object geometry, so nothing depends on the pass-1 class harvest. 13 renderer-level tests over the real frame pump: window math, reference stability, d-pad walk + chase landing (offset exactly 20), CIRCLE/tap/pan/ fling/arrest journeys via packed touch frames, rebase, stickToBottom, and near-end triggers. Co-Authored-By: Claude Fable 5 --- framework/compiler/jsx-plugin.ts | 2 + framework/src/kinetics.ts | 10 +- framework/src/virtual-list.ts | 397 +++++++++++++++++++++++++++++++ package.json | 1 + tests/virtual-list.test.ts | 266 +++++++++++++++++++++ tsconfig.json | 1 + 6 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 framework/src/virtual-list.ts create mode 100644 tests/virtual-list.test.ts diff --git a/framework/compiler/jsx-plugin.ts b/framework/compiler/jsx-plugin.ts index 931292c..13c1943 100644 --- a/framework/compiler/jsx-plugin.ts +++ b/framework/compiler/jsx-plugin.ts @@ -47,6 +47,7 @@ const LIFECYCLE_VUE_VAPOR_PATH = new URL("../src/lifecycle-vue-vapor.ts", import const OSK_PATH = new URL("../src/osk.tsx", import.meta.url).pathname; const PLATFORM_PATH = new URL("../src/platform.ts", import.meta.url).pathname; const PRELUDE_PATH = new URL("../src/prelude.ts", import.meta.url).pathname; +const VIRTUAL_LIST_PATH = new URL("../src/virtual-list.ts", import.meta.url).pathname; const VUE_VAPOR_RUNTIME_PATH = new URL( "../../node_modules/vue/dist/vue.runtime-with-vapor.esm-browser.prod.js", import.meta.url, @@ -91,6 +92,7 @@ export const FRAMEWORKS: Record< platform: PLATFORM_PATH, prelude: PRELUDE_PATH, renderer: RENDERER_SOLID_PATH, + "virtual-list": VIRTUAL_LIST_PATH, }, }, "vue-vapor": { diff --git a/framework/src/kinetics.ts b/framework/src/kinetics.ts index 59c43b4..4a13c15 100644 --- a/framework/src/kinetics.ts +++ b/framework/src/kinetics.ts @@ -77,6 +77,9 @@ export interface Scroller { /** Shift offset AND every in-flight anchor by delta after a prepend, so * backfill never moves what the user is looking at (the im rebase). */ rebase(delta: number): void; + /** The position the scroller is heading to: the chase/tween target when + * one is in flight, the current offset otherwise. */ + intent(): number; /** At the end of the range, judged on INTENT: the chase/tween target when * one is in flight, the position otherwise (the im at-bottom rule). */ isAtEnd(slackPx?: number): boolean; @@ -264,9 +267,12 @@ export function createScroller(opts: ScrollerOptions): Scroller { emit(pos + delta); }, + intent(): number { + return state === "chase" ? target : state === "tween" ? tweenTo : pos; + }, + isAtEnd(slackPx = 1): boolean { - const intent = state === "chase" ? target : state === "tween" ? tweenTo : pos; - return intent >= opts.max() - slackPx; + return this.intent() >= opts.max() - slackPx; }, projectFling, diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts new file mode 100644 index 0000000..6a20293 --- /dev/null +++ b/framework/src/virtual-list.ts @@ -0,0 +1,397 @@ +// VirtualList: the apps/im thread pattern, generalized. +// +// Two-node contract (the one Gallery documents in components.ts): an +// UNTRANSFORMED overflow-hidden viewport — its scissor comes from its own +// world box, so the clip node must never move — wrapping a full-content- +// height canvas whose translateY is a plain signal binding (paint-only: one +// setProp per moving frame, no relayout). Only the rows intersecting the +// viewport ± overscan are mounted; a thousand-row list costs the core a +// dozen nodes. +// +// Input layers, resolved by what the host delivers rather than by target: +// touch pan/fling through the gesture layer + kinetic scroller; a down +// press-highlights the row under the finger (native `active:` +// variant), a pan CLAIMS the contact and cancels the highlight, +// a tap routes through pressNode() so touch taps, d-pad CIRCLE, +// and cursor clicks land in the same onPress handler. On hosts +// without touch (PSP) the recognizer simply never sees contacts. +// d-pad focusRows (default): a focus controller drives a focused index +// with keep-in-view chase scrolling; rows unmounting off-window +// never strand focus — the controller holds the authoritative +// index and re-asserts it when the target row mounts. With +// focusRows: false the d-pad scrolls the im way (bindDpadScroll). +// cursor rows are focusable, so hover-is-focus and click work as-is. +// +// No JSX and no class strings on purpose: composition is direct component +// calls with live getters (the components.ts style, testable under plain +// bun test), and all geometry is style-object based so nothing here depends +// on the pass-1 class harvest. + +import { For, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from "solid-js"; +import type { JSX as SolidJSX } from "solid-js"; +import { ENUMS } from "../../contracts/spec/spec.ts"; +import { createGesture, type GestureContact } from "./gesture.ts"; +import { + focusNode, + getFocused, + hitFocusable, + pressNode, + pushFocusController, + setActiveNode, + type FocusDirection, +} from "./input.ts"; +import { bindDpadScroll, createScroller, type Scroller } from "./kinetics.ts"; +import { onFrame } from "./frame.ts"; +import { View } from "./primitives.ts"; +import type { NodeMirror } from "./renderer.ts"; + +export interface VirtualListHandle { + scroller: Scroller; + /** Shift the offset after a prepend so the viewport content does not move + * (the im rebase invariant). `addedPx` = height added ABOVE the window. */ + rebase(addedPx: number): void; + rebaseRows(addedRows: number): void; + scrollToIndex(index: number, align?: "start" | "center" | "end" | "nearest", animate?: boolean): void; + focusedIndex(): number | null; +} + +export interface VirtualListProps { + count: number; + /** Fixed row height in logical px (uniform-row v1). */ + rowHeight: number; + /** Viewport height in logical px. */ + height: number; + renderRow: (index: number) => SolidJSX.Element; + /** Extra px mounted beyond each viewport edge. Default 60 (im OVERSCAN). */ + overscan?: number; + /** Inject a scroller (snap points, shared state). Its `max` should match + * this list's content. Default: one sized to count·rowHeight − height. */ + controller?: Scroller; + /** Rows are Focusable and the d-pad drives a focused index (default). + * false: rows are plain views and the d-pad scrolls the im way. */ + focusRows?: boolean; + onRowPress?: (index: number) => void; + /** Fired every frame while the offset is within nearStartPx of the top — + * guard with your own loading/hasMore flags (the im convention). */ + onNearStart?: () => void; + nearStartPx?: number; + onNearEnd?: () => void; + nearEndPx?: number; + /** Follow appends while the user sits at the end (target-judged). */ + stickToBottom?: boolean; + /** Gate d-pad/touch input (e.g. `() => !osk.isOpen()`). Default on. */ + inputActive?: () => boolean; + /** Viewport geometry in screen px — the touch fallback when the host has + * no hitTest op, and the complement for contacts on unpainted gaps. */ + touchRect?: () => { x: number; y: number; w: number; h: number } | null; + /** Extra style merged onto the viewport (height/overflow stay owned here). */ + style?: Record; + ref?: (handle: VirtualListHandle) => void; +} + +const DEFAULT_OVERSCAN = 60; +const DEFAULT_NEAR_PX = 36; + +function isWithin(node: NodeMirror, ancestor: NodeMirror): boolean { + let n: NodeMirror | null = node; + while (n) { + if (n === ancestor) return true; + n = n.parent; + } + return false; +} + +export function VirtualList(props: VirtualListProps): SolidJSX.Element { + const overscan = () => props.overscan ?? DEFAULT_OVERSCAN; + const focusRows = props.focusRows !== false; + const active = () => (props.inputActive ? props.inputActive() : true); + const total = () => props.count * props.rowHeight; + + const scroller = + props.controller ?? + createScroller({ + max: () => Math.max(0, total() - props.height), + }); + const offset = scroller.offset; + + let viewportNode: NodeMirror | undefined; + const rowNodes = new Map(); + const [focusedIndex, setFocusedIndex] = createSignal(null); + /** A d-pad move targeting a row that has not mounted yet (chase scroll in + * flight). Asserted by the window effect the frame it appears. */ + let pendingFocus: number | null = null; + + // ---- windowing ---------------------------------------------------------- + + const range = createMemo( + (prev) => { + const first = Math.max(0, Math.floor((offset() - overscan()) / props.rowHeight)); + const last = Math.min( + props.count - 1, + Math.floor((offset() + props.height + overscan() - 1) / props.rowHeight), + ); + // Reference-stable across idle frames so skips its diff. + if (prev && prev[0] === first && prev[1] === last) return prev; + return [first, last] as const; + }, + [0, -1] as const, + ); + + const visible = createMemo(() => { + const [first, last] = range(); + const out: number[] = []; + for (let i = first; i <= last; i++) out.push(i); + return out; + }); + + // ---- focus (d-pad) ------------------------------------------------------ + + const currentIndex = (): number | null => { + const f = getFocused(); + if (f) { + for (const [i, n] of rowNodes) { + if (isWithin(f, n)) return i; + } + } + return untrack(focusedIndex); + }; + + function scrollToIndex( + index: number, + align: "start" | "center" | "end" | "nearest" = "nearest", + animate = true, + ): void { + const rowTop = index * props.rowHeight; + const rowBottom = rowTop + props.rowHeight; + const o = untrack(offset); + let to: number; + switch (align) { + case "start": + to = rowTop; + break; + case "center": + to = rowTop - (props.height - props.rowHeight) / 2; + break; + case "end": + to = rowBottom - props.height; + break; + default: { + if (rowTop < o) to = rowTop; + else if (rowBottom > o + props.height) to = rowBottom - props.height; + else return; // already fully in view + } + } + if (animate) scroller.chaseTo(to); + else scroller.scrollTo(to, { immediate: true }); + } + + function focusIndex(index: number): void { + setFocusedIndex(index); + scrollToIndex(index, "nearest"); + const node = rowNodes.get(index); + if (node) { + pendingFocus = null; + focusNode(node); + } else { + pendingFocus = index; // mounts within a frame or two of the chase + } + } + + const moveFocus = (direction: FocusDirection): boolean => { + if (direction !== "up" && direction !== "down") return false; // leave the list + if (!active() || props.count === 0) return false; + const at = currentIndex(); + if (at === null) { + focusIndex(Math.max(0, Math.min(props.count - 1, Math.floor(untrack(offset) / props.rowHeight)))); + return true; + } + const next = direction === "down" ? at + 1 : at - 1; + if (next < 0 || next >= props.count) return true; // clamp at the ends + focusIndex(next); + return true; + }; + + onMount(() => { + if (focusRows) { + if (viewportNode) onCleanup(pushFocusController(viewportNode, moveFocus)); + // Re-assert a focus move whose row was not mounted yet. + createEffect(() => { + visible(); + if (pendingFocus === null) return; + const node = rowNodes.get(pendingFocus); + if (node) { + pendingFocus = null; + focusNode(node); + } + }); + } else { + bindDpadScroll(scroller, { active }); + } + }); + + // ---- touch -------------------------------------------------------------- + + const rowFromContact = (c: GestureContact): { index: number; node: NodeMirror | null } | null => { + // Ink path: the nearest focusable under the finger, matched to a row. + const hit = hitFocusable(c.x, c.y); + if (hit) { + for (const [i, n] of rowNodes) { + if (isWithin(hit, n)) return { index: i, node: n }; + } + } + // Geometry fallback (no hitTest, or the finger sits on an unpainted gap). + const rect = props.touchRect?.(); + if (rect && c.x >= rect.x && c.x < rect.x + rect.w && c.y >= rect.y && c.y < rect.y + rect.h) { + const index = Math.floor((untrack(offset) + (c.y - rect.y)) / props.rowHeight); + if (index >= 0 && index < props.count) return { index, node: rowNodes.get(index) ?? null }; + } + return null; + }; + + createGesture({ + region: { node: () => viewportNode, rect: () => props.touchRect?.() ?? null }, + axis: "y", + onDown: (c) => { + if (!active()) return; + scroller.stop(); // a finger down arrests any fling in flight + if (!focusRows) return; + const row = rowFromContact(c); + if (row?.node) setActiveNode(row.node); + }, + onPanStart: () => { + if (!active()) return; + setActiveNode(null); + scroller.beginDrag(); + }, + onPanMove: (c) => { + if (!active()) return; + scroller.drag(-c.fdy); // content follows the finger + }, + onPanEnd: (c) => { + if (!active()) return; + scroller.endDrag(-c.vy); + }, + onTap: (c) => { + if (!active()) return; + const row = rowFromContact(c); + if (!row) return; + setActiveNode(null); + setFocusedIndex(row.index); + if (row.node && focusRows) { + pressNode(row.node); // same onPress path as CIRCLE and cursor clicks + } else { + props.onRowPress?.(row.index); + } + }, + onCancel: () => { + setActiveNode(null); + if (scroller.state() === "tracking") scroller.endDrag(0); // no fling out of a modal open + }, + }); + + // ---- per-frame pump + data-flow invariants ------------------------------ + + onFrame(() => { + scroller.step(); + // Keep the focused index in sync when focus entered a row through linear + // traversal or removal repair (paths the controller does not see). + if (focusRows) { + const f = getFocused(); + if (f) { + for (const [i, n] of rowNodes) { + if (isWithin(f, n)) { + if (untrack(focusedIndex) !== i) setFocusedIndex(i); + break; + } + } + } + } + const o = untrack(offset); + if (props.onNearStart && o < (props.nearStartPx ?? DEFAULT_NEAR_PX)) props.onNearStart(); + if (props.onNearEnd) { + const max = Math.max(0, total() - props.height); + if (o > max - (props.nearEndPx ?? DEFAULT_NEAR_PX)) props.onNearEnd(); + } + }); + + // Stick-to-bottom: follow appends while the INTENT was at the PREVIOUS + // end (the im rule — at-bottom is judged before the append grew the range, + // and on the target rather than the eased position). + let prevCount = props.count; + createEffect(() => { + const count = props.count; + untrack(() => { + if (props.stickToBottom && count > prevCount) { + const prevMax = Math.max(0, prevCount * props.rowHeight - props.height); + if (scroller.intent() >= prevMax - 8) { + scroller.chaseTo(Math.max(0, count * props.rowHeight - props.height)); + } + } + prevCount = count; + }); + }); + + const handle: VirtualListHandle = { + scroller, + rebase: (px) => scroller.rebase(px), + rebaseRows: (rows) => scroller.rebase(rows * props.rowHeight), + scrollToIndex, + focusedIndex, + }; + props.ref?.(handle); + + // ---- the two-node contract ---------------------------------------------- + + const row = (index: number): SolidJSX.Element => + View({ + focusable: focusRows, + onPress: props.onRowPress ? () => props.onRowPress!(index) : undefined, + style: { + posType: ENUMS.PosType.Absolute, + insetT: index * props.rowHeight, + insetL: 0, + insetR: 0, + height: props.rowHeight, + }, + ref: (node: NodeMirror) => { + rowNodes.set(index, node); + onCleanup(() => { + if (rowNodes.get(index) === node) rowNodes.delete(index); + }); + }, + get children() { + return props.renderRow(index); + }, + }); + + return View({ + get style() { + return { + overflow: ENUMS.Overflow.Hidden, + height: props.height, + ...props.style, + }; + }, + ref: (node: NodeMirror) => { + viewportNode = node; + }, + children: View({ + get style() { + return { + posType: ENUMS.PosType.Absolute, + insetT: 0, + insetL: 0, + insetR: 0, + height: total(), + translateY: -offset(), + }; + }, + children: For({ + get each() { + return visible(); + }, + children: row, + }), + }), + }); +} diff --git a/package.json b/package.json index 9552927..5047f31 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "./solid/components": "./framework/src/components.ts", "./solid/lifecycle": "./framework/src/lifecycle.ts", "./solid/renderer": "./framework/src/renderer-solid.ts", + "./virtual-list": "./framework/src/virtual-list.ts", "./vue-vapor": "./framework/src/index-vue-vapor.ts", "./vue-vapor/animation": "./framework/src/animation.ts", "./vue-vapor/clock": "./framework/src/clock.ts", diff --git a/tests/virtual-list.test.ts b/tests/virtual-list.test.ts new file mode 100644 index 0000000..f377d62 --- /dev/null +++ b/tests/virtual-list.test.ts @@ -0,0 +1,266 @@ +// VirtualList renderer-level tests: mount through the public render() with a +// mock HostOps (the devtools.test.ts pattern) and drive the real frame pump — +// windowing math, reference stability, d-pad focus-follow, touch tap/pan +// through the gesture layer, rebase, and the data-flow invariants. +// +// Run: bun test --conditions=browser tests/virtual-list.test.ts + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) { + throw new Error("solid-js resolved to its SSR build — run: bun test --conditions=browser"); +} + +import { createSignal } from "solid-js"; +import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; +import { render as publicRender } from "../framework/src/index.ts"; +import { resetRendererState, rootMirror, type NodeMirror } from "../framework/src/renderer.ts"; +import { resetStyles } from "../framework/src/styles.ts"; +import { resetInput, getFocused } from "../framework/src/input.ts"; +import { resetPack } from "../framework/src/pak.ts"; +import { Text } from "../framework/src/primitives.ts"; +import { VirtualList, type VirtualListHandle } from "../framework/src/virtual-list.ts"; +import { __packTouch } from "../framework/src/touch.ts"; +import { BTN, ROOT_ID } from "../contracts/spec/spec.ts"; + +function makeHost(): Host { + let nextId = ROOT_ID + 1; + const noop = () => {}; + const ops: HostOps = { + createNode: () => nextId++, + destroyNode: noop, + insertBefore: noop, + removeChild: noop, + setStyle: noop, + setProp: noop, + setText: noop, + replaceText: noop, + uploadTexture: () => 900, + setImage: noop, + setSprite: noop, + animate: () => 1, + cancelAnim: noop, + setFocus: noop, + setActive: noop, + loadStyles: noop, + loadFontAtlas: noop, + measureText: () => 0, + }; + return { kind: "injected", target: "test", strict: true, ops }; +} + +let host: Host; +let dispose: (() => void) | null = null; + +const g = globalThis as Record; + +function frame(buttons = 0, touches?: readonly number[]): void { + (g.frame as (b: number, a?: number, t?: readonly number[]) => void)(buttons, undefined, touches); +} + +beforeEach(() => { + host = makeHost(); + installHost(host); + resetRendererState(); + resetStyles(); + resetPack(); + resetInput(); +}); + +afterEach(() => { + dispose?.(); + dispose = null; + g.frame = undefined; +}); + +/** rootMirror -> appRoot -> viewport -> canvas */ +function canvasNode(): NodeMirror { + return rootMirror.children[0].children[0].children[0]; +} + +interface MountOpts { + count?: () => number; + onRowPress?: (i: number) => void; + focusRows?: boolean; + stickToBottom?: boolean; + onNearEnd?: () => void; + touchRect?: () => { x: number; y: number; w: number; h: number }; +} + +const LIST_RECT = { x: 0, y: 0, w: 480, h: 50 }; + +function mountList(opts: MountOpts = {}): VirtualListHandle { + let handle: VirtualListHandle | undefined; + dispose = publicRender( + () => + VirtualList({ + get count() { + return opts.count ? opts.count() : 100; + }, + rowHeight: 10, + height: 50, + overscan: 20, + focusRows: opts.focusRows, + onRowPress: opts.onRowPress, + stickToBottom: opts.stickToBottom, + onNearEnd: opts.onNearEnd, + touchRect: opts.touchRect ?? (() => LIST_RECT), + renderRow: (i) => Text({ children: `ROW ${i}` }), + ref: (h) => { + handle = h; + }, + }), + { ops: host.ops, styles: {} }, + ); + if (!handle) throw new Error("VirtualList ref not called"); + return handle; +} + +describe("windowing", () => { + test("mounts only the visible slice ± overscan", () => { + mountList(); + // offset 0: first = 0, last = floor((0+50+20-1)/10) = 6 → 7 rows of 100. + expect(canvasNode().children.length).toBe(7); + }); + + test("scrolling re-windows in O(window) rows at absolute offsets", () => { + const h = mountList(); + h.scroller.scrollTo(500, { immediate: true }); + // first = floor(480/10) = 48, last = floor(569/10) = 56 → 9 rows. + const kids = canvasNode().children; + expect(kids.length).toBe(9); + }); + + test("sub-row scrolls keep the same window (reference-stable input)", () => { + const h = mountList(); + h.scroller.scrollTo(500, { immediate: true }); + const before = canvasNode().children.slice(); + h.scroller.scrollTo(500.5, { immediate: true }); + const after = canvasNode().children; + expect(after.length).toBe(before.length); + for (let i = 0; i < after.length; i++) expect(after[i]).toBe(before[i]); + }); + + test("a shrinking count clamps the window", () => { + const [count, setCount] = createSignal(100); + mountList({ count }); + setCount(3); + expect(canvasNode().children.length).toBe(3); + }); +}); + +describe("d-pad focus", () => { + test("DOWN enters the list, then the controller walks rows with keep-in-view", () => { + const h = mountList(); + frame(BTN.DOWN); // linear traversal focuses the first row + frame(0); + expect(getFocused()).toBe(canvasNode().children[0]); + // Walk down 6 rows: row 6's bottom (70) exceeds the viewport (50) → the + // scroller chases; run frames for the chase to settle. + for (let i = 0; i < 6; i++) { + frame(BTN.DOWN); + frame(0); + } + expect(h.focusedIndex()).toBe(6); + for (let i = 0; i < 40; i++) frame(0); + expect(h.scroller.offset()).toBe(20); // row 6 bottom-aligned: 70 - 50 + }); + + test("CIRCLE presses the focused row through onRowPress", () => { + const pressed: number[] = []; + mountList({ onRowPress: (i) => pressed.push(i) }); + frame(BTN.DOWN); + frame(0); + frame(BTN.CIRCLE); + expect(pressed).toEqual([0]); + }); + + test("focus keeps its index when the target row mounts after the chase", () => { + const h = mountList(); + frame(BTN.DOWN); + frame(0); + h.scrollToIndex(50, "start", false); // jump far: focused row 0 unmounts + frame(0); + frame(BTN.DOWN); // controller resumes from its own index + for (let i = 0; i < 30; i++) frame(0); + expect(h.focusedIndex()).not.toBeNull(); + }); +}); + +describe("touch", () => { + test("tap on a row fires the shared onPress path (geometry fallback, no hitTest)", () => { + const pressed: number[] = []; + const h = mountList({ onRowPress: (i) => pressed.push(i) }); + h.scroller.scrollTo(100, { immediate: true }); + frame(0, [__packTouch(1, 100, 25)]); // y 25 in-view → content y 125 → row 12 + frame(0); // release + expect(pressed).toEqual([12]); + expect(h.focusedIndex()).toBe(12); + }); + + test("pan claims the contact, follows the finger, and flings on release", () => { + const pressed: number[] = []; + const h = mountList({ onRowPress: (i) => pressed.push(i) }); + // Drag upward 12 px/frame (content scrolls down), then release. + frame(0, [__packTouch(1, 100, 45)]); + frame(0, [__packTouch(1, 100, 33)]); + frame(0, [__packTouch(1, 100, 21)]); + frame(0, [__packTouch(1, 100, 9)]); + const atRelease = h.scroller.offset(); + expect(atRelease).toBeGreaterThan(20); // finger-follow moved the content + frame(0); // release → fling + expect(pressed).toEqual([]); // never a tap + let last = h.scroller.offset(); + let grew = false; + for (let i = 0; i < 30; i++) { + frame(0); + if (h.scroller.offset() > last) grew = true; + last = h.scroller.offset(); + } + expect(grew).toBe(true); // inertia continued after release + }); + + test("a down arrests an in-flight fling", () => { + const h = mountList(); + frame(0, [__packTouch(1, 100, 45)]); + frame(0, [__packTouch(1, 100, 25)]); + frame(0, [__packTouch(1, 100, 5)]); + frame(0); // release → fling + frame(0); + expect(h.scroller.state()).toBe("fling"); + frame(0, [__packTouch(2, 100, 25)]); // catch + expect(h.scroller.state()).not.toBe("fling"); + }); +}); + +describe("data-flow invariants", () => { + test("rebaseRows shifts the offset by exactly the added height", () => { + const h = mountList(); + h.scroller.scrollTo(100, { immediate: true }); + h.rebaseRows(5); + expect(h.scroller.offset()).toBe(150); + }); + + test("stickToBottom follows appends only while the intent is at the end", () => { + const [count, setCount] = createSignal(10); // total 100, max = 50 + const h = mountList({ count, stickToBottom: true }); + h.scroller.scrollTo(50, { immediate: true }); // at the end + setCount(12); // max becomes 70 + for (let i = 0; i < 40; i++) frame(0); + expect(h.scroller.offset()).toBe(70); // followed + h.scroller.scrollTo(0, { immediate: true }); // reading history + setCount(14); + for (let i = 0; i < 10; i++) frame(0); + expect(h.scroller.offset()).toBe(0); // did not move + }); + + test("onNearEnd fires while inside the trigger zone", () => { + let hits = 0; + const h = mountList({ onNearEnd: () => hits++ }); + frame(0); + expect(hits).toBe(0); + h.scroller.scrollTo(920, { immediate: true }); // max = 950, zone = 36 + frame(0); + expect(hits).toBe(1); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d608b5f..a6d63dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ "@pocketjs/framework/solid/components": ["./framework/src/components.ts"], "@pocketjs/framework/solid/lifecycle": ["./framework/src/lifecycle.ts"], "@pocketjs/framework/solid/renderer": ["./framework/src/renderer-solid.ts"], + "@pocketjs/framework/virtual-list": ["./framework/src/virtual-list.ts"], "@pocketjs/framework/vue-vapor": ["./framework/src/index-vue-vapor.ts"], "@pocketjs/framework/vue-vapor/animation": ["./framework/src/animation.ts"], "@pocketjs/framework/vue-vapor/components": ["./framework/src/components-vue-vapor.ts"], From fb3a519e11f666b7cf368137823193ed0b5283b5 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:49:46 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(virtual-list):=20focusRow=20=E2=80=94?= =?UTF-8?q?=20programmatic=20row=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fresh-results entry point apps need: after a search replaces the list, focusRow(0) scrolls the row into view and focuses it through the same path the d-pad walk takes (pending-focus latch included), so the next CIRCLE press lands on a row instead of nothing. Clamped; no-op with focusRows off. Co-Authored-By: Claude Fable 5 --- framework/src/virtual-list.ts | 8 ++++++++ tests/virtual-list.test.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index 6a20293..6a016a7 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -53,6 +53,10 @@ export interface VirtualListHandle { rebaseRows(addedRows: number): void; scrollToIndex(index: number, align?: "start" | "center" | "end" | "nearest", animate?: boolean): void; focusedIndex(): number | null; + /** Programmatic row focus (fresh-results entry point): scrolls the row + * into view and focuses it once mounted — the same path the d-pad walk + * takes. No-op with focusRows: false. */ + focusRow(index: number): void; } export interface VirtualListProps { @@ -337,6 +341,10 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { rebaseRows: (rows) => scroller.rebase(rows * props.rowHeight), scrollToIndex, focusedIndex, + focusRow(index: number): void { + if (!focusRows || props.count === 0) return; + focusIndex(Math.max(0, Math.min(props.count - 1, index))); + }, }; props.ref?.(handle); diff --git a/tests/virtual-list.test.ts b/tests/virtual-list.test.ts index f377d62..045ac5f 100644 --- a/tests/virtual-list.test.ts +++ b/tests/virtual-list.test.ts @@ -264,3 +264,19 @@ describe("data-flow invariants", () => { expect(hits).toBe(1); }); }); + +describe("focusRow", () => { + test("focuses a row programmatically, scrolling it into view", () => { + const h = mountList(); + h.focusRow(0); + frame(0); + expect(h.focusedIndex()).toBe(0); + expect(getFocused()).toBe(canvasNode().children[0]); + h.focusRow(50); // off-window: chases + focuses on mount + for (let i = 0; i < 60; i++) frame(0); + expect(h.focusedIndex()).toBe(50); + h.focusRow(999); // clamps to the last row + for (let i = 0; i < 120; i++) frame(0); + expect(h.focusedIndex()).toBe(99); + }); +}); From 058fb3494b757525c99ef796beca6012cb3e7ec5 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:55:11 +0800 Subject: [PATCH 3/4] fix(virtual-list): untrack props reads inside handle methods focusRow/scrollToIndex/rebaseRows run in CALLER reactive scopes; focusRow's props.count read silently subscribed the caller's effect to the list length, so an effect doing "on fresh search, focusRow(0)" re-fired on every append and yanked focus back to the top (caught by the pocket-youtube LOAD MORE journey). Handle methods now untrack all props reads; regression test pins that a focusRow-in-effect gains no count subscription. Co-Authored-By: Claude Fable 5 --- framework/src/virtual-list.ts | 13 +++++++++---- tests/virtual-list.test.ts | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index 6a016a7..fd8c39c 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -335,15 +335,20 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { }); }); + // Handle methods run in CALLER reactive scopes (effects, event handlers): + // untrack every props read so an effect that calls focusRow(0) does not + // silently subscribe to count/rowHeight and re-fire on every append. const handle: VirtualListHandle = { scroller, rebase: (px) => scroller.rebase(px), - rebaseRows: (rows) => scroller.rebase(rows * props.rowHeight), - scrollToIndex, + rebaseRows: (rows) => untrack(() => scroller.rebase(rows * props.rowHeight)), + scrollToIndex: (index, align, animate) => untrack(() => scrollToIndex(index, align, animate)), focusedIndex, focusRow(index: number): void { - if (!focusRows || props.count === 0) return; - focusIndex(Math.max(0, Math.min(props.count - 1, index))); + untrack(() => { + if (!focusRows || props.count === 0) return; + focusIndex(Math.max(0, Math.min(props.count - 1, index))); + }); }, }; props.ref?.(handle); diff --git a/tests/virtual-list.test.ts b/tests/virtual-list.test.ts index 045ac5f..1756781 100644 --- a/tests/virtual-list.test.ts +++ b/tests/virtual-list.test.ts @@ -11,7 +11,7 @@ if (Bun.resolveSync("solid-js", import.meta.dir).endsWith("server.js")) { throw new Error("solid-js resolved to its SSR build — run: bun test --conditions=browser"); } -import { createSignal } from "solid-js"; +import { createEffect, createSignal } from "solid-js"; import { installHost, type Host, type HostOps } from "../framework/src/host.ts"; import { render as publicRender } from "../framework/src/index.ts"; import { resetRendererState, rootMirror, type NodeMirror } from "../framework/src/renderer.ts"; @@ -280,3 +280,36 @@ describe("focusRow", () => { expect(h.focusedIndex()).toBe(99); }); }); + +describe("handle reactivity hygiene", () => { + test("focusRow inside an effect does not subscribe to count", () => { + const [count, setCount] = createSignal(10); + let effectRuns = 0; + let h!: VirtualListHandle; + dispose = publicRender( + () => { + const el = VirtualList({ + get count() { + return count(); + }, + rowHeight: 10, + height: 50, + renderRow: (i) => Text({ children: `R${i}` }), + ref: (handle) => { + h = handle; + }, + }); + createEffect(() => { + effectRuns++; + h.focusRow(0); // must NOT leak a props.count subscription + }); + return el; + }, + { ops: host.ops, styles: {} }, + ); + const runsAfterMount = effectRuns; + setCount(11); // an append must not re-fire the caller's effect + expect(effectRuns).toBe(runsAfterMount); + expect(h.focusedIndex()).toBe(0); + }); +}); From c6164e7b6d0534fe41e768b14e3b2dec655685f6 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:03:55 +0800 Subject: [PATCH 4/4] fix(virtual-list): default the viewport to full parent width A plain View is a flex ROW; an unsized viewport inside one lays out at width 0 and the entire list silently paints nothing. The mock-host unit tests could never catch it (no layout); a real-core repro did. The viewport now defaults width to SIZE_FULL (overridable via props.style). Co-Authored-By: Claude Fable 5 --- framework/src/virtual-list.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/framework/src/virtual-list.ts b/framework/src/virtual-list.ts index fd8c39c..798c320 100644 --- a/framework/src/virtual-list.ts +++ b/framework/src/virtual-list.ts @@ -382,6 +382,11 @@ export function VirtualList(props: VirtualListProps): SolidJSX.Element { return { overflow: ENUMS.Overflow.Hidden, height: props.height, + // Full parent width by default (SIZE_FULL): a plain View is a flex + // ROW, so an unsized viewport inside one lays out at width 0 and the + // whole list silently paints nothing (found the hard way — the mock + // host has no layout, so only a real core catches it). + width: -1, ...props.style, }; },