126 lines
4.1 KiB
Markdown
126 lines
4.1 KiB
Markdown
## 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. We’ll 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.
|
||
|
||
2. 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
|
||
|
||
3. 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
|
||
|
||
4. 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.
|
||
|
||
5. 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?
|