Skip to content

VirtualGrid: rows stop animating once the slice shifts (prevRowY measured after layout) #66

Description

@makisp

Package: @solidtv/solid 1.3.9 (renderer @solidtv/renderer 1.6.1)
File: src/primitives/VirtualGrid.tsx, onSelectedChanged

Symptom

With scroll="always" (the default) and the default transition: { y: true }, moving Down through a
VirtualGrid animates only the first buffer + 1 rows. From then on, every row change jumps the
grid by one row in a single frame, with no tween. Up behaves the same once the slice shifts.

Reproduction

A VirtualGrid with columns={5}, rows={4}, buffer={2}, and about 40 items. Press Down
repeatedly. Rows 0→1 and 1→2 tween. From row 3 on, start() is non-zero and every step re-slices,
and none of those steps tweens.

Measured in Chrome with a per-frame sampler on the grid's lng.y and wrappers on lng.y's setter
and lng.animateProp, for one Down past row 3:

  • lng.y stayed at -400 on every frame;
  • there was exactly one write to lng.y (-400, from the compensation line);
  • animateProp was never called.

Root cause

setSlice(items().slice(start(), end()));
// ...
queueMicrotask(() => {
  const prevRowY = this.y + active.y;   // <-- measured here
  this.updateLayout();
  this.lng.y = prevRowY - active.y;
  columnScroll(idx, elm, active, lastIdx);
});

setSlice synchronously inserts and removes children. Each insert calls addToLayoutQueue, which
calls schedulePostMutation() → queueMicrotask(runPostMutation) (core/elementNode.ts).
That microtask is queued before VirtualGrid's own, so runPostMutation runs first. Its layout
phase calls updateLayout() on the grid, and the flex layout moves active to its new slice
position.

When VirtualGrid's microtask runs, active.y is already the new value, so:

  • prevRowY - active.y === this.y, and the compensation is a no-op;
  • columnScroll computes -active.y + offset, which is the same target as the previous step,
    because in steady state the focused card always sits buffer rows into the slice;
  • componentRef.y !== nextPosition is false, so it never assigns y and no transition starts.

The content moves up a row via layout alone, which is the one-frame jump.

(Wrapping updateLayout confirms the order. Two calls per Down: the first, from
runPostMutation, moves active.y 600 → 400. The second, VirtualGrid's own, sees 400 → 400.)

Why Virtual.tsx doesn't have this bug

Virtual.tsx reads its equivalent value synchronously, before the microtask:

const prevChildPos = (targetPosition ?? this[axis]) + active[axis];
queueMicrotask(() => {
  elm.updateLayout();
  ...
  this.lng[axis] = prevChildPos - active[axis];

Suggested fix

Take the measurement synchronously, right after setSlice, while the old layout still stands:

     setSlice(items().slice(start(), end()));
+    const prevRowY = this.y + active.y;

     // this.selected is relative to the slice
     ...
     queueMicrotask(() => {
-      const prevRowY = this.y + active.y;
       this.updateLayout();
       this.lng.y = prevRowY - active.y;
       columnScroll(idx, elm, active, lastIdx);
     });

With this change, every row change tweens in the browser, for example -200 → -400 over ~23
frames on a 200px row pitch. Held navigation also chains smoothly: each step picks up the running
tween instead of jumping.

A regression test that fails before and passes after: mount a grid, and for each of 6 Downs, flush
microtasks and assert that a y tween is queued on the grid with a target different from its
current y. Before the fix this yields [true, true, false, false, false, false].

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions