Files
opencode/specs/04-scroll-spy-optimization.md
2026-01-09 05:07:16 -06:00

4.1 KiB
Raw Permalink Blame History

Spy acceleration

Replace O(N) DOM scans in session view


Summary

The session scroll-spy currently scans the DOM with querySelectorAll and walks message nodes, which becomes expensive as message count grows. Well replace the scan with an observer-based or indexed approach that scales smoothly.


Goals

  • Remove repeated full DOM scans during scroll in the session view
  • Keep “current message” tracking accurate during streaming and layout shifts
  • Provide a safe fallback path for older browsers and edge cases

Non-goals

  • Visual redesign of the session page
  • Changing message rendering structure or IDs
  • Perfect accuracy during extreme layout thrash

Current state

  • packages/app/src/pages/session.tsx uses querySelectorAll('[data-message-id]') for scroll-spy.
  • The page is large and handles many responsibilities, increasing the chance of perf regressions.

Proposed approach

Implement a two-tier scroll-spy:

  • Primary: IntersectionObserver to track which message elements are visible, updated incrementally.
  • Secondary: binary search over precomputed offsets when observer is unavailable or insufficient.
  • Use ResizeObserver (and a lightweight “dirty” flag) to refresh offsets only when layout changes.

Phased implementation steps

  1. Extract a dedicated scroll-spy module
  • Create packages/app/src/pages/session/scroll-spy.ts (or similar) that exposes:
    • register(el, id) and unregister(id)
    • getActiveId() signal/store
  • Keep DOM operations centralized and easy to profile.
  1. Add IntersectionObserver tracking
  • Observe each [data-message-id] element once, on mount.
  • Maintain a small map of id -> intersectionRatio (or visible boolean).
  • Pick the active id by:
    • highest intersection ratio, then
    • nearest to top of viewport as a tiebreaker
  1. Add binary search fallback
  • Maintain an ordered list of { id, top } positions.
  • On scroll (throttled via requestAnimationFrame), compute target Y and binary search to find nearest message.
  • Refresh the positions list on:
    • message list mutations (new messages)
    • container resize events (ResizeObserver)
    • explicit “layout changed” events after streaming completes
  1. Remove querySelectorAll hot path
  • Keep a one-time initial query only as a bridge during rollout, then remove it.
  • Ensure newly rendered messages are registered via refs rather than scanning the whole DOM.
  1. Add a feature flag and fallback
  • Add session.scrollSpyOptimized flag.
  • If observer setup fails, fall back to the existing scan behavior temporarily.

Data migration / backward compatibility

  • No persisted data changes.
  • IDs remain sourced from existing data-message-id attributes.

Risk + mitigations

  • Risk: observer ordering differs from previous “active message” logic.
    • Mitigation: keep selection rules simple, document them, and add a small tolerance for tie cases.
  • Risk: layout shifts cause incorrect offset indexing.
    • Mitigation: refresh offsets with ResizeObserver and after message streaming batches.
  • Risk: performance regressions from observing too many nodes.
    • Mitigation: prefer one observer instance and avoid per-node observers.

Validation plan

  • Manual scenarios:
    • very long sessions (hundreds of messages) and continuous scrolling
    • streaming responses that append content and change heights
    • resizing the window and toggling side panels
  • Add a dev-only profiler hook to log time spent in scroll-spy updates per second.

Rollout plan

  • Land extracted module first, still using the old scan internally.
  • Add observer implementation behind session.scrollSpyOptimized off by default.
  • Enable flag for internal testing, then default on after stability.
  • Keep fallback code for one release cycle, then remove scan path.

Open questions

  • What is the exact definition of “active” used elsewhere (URL hash, sidebar highlight, breadcrumb)?
  • Are messages virtualized today, or are all DOM nodes mounted at once?
  • Which container is the scroll root (window vs an inner div), and does it change by layout mode?