Skip to content

feat(framework): VirtualList — the im thread pattern as a component#165

Draft
doodlewind wants to merge 4 commits into
feat/tape-touch-trackfrom
feat/virtual-list
Draft

feat(framework): VirtualList — the im thread pattern as a component#165
doodlewind wants to merge 4 commits into
feat/tape-touch-trackfrom
feat/virtual-list

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

Part 4/6 of the touch-input series (stacked on #164; uses the gesture layer from #161 and the scroller from #163).

What

@pocketjs/framework/virtual-list — generalizes apps/im/thread.tsx:

  • Two-node contract preserved verbatim: untransformed overflow-hidden viewport (scissor from its own world box) + full-content-height canvas bound to translateY: -offset() (paint-only). O(1) windowing for fixed row heights, reference-stable visible memo so idle frames skip the <For> diff. A thousand-row list costs the core a dozen nodes — less memory than the current materialize-everything lists.
  • Input layering (degradation lives in the input layer, not the component):
    • touch: down = press-highlight via setActiveNode; pan claims + beginDrag/drag/endDrag → fling; tap = pressNode() → same onPress as CIRCLE/cursor; down arrests a fling; cancel (modal open) ends tracking without a fling.
    • d-pad: focusRows (default) = focus-controller-driven index + keep-in-view chase; off-window unmounts never strand focus (authoritative index + pending-focus re-assert + per-frame adoption of linear-traversal entries). focusRows: false = the exact im d-pad pump.
    • cursor: rows are focusable; hover-is-focus works unchanged.
  • im invariants: prepend rebase/rebaseRows (backfill never moves the viewport), stickToBottom judged on the scroller intent against the previous max (Scroller.intent() added to kinetics), near-start/end fetch callbacks (app guards, the im convention).
  • Written without JSX or class strings (direct calls + style objects, the components.ts style) so it unit-tests under plain bun and is independent of the pass-1 class harvest; subpath registered in all three maps regardless (bundler resolver).

Tests

13 renderer-level cases through the public render() + real frame pump with packed-touch frames: windowing/stability/clamping, d-pad walk with exact chase landing, CIRCLE + tap + pan/fling/arrest journeys, rebase, stickToBottom follow/hold, near-end. Full regression: renderer/cursor/kinetics/im-sim green, tsc --noEmit clean.

🤖 Generated with Claude Code

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 <For> 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 <noreply@anthropic.com>
doodlewind and others added 3 commits July 23, 2026 20:49
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant