Skip to content

Retry node-selection scroll until the cursor is on screen#291

Open
johannesmutter wants to merge 5 commits into
mainfrom
fix/node-scroll-after-relayout
Open

Retry node-selection scroll until the cursor is on screen#291
johannesmutter wants to merge 5 commits into
mainfrom
fix/node-scroll-after-relayout

Conversation

@johannesmutter

Copy link
Copy Markdown
Collaborator

Problem. After an undo/insert, the restored nodes' layout (images, content sizing) settles over several frames. __render_node_selection scrolled once, at a fixed-too-early moment, against an un-settled layout — leaving the cursor off-screen. Repro: select all buttons in a story → delete → scroll away → undo; previously only a second undo revealed them, once layout had settled.

Fix. The render no longer scrolls once and gives up: if the DOM isn't mounted yet it retries on the next frame, and the scroll re-runs each frame until the cursor is genuinely in view (bounded to ~1s).

Why this layer. Earlier attempts each baked in a timing assumption — setTimeout(0), a 500ms observer window, leading-edge visibility — and each was wrong in some case. This assumes nothing: it's defined by the outcome ("is the cursor in view?") and converges regardless of when or why layout settles. It tolerates the async layout rather than eliminating it — which is correct, since indeterminate content size until load is inherent to the platform. Going deeper would mean deterministic layout from the model (e.g. media nodes carrying dimensions) or an unbounded event-driven ResizeObserver instead of the bounded poll — both optional refinements, not corrections.

Resolves the remaining multi-node case from #288.

@vercel

vercel Bot commented May 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
svedit Ready Ready Preview, Comment May 28, 2026 12:03pm

@michael

michael commented May 22, 2026

Copy link
Copy Markdown
Owner

Problem description is spot on. setTimeout(fn, 0) was too naive here.

Fix definitely improves situation. I'm still testing and reproducing as I still found cases where the active node selection isn't revealed. will document as soon as I can reproduce reliably.

We definitely need some notion of "wait until layout has settled" then scroll. Though we might only get heuristics here. So hard to say which approach is the simplest+most robust.

Experimenting with an alternative approach (#292) and applying it to all scroll to situations (not just node selections)

johannesmutter added a commit that referenced this pull request May 26, 2026
A single deferred scroll computed against an un-settled layout strands
the cursor when images / nested mounts continue reflowing after the
triggering action — the "still found cases where the active node
selection isn't revealed" case on #291.

Node selection (__scroll_node_cursor_into_view):
- IntersectionObserver on the two nodes flanking the cursor gap re-fires
  the scroll on each viewport-edge crossing, until the selection moves
  on, the user scrolls (after a 500ms grace so the settling relayout
  and any inertial scroll can't abort it), or a 2s safety timeout fires.
  Replaces the rAF-poll-until-deadline loop with an event-driven watch
  that catches relayout shifts past the first visibility-pass.

Property and text selection:
- setTimeout(fn, 0) -> await tick() then a rAF (with 50ms fallback for
  background-tab rAF throttling). tick() drains Svelte's effect queue
  so the scroll measures the post-flush DOM, not the pre-flush one.
- Job-id cancellation so a fresh render invalidates an older pending
  scroll instead of letting both yank the viewport.
- Text selection captures focusNode.parentElement at run time, not at
  enqueue time, so a focus that moves during the settle window doesn't
  scroll a stale element.

Node-cursor watch starts also bump the property/text job-id, so a node
render cancels any pending property/text scroll scheduled just before
it.

Adopts the deferred-scroll helper shape from #292 for the property/text
generalization; #292 can be closed in favour of this combined approach.
svedit-lab "Run Smoke Tests": 25/25 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@johannesmutter

Copy link
Copy Markdown
Collaborator Author

@michael Pushed a1c4ef6 combining both approaches.

Node selection: IntersectionObserver watching the two flanking nodes — re-fires the scroll on every viewport-edge crossing, so the relayout cases that survived the first visibility-pass (your "still found cases") now get caught. Stops on selection change, user scroll (with a 500ms grace so the settling itself can't abort it), or a 2s safety timeout.

Property + text selection: adopted your tick() + (rAF || 50ms) + deferred_scroll_job_id pattern from #292 — those don't have the flank-settling structure, so single-shot is the right shape there. A node-scroll-watch start also bumps the job-id to invalidate any pending property/text scroll queued just before it.

@michael

michael commented May 27, 2026

Copy link
Copy Markdown
Owner

This looks good on a test within Svedit in Chrome. But since this introduces quite a bit of machinery and event sources, I'd like to take my time to test against this branch with the EW app for a while, and merge once confident enough. Close to releasing 0.10.0, and this will likely be part of a follow-up patch release.

Do you really think binding to wheel and touchmove is necessary here? If I understand correctly this is meant to cancel the automatic scroll once the user interacts. Just my gut says that these additional event sources will cause race conditions. I don't think the scroll-into-view flow should run longer than 500ms, and I think that's an acceptable period to block the UI for scroll input. So maybe the cancel logic isn't needed.

Another concern: The intersection observer assumes that the window viewport is scrollable, but Svedit could also be mounted/rendered inside an overflowing container.

Found another remaining scroll-into-view issue: When you type inside a button right at the right edge of the button container in story, and then use the right arrow or down key, the last node gap will be selected, but not revealed in the viewport.

lala.mov

johannesmutter and others added 2 commits May 28, 2026 10:46
A single deferred scroll computed against an un-settled layout strands
the cursor when images / nested mounts continue reflowing after the
triggering action — the "still found cases where the active node
selection isn't revealed" case on #291.

Node selection (__scroll_node_cursor_into_view):
- IntersectionObserver on the two nodes flanking the cursor gap re-fires
  the scroll on each viewport-edge crossing, until the selection moves
  on, the user scrolls (after a 500ms grace so the settling relayout
  and any inertial scroll can't abort it), or a 2s safety timeout fires.
  Replaces the rAF-poll-until-deadline loop with an event-driven watch
  that catches relayout shifts past the first visibility-pass.

Property and text selection:
- setTimeout(fn, 0) -> await tick() then a rAF (with 50ms fallback for
  background-tab rAF throttling). tick() drains Svelte's effect queue
  so the scroll measures the post-flush DOM, not the pre-flush one.
- Job-id cancellation so a fresh render invalidates an older pending
  scroll instead of letting both yank the viewport.
- Text selection captures focusNode.parentElement at run time, not at
  enqueue time, so a focus that moves during the settle window doesn't
  scroll a stale element.

Node-cursor watch starts also bump the property/text job-id, so a node
render cancels any pending property/text scroll scheduled just before
it.

Adopts the deferred-scroll helper shape from #292 for the property/text
generalization; #292 can be closed in favour of this combined approach.
svedit-lab "Run Smoke Tests": 25/25 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@johannesmutter

Copy link
Copy Markdown
Collaborator Author

Another issue I found: set this css anywhere and node gaps will not render at all:

content-visibility: auto;
contain-intrinsic-size: 100% 100vh;

@johannesmutter

Copy link
Copy Markdown
Collaborator Author

tmp: testing new approach. performance:

nodes render_ms dom_el gaps memory scroll_avg scroll_min resize_avg resize_min mutation_avg mutation_min
50 46 672 20 28 60 30 60 60 50 30
100 94 1352 18 28 59 30 60 57 44 30
200 148 2715 18 42 40 20 53 30 32 20
500 312 6823 16 61 16 12 19 10 12 8
1000 763 13679 15 84 8 4 4 2 3 2
2000 2460 27372 15 197 1 1 1 0 1 0

…d when typing in a button in story.buttons node array where the text caret touches the end edge of an overflowing container: use scroll padding to leave "breathing" room
@johannesmutter

Copy link
Copy Markdown
Collaborator Author

This issue is now addressed by adding css scroll padding:

Found another remaining scroll-into-view issue: When you type inside a button right at the right edge of the button container in story, and then use the right arrow or down key, the last node gap will be selected, but not revealed in the viewport.

Three changes to __scroll_node_cursor_into_view in Svedit.svelte:

- Drop the wheel/touchmove cancellation path and shorten the safety cap
  from 2000 ms to 500 ms. At 500 ms a deliberate user scroll inside the
  watch isn't a coherent human gesture, so the cancellation event
  sources Michael flagged as race-prone are no longer needed.

- Use the nearest scrollable ancestor as the IntersectionObserver root,
  and give __intersects_viewport an optional `root` parameter. When
  Svedit is mounted inside an overflow:auto container, the observer
  and the visibility check both test the surface the user actually
  scrolls instead of the window viewport.

- Restructure the edge branches: cursor at offset 0 / trailing now
  skips the flank-visibility guard (the cursor sits past its single
  flank, so flank visibility tells us nothing about the cursor). Each
  edge branch sets the matching internal scroll, then falls through to
  scrollIntoView on the flank only when the flank is still off-screen.
  This unblocks two cases the previous "max_left === 0 && max_top === 0"
  gate missed:
    - Typing at the right edge of an overflowing button + arrow-right
      -> trailing gap reveal (combined with NodeArrayProperty's scroll-
      padding default, the trailing gap stays positioned through typing).
    - #288 offscreen-array-undo: 10 buttons -> delete -> scroll to bottom
      -> undo. Previously the trailing branch's scrollIntoView was gated
      on both max_left and max_top being 0 and skipped in horizontal-
      overflow + offscreen-vertically scenarios.

Four new tests pin the regressions:
- selection_scroll: model-driven selection on an offscreen
  horizontally-overflowing array scrolls it into view (#288 regression).
- gap_visibility: mid gap stays .positioned when only the prev flank is
  in near_map (locks in the wrap/grid prev-only rule).
- gap_visibility: mid gap-marker emits when only the prev flank is in
  array_indices (matching rule for NodeGapMarkers).
- gap_visibility: scroll-padding-inline / -block default is non-zero on
  every node-array (locks in the typing-edge fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@johannesmutter

Copy link
Copy Markdown
Collaborator Author

@michael I addressed your feedback and a couple other issues I found.

@michael

michael commented May 28, 2026

Copy link
Copy Markdown
Owner

content-visibility: auto;
contain-intrinsic-size: 100% 100vh;

Is this addressed also already? And can you explain what is/was the issue here? And when would you use this? From the docs I found I still don't understand the use-cases and problems it solves. Mainly a performance optimization I guess?

@johannesmutter

Copy link
Copy Markdown
Collaborator Author

RE: content-visibility: auto

content-visibility: auto is a performance primitive: the browser skips layout/paint/a11y for off-screen subtrees and renders them as a placeholder sized via contain-intrinsic-size. It's how you'd virtualise a long page without writing JS virtualisation. My app used it in a presentation view with hundreds of full height slides (I have adjusted it to now only use it in view mode).

It breaks node gaps because of how CSS anchor positioning interacts with containment. While the cell is off-screen, content-visibility: auto applies contain: size layout, which (a) replaces the cell's content box with the placeholder size: so any anchor() reading the node's edges resolves against the wrong rect and (b) scopes the contained subtree's anchor-name discovery so gaps that live outside the cell can't see the node's anchor-name across the boundary. Setting it anywhere kills every gap, not just the ones near the visibility boundary.

So this issue is for another day/ PR. The fix is either:

  • move gaps inside the cell (we had this before, which has other downsides),
  • use anchor-scope once browesr support stabilises
  • or something else I'm not yet aware of.

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.

2 participants