Skip to content

Inverted (scaleY) list: appended self-sizing items paint one frame at estimated positions before the measure lands (visible flash, new-arch iOS) #493

Description

@olinanderson

Summary

In a chat-style composition (scaleY(-1) container + reversed data + per-cell un-flip, bottom anchored at scroll 0), appending self-sizing items commits one painted frame at the items' estimated positions before the real measure/correction lands. On screen this reads as a flash: the new rows spread out with estimate-sized gaps between them, and existing content is displaced up by the total estimate — then the next frame snaps everything correct.

Isolated to a bare LegendList — no gestures, no keyboard, no other libraries in the tree.

Environment

  • @legendapp/list 3.3.2 (identical behavior on 3.2.0 — frame profiles byte-equal across the two)
  • react-native 0.81.5, Expo ~54, new architecture (Fabric), Hermes
  • iOS 26.4 simulator (iPhone 17 Pro Max); recorded at 60fps via simctl io recordVideo

Minimal repro

import React, { useCallback, useMemo, useState } from 'react';
import { Button, Text, View } from 'react-native';
import { LegendList } from '@legendapp/list';

const INVERT = { transform: [{ scaleY: -1 as const }] };
const TALL = (i: number) =>
  Array.from({ length: 9 }, (_, l) => `Seed ${i} line ${l + 1} — tall self-sizing row.`).join('\n');

export function Repro() {
  const [rows, setRows] = useState([
    { id: 's1', text: TALL(1) },
    { id: 's2', text: 'Short reply one.' },
    { id: 's3', text: TALL(2) },
    { id: 's4', text: 'Short reply two.' },
  ]);
  // Chronological; newest LAST — reversed so newest is data[0], the scaleY
  // flip restores visual order with the bottom self-anchored at scroll 0.
  const data = useMemo(() => rows.slice().reverse(), [rows]);
  const renderItem = useCallback(({ item }: any) => (
    <View style={INVERT}>
      <View style={{ padding: 12, marginVertical: 6, marginHorizontal: 16, borderRadius: 12, backgroundColor: '#7c6fd6' }}>
        <Text style={{ fontSize: 13.5, lineHeight: 21, color: 'white' }}>{item.text}</Text>
      </View>
    </View>
  ), []);
  // Mimics a chat send: ONE state update appending 3 rows (message + stub + footer).
  const append3 = () => setRows((prev) => {
    const n = prev.length;
    return [...prev,
      { id: `u${n}`, text: `Sent message ${n} — short.` },
      { id: `t${n}`, text: ' ' },
      { id: `f${n}`, text: `footer ${n}` }];
  });
  return (
    <View style={{ flex: 1 }}>
      <LegendList
        data={data}
        keyExtractor={(r: any) => r.id}
        renderItem={renderItem}
        style={INVERT}
        contentContainerStyle={{ paddingTop: 90, paddingBottom: 10 }}
        estimatedItemSize={140}
        recycleItems
      />
      <Button title="append 3" onPress={append3} />
    </View>
  );
}

Steps: mount, tap "append 3" once. Expected: the three rows appear at the bottom in one clean frame. Actual: for exactly one frame, the three rows paint spread apart at estimate-sized offsets (each roughly estimatedItemSize-spaced rather than at measured positions) with the previous content displaced upward; the next frame corrects.

Measurement

Frame-by-frame difference (ffmpeg tblend=differencesignalstats YAVG over 60fps captures): the append frame pair spikes to 57.6 / 54.7 on the bare repro above (quiescent frames are <0.1). In our production chat list the same signature is a 24–26 spike, one frame, on every send. No scroll event reaches JS for that frame (an app-side onScroll clamp — see #492 — never observes it), so it appears the paint happens on the UI thread between the insert layout (estimated sizes) and the applied measurement.

Things we ruled out

  • Estimate accuracy is not sufficient to fix it: we locally patched in the per-item estimate hook proposed in At-bottom/followOutput semantics that hold under estimate re-layout (chat lists), or a per-item size estimate hook #492 (getEstimatedItemSize(item, index, type) consulted before the per-type average in getItemSize) and fed calibrated, near-exact heights — the flash frame still paints (the gaps shrink with better estimates, but a mispainted frame still commits). The defect looks like paint ordering (estimated layout is allowed to reach the screen), not estimate quality.
  • Forcing early measurement from the outside makes it worse: driving ref.setItemSize(...) with correct sizes at insert, or triggering the item's useSyncLayout() on mount, both destabilized the bottom anchor (offset excursions of 13k–32k px that our at-bottom clamp had to recover).
  • Not the keyboard, not gestures (repro is driven programmatically with neither).

Relation to #492

Same at-bottom/chat family as #492 (estimate corrections vs the bottom anchor), but a distinct defect: #492 asks for better inputs (per-item estimates, a followOutput contract); this one is about a frame being painted from estimated positions at all. A followOutput/at-bottom contract that suppresses paints until appended items are measured (or applies the batched sync measure before the first paint on Fabric) would fix both.

60fps .mov captures (bare repro + production) available on request.

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