performLayout method

  1. @override
void performLayout()
override

Do the work of computing the layout for this render object.

Do not call this function directly: call layout instead. This function is called by layout when there is actually work to be done by this render object during layout. The layout constraints provided by your parent are available via the constraints getter.

If sizedByParent is true, then this function should not actually change the dimensions of this render object. Instead, that work should be done by performResize. If sizedByParent is false, then this function should both change the dimensions of this render object and instruct its children to layout.

In implementing this function, you must call layout on each of your children, passing true for parentUsesSize if your layout information is dependent on your child's layout information. Passing true for parentUsesSize ensures that this render object will undergo layout if the child undergoes layout. Otherwise, the child can change its layout information without informing this render object.

Some special RenderObject subclasses (such as the one used by OverlayPortal.overlayChildLayoutBuilder) call applyPaintTransform in their performLayout implementation. To ensure such RenderObjects get the up-to-date paint transform, RenderObject subclasses should typically update the paint transform (as reported by applyPaintTransform) in this method instead of paint.

Implementation

@override
void performLayout() {
  debugPerformLayoutCount++;
  debugLastParentDataRefreshIterationCount = 0;
  debugLastParentDataCumulativeBuilds = 0;
  final constraints = this.constraints;
  // This sliver's layout and paint code assume a vertical-forward axis.
  // Child constraints, offset math, sticky pinning and hit-testing all use
  // plain (x = indent, y = layoutOffset) coordinates with no axis mapping.
  // Running in any other axis/growth/reverse configuration silently renders
  // incorrectly, so fail loudly in debug builds.
  final bool axisOk =
      constraints.axis == Axis.vertical &&
      constraints.axisDirection == AxisDirection.down &&
      constraints.growthDirection == GrowthDirection.forward;
  if (!axisOk) {
    // Release-safe degraded behavior: report zero geometry rather than
    // miscompute against vertical-down assumptions. Mirrors the empty-tree
    // pattern just below: didStartLayout before setting geometry,
    // didFinishLayout after.
    //
    // The fallback runs BEFORE the assert so that even in debug builds
    // (where the assert throws and the framework catches it) downstream
    // layout/semantics/paint see valid zero geometry instead of a null
    // SliverGeometry. Without this ordering, the framework cascades to
    // multiple downstream null-check errors trying to walk an unlaid sliver.
    childManager?.didStartLayout();
    geometry = SliverGeometry.zero;
    childManager?.didFinishLayout();
  }
  assert(
    axisOk,
    "SliverTree currently supports only vertical, forward-growing axes "
    "(Axis.vertical, AxisDirection.down, GrowthDirection.forward). Got "
    "axis=${constraints.axis}, axisDirection=${constraints.axisDirection}, "
    "growthDirection=${constraints.growthDirection}.",
  );
  if (!axisOk) return;
  childManager?.didStartLayout();

  final visibleNodes = controller.visibleNodes;
  if (visibleNodes.isEmpty) {
    // An animated mutation may have staged a FLIP baseline and then
    // emptied the tree in the same frame. Discard it here, under
    // first-wins staging a stranded baseline would block every later
    // beginSlideBaseline, and when the tree repopulates the stale
    // pre-empty offsets would be consumed as the FLIP "before" of an
    // unrelated mutation (wrong one-frame deltas). Ghost/phantom
    // state is likewise keyed against rows that no longer exist,
    // mirror the controller-swap reset. Scroll bookkeeping restarts
    // fresh with the repopulated tree.
    _composer.reset();
    _phantomExitGhosts = null;
    _phantomClipAnchors = null;
    _lastObservedScrollOffset = double.nan;
    _structureChanged = true;
    _lastVisibleNodeCount = 0;
    // Nothing was admitted, so no bound was honored either; leaving a
    // stale one would let a later tick read "already admitted" for a
    // window this layout never opened.
    _admittedSlideBound = 0.0;
    geometry = SliverGeometry.zero;
    childManager?.didFinishLayout();
    return;
  }

  // Step 0a: Prune freed/settled entries from `_phantomExitGhosts`
  // every layout. Runs unconditionally (not just when slides are idle)
  // so dead entries don't accumulate during active slide cycles. Paint
  // passes below can iterate these maps read-only because of this prune.
  _pruneSettledPhantomExitGhosts();

  // Step 0b: Same for the composer ghost registry. `pruneSettled`
  // handles both freed-key (nid < 0) and settled (both deltas == 0)
  // entries. When all FLIP slides have settled, we additionally drop
  // everything via `clearAll` so the map shrinks back to empty.
  //
  // Both branches are FLIP-only. A held make-room preview keeps the
  // composed `hasActiveSlides` true for a whole drag, so gating on it
  // sent every layout down the prune branch, where the composed
  // criterion then retained any ghost the preview had shifted.
  if (_composer.hasGhosts) {
    if (!controller.hasActiveFlipSlides) {
      _composer.ghosts.clearAll();
    } else {
      _composer.ghosts.pruneSettled();
    }
  }

  _ensureLayoutCapacity();

  // Detect structure changes
  if (controller.structureGeneration != _lastStructureGeneration) {
    _structureChanged = true;
    _sticky.dirty = true;
    _lastStructureGeneration = controller.structureGeneration;
  }
  if (visibleNodes.length != _lastVisibleNodeCount) {
    _structureChanged = true;
    _sticky.dirty = true;
  }

  final scrollOffset = constraints.scrollOffset;
  final remainingPaintExtent = constraints.remainingPaintExtent;
  final remainingCacheExtent = constraints.remainingCacheExtent;
  final crossAxisExtent = constraints.crossAxisExtent;

  // Cache region bounds: per sliver protocol, remainingCacheExtent starts
  // at scrollOffset + cacheOrigin (cacheOrigin is typically <= 0).
  final cacheOrigin = constraints.cacheOrigin;
  final cacheStart = scrollOffset + cacheOrigin;
  final cacheEnd = cacheStart + remainingCacheExtent;

  // Slide-pipeline ordering:
  //
  //   1. Build the current viewport snapshot.
  //   2. If scroll changed since last layout and edge ghosts exist,
  //      normalize the ghost map for the current viewport, handle
  //      re-promotions, direction flips, and stays-same. When a
  //      pending mutation baseline exists, normalization runs WITHOUT
  //      installing standalone slides so the upcoming consume owns
  //      the single animation batch for this layout (avoids two
  //      independent `animateSlideFromOffsets` calls in the same
  //      frame). When no pending baseline exists, normalization
  //      installs slides directly.
  //   3. Consume the pending mutation baseline. After step 2 the
  //      ghost map already reflects the current viewport, so consume
  //      composes against fresh state instead of the stale frozen
  //      `edgeY` the previous implementation carried.
  //   4. Update `_lastObservedScrollOffset` to the new scroll.
  //
  // `snapshotVisibleOffsets()` walks `visibleNodes` with
  // `getCurrentExtent`, which is independent of Pass 1's per-nid
  // offset array, so all three steps are safe before Pass 1.
  final currentViewport = _currentViewportSnapshot();
  final currentScroll = currentViewport.scrollOffset;
  final scrollChanged =
      !_lastObservedScrollOffset.isNaN &&
      currentScroll != _lastObservedScrollOffset;
  if (scrollChanged && _composer.hasGhosts) {
    _composer.ghosts.normalizeForViewport(
      viewport: currentViewport,
      installStandaloneSlides: !_composer.isBaselineStaged,
    );
  }
  _consumeSlideBaselineIfAny(currentViewport: currentViewport);
  _lastObservedScrollOffset = currentScroll;

  // FLIP-slide overreach (Option A): during a slide, a row's painted y
  // can differ from its structural y by up to `slideOverreach` px in
  // either direction. Widen the effective cache region by that amount
  // so rows whose painted y lies in the viewport, but whose structural
  // y is outside the normal cache region, still get built. Without
  // this, a swap of two large subtrees leaves a visible gap at the slot
  // where a sliding row should appear (no child created for it), and
  // the gap does NOT resolve on scroll because the build decision still
  // only considers structural offsets. Overreach shrinks to 0 as the
  // slide progresses (see [TreeController.composedSlideAbsDeltaBound]),
  // so the transient overbuild contracts with the animation.
  //
  // Future optimization (Option B): replace this blanket clamp with a
  // per-entry precise union. For each active slide, compute the
  // structural index range whose painted y (structural + currentDelta)
  // intersects the cache region, then union those ranges with the
  // normal cache-region index range. This eliminates the transient
  // overbuild for the common case of a few small slides, at the cost
  // of a per-entry scan every frame. Worth doing only when the
  // overbuild measurably hurts, large-subtree swaps are rare and
  // short-lived, so the blanket clamp is usually fine.
  // Summed bound, not max: a row can carry a FLIP delta AND a held
  // make-room offset at once (drag started mid-FLIP), and the composed
  // displacement reaches the sum. Identical to the max whenever only
  // one engine is active.
  final slideOverreach = controller.composedSlideAbsDeltaBound;
  _admittedSlideBound = slideOverreach;
  final effectiveCacheStart = cacheStart - slideOverreach;
  final effectiveCacheEnd = cacheEnd + slideOverreach;

  // ────────────────────────────────────────────────────────────────────────
  // PASS 1: Calculate offsets and extents
  // ────────────────────────────────────────────────────────────────────────
  double totalScrollExtent;
  final bool hasAnimations = controller.hasActiveAnimations;
  // Fetch the bulk-animation snapshot once so downstream branches share a
  // single read of value/generation/membership. Per-key membership
  // queries inside _rebuildBulkCumulatives go through this snapshot too.
  final BulkAnimationData<TKey> bulkData = controller.bulkAnimationData();
  final bool bulkOnly = bulkData.isValid && !controller.hasOpGroupAnimations;

  if (bulkOnly) {
    // Fast path: bulk animation only. Every node's offset is a scalar
    // function of position via the precomputed cumulatives. Avoid touching
    // _nodeOffsetsByNid for nodes outside the cache region, that write
    // is what the per-frame O(N) cost was buying.
    final bulkGen = bulkData.generation;
    final n = visibleNodes.length;
    if (!_bulkCumulativesValid ||
        _bulkCumulativesCount != n ||
        bulkGen != _lastBulkAnimationGeneration ||
        _structureChanged) {
      _rebuildBulkCumulatives(visibleNodes, bulkData);
      _lastBulkAnimationGeneration = bulkGen;
      _structureChanged = false;
    }
    _bulkValueCached = bulkData.value;
    totalScrollExtent = _offsetAtVisibleIndex(n);
    _lastFrameUsedBulkCumulatives = true;
  } else if (_structureChanged || _lastFrameUsedBulkCumulatives) {
    // Either the visible order changed OR we just exited the bulk-only
    // fast path, in both cases the per-nid offset/extent arrays are
    // not guaranteed fresh for every visible node, so do a full walk.
    _bulkCumulativesValid = false;
    _lastFrameUsedBulkCumulatives = false;
    totalScrollExtent = 0.0;

    final orderNids = controller.orderNidsView;
    final n = visibleNodes.length;
    for (int i = 0; i < n; i++) {
      final nid = orderNids[i];
      _nodeOffsetsByNid[nid] = totalScrollExtent;
      final extent = controller.getCurrentExtentNid(nid);
      _nodeExtentsByNid[nid] = extent;
      totalScrollExtent += extent;
    }

    _structureChanged = false;
  } else if (!hasAnimations && !_animationsWereActive) {
    // Pure scrolling: no animations active now or last frame.
    // Offsets and extents are unchanged, reuse cached total.
    totalScrollExtent = _lastTotalScrollExtent;
  } else if (hasAnimations) {
    // Active animation frame: only indices at or beyond the first
    // animating node can have changed offsets/extents. Everything
    // before them has stable cached values from the prior frame.
    final firstAnimIdx = controller.computeFirstAnimatingVisibleIndex();
    if (firstAnimIdx >= visibleNodes.length) {
      // Animating nodes exist but none are in the visible order
      // (e.g. an animation on a subtree that was moved out of view).
      // Nothing to recompute here.
      totalScrollExtent = _lastTotalScrollExtent;
    } else {
      final orderNids = controller.orderNidsView;
      if (firstAnimIdx == 0) {
        totalScrollExtent = 0.0;
      } else {
        final prevNid = orderNids[firstAnimIdx - 1];
        totalScrollExtent =
            _nodeOffsetsByNid[prevNid] + _nodeExtentsByNid[prevNid];
      }
      for (int i = firstAnimIdx; i < visibleNodes.length; i++) {
        final nid = orderNids[i];
        final newExtent = controller.getCurrentExtentNid(nid);
        _nodeOffsetsByNid[nid] = totalScrollExtent;
        _nodeExtentsByNid[nid] = newExtent;
        totalScrollExtent += newExtent;
      }
    }
  } else {
    // Transitional frame: no active animations this frame, but there
    // were last frame. Some just-settled nodes may have their cached
    // extent stuck at an intermediate interpolated value if the
    // settling frame fired before our last layout. Walk the list with
    // the extent-equality short-circuit so stable-prefix nodes stay
    // cheap and only changed nodes get rewritten.
    totalScrollExtent = 0.0;
    bool foundAnimating = false;

    final orderNids = controller.orderNidsView;
    for (int i = 0; i < visibleNodes.length; i++) {
      final nid = orderNids[i];
      final newExtent = controller.getCurrentExtentNid(nid);
      final oldExtent = _nodeExtentsByNid[nid];

      if (!foundAnimating && oldExtent == newExtent) {
        // Structure is stable in this branch, so the prior-layout slot
        // value is valid; no null-vs-zero ambiguity to guard against.
        totalScrollExtent = _nodeOffsetsByNid[nid] + newExtent;
      } else {
        foundAnimating = true;
        _nodeOffsetsByNid[nid] = totalScrollExtent;
        _nodeExtentsByNid[nid] = newExtent;
        totalScrollExtent += newExtent;
      }
    }
  }

  // Pass 1 is done: does `_nodeOffsetsByNid` now describe EVERY visible
  // row, off-cache ones included? Each non-bulk branch above writes the
  // full visible prefix, so yes; the bulk fast path maintains only the
  // cache region, so no. That includes a bulk frame which LEAVES the
  // fast path later in this method: Pass 2's fall-off rewrites just the
  // tail from the first changed row, so earlier off-cache slots still
  // hold the previous non-bulk frame's values.
  //
  // Frame-local by construction: written here, refined by the sticky
  // block's full recompute below, and read by the parentData refresh,
  // all within this one `performLayout`. Nothing carries it across
  // frames, so there is no stale value to reset anywhere.
  bool offsetsAuthoritative = !bulkOnly;

  // ────────────────────────────────────────────────────────────────────────
  // PASS 2: Create children for nodes in cache region
  // ────────────────────────────────────────────────────────────────────────

  // Clear prior-layout cache-region flags in one memset-style pass, then
  // mark the slice [cacheStartIndex, cacheEndIndex) as this frame's members.
  // Sparse clear of last frame's writes. Iterate the nids we wrote
  // last frame instead of memset'ing the whole nid-indexed array, the
  // array's length tracks nidCapacity, which grows monotonically and
  // dwarfs the actual cache-region size on a long-lived tree.
  for (int i = 0; i < _writtenCacheRegionNidsLen; i++) {
    final nid = _writtenCacheRegionNids[i];
    if (nid < _inCacheRegionByNid.length) {
      _inCacheRegionByNid[nid] = 0;
    }
  }
  _writtenCacheRegionNidsLen = 0;
  final cacheStartIndex = _findFirstVisibleIndex(effectiveCacheStart);

  // In bulk-only mode, break on the row's *steady-state* (full-space)
  // position rather than its animated position. At low bulkValue, animated
  // rows have sub-pixel extents, using animated offsets would admit
  // thousands of invisible rows into the cache region on frame 1 of
  // expandAll, causing a mass-mount hitch. Anchoring the band to full-space
  // caps admission at the count we'd mount at bulkValue=1.
  final double fullCacheEnd;
  if (_bulkCumulativesValid && cacheStartIndex < visibleNodes.length) {
    final fullStart =
        _stableCumulative[cacheStartIndex] +
        _bulkFullCumulative[cacheStartIndex];
    fullCacheEnd = fullStart + remainingCacheExtent + slideOverreach * 2.0;
  } else {
    fullCacheEnd = 0.0;
  }

  // Dispatch the per-iteration `if (_bulkCumulativesValid)` branch out of
  // the loop body, it's invariant across one loop run, so a single
  // up-front decision replaces N per-iteration branches.
  //
  // Bulk fast path: scalar offset = _stableCumulative[i] + value *
  // _bulkFullCumulative[i]. Inline because the loop writes per-nid
  // arrays the render object owns and reads cumulative arrays the
  // render object owns.
  //
  // Op-group path: dual-view (live/post) admission cap that pre-mounts
  // post-animation visible rows during a collapse and caps mass-mounting
  // during an expand. Lives in [LayoutAdmissionPolicy.admit].
  final int cacheEndIndex;
  if (_bulkCumulativesValid) {
    cacheEndIndex = _admitBulkFastPath(
      cacheStartIndex: cacheStartIndex,
      visibleNodes: visibleNodes,
      fullCacheEnd: fullCacheEnd,
    );
  } else {
    cacheEndIndex = _admission.admit(
      cacheStartIndex: cacheStartIndex,
      visibleNodes: visibleNodes,
      nodeOffsetsByNid: _nodeOffsetsByNid,
      nodeExtentsByNid: _nodeExtentsByNid,
      inCacheRegionByNid: _inCacheRegionByNid,
      onCacheRegionAdmit: _writeCacheRegionNid,
      effectiveCacheEnd: effectiveCacheEnd,
      slideOverreach: slideOverreach,
      remainingCacheExtent: remainingCacheExtent,
    );
  }

  // Create children for nodes in the cache region.
  //
  // The range `[cacheStartIndex, cacheEndIndex)` may contain rows that
  // were iterated but not admitted (e.g. off-screen exits during a
  // collapse, iterated past to reach the post-animation-visible
  // following rows, but not admitted themselves). Gate on
  // `_inCacheRegionByNid[nid]` so skipped rows do not trigger a build.
  if (cacheEndIndex > cacheStartIndex) {
    invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
      for (int i = cacheStartIndex; i < cacheEndIndex; i++) {
        final nodeId = visibleNodes[i];
        final nid = _controller.nidOf(nodeId);
        if (_inCacheRegionByNid[nid] == 0) continue;
        childManager?.createChild(nodeId);
      }
    });
  }

  // Layout the children: track whether any extent changed to skip
  // the O(N) _recomputeOffsets when sizes are stable (cache hit path).
  // Also track the smallest index whose extent changed so we can walk
  // only from there when recomputing offsets.
  bool extentsChanged = false;
  int firstChangedIdx = visibleNodes.length;

  for (int i = cacheStartIndex; i < cacheEndIndex; i++) {
    final nodeId = visibleNodes[i];
    final actualAnimatedExtent = _layoutNodeChild(nodeId, crossAxisExtent);
    if (actualAnimatedExtent == null) continue;

    final nid = _controller.nidOf(nodeId);
    final estimatedExtent = _nodeExtentsByNid[nid];
    if (actualAnimatedExtent != estimatedExtent) {
      _nodeExtentsByNid[nid] = actualAnimatedExtent;
      totalScrollExtent += actualAnimatedExtent - estimatedExtent;
      extentsChanged = true;
      if (i < firstChangedIdx) firstChangedIdx = i;
    }

    final child = getChildForNode(nodeId)!;
    final parentData = child.parentData! as SliverTreeParentData;
    parentData.layoutOffset = _nodeOffsetsByNid[nid];
  }

  // Only recompute offsets if actual extents differed from estimates.
  // During steady-state animation (constraint cache hit means same sizes),
  // this skips the full O(N) recomputation. When extents did change,
  // only walk from the first changed index forward, offsets before
  // that point are unaffected by later-index extent changes.
  if (extentsChanged) {
    _sticky.dirty = true;

    if (_bulkCumulativesValid) {
      // A child's measured size perturbed _fullExtents mid-bulk; the
      // cumulatives are now inconsistent with truth for positions beyond
      // firstChangedIdx. Materialize per-nid extents for the affected
      // tail so _recomputeOffsetsFrom can walk it, then fall back off
      // the fast path for this frame.
      _materializeBulkStaleExtents(
        fromIndex: firstChangedIdx,
        cacheStartIndex: cacheStartIndex,
        cacheEndIndex: cacheEndIndex,
        visibleCount: visibleNodes.length,
      );
    }

    totalScrollExtent = _recomputeOffsetsFrom(firstChangedIdx);

    // Only rewrite parentData.layoutOffset for cache-region nodes at or
    // after firstChangedIdx. Earlier cache-region nodes already had the
    // correct value written in the measurement loop above.
    final updateStart = math.max(cacheStartIndex, firstChangedIdx);
    final orderNids = controller.orderNidsView;
    for (int i = updateStart; i < cacheEndIndex; i++) {
      final nodeId = visibleNodes[i];
      final child = getChildForNode(nodeId);
      if (child == null) continue;
      final parentData = child.parentData! as SliverTreeParentData;
      parentData.layoutOffset = _nodeOffsetsByNid[orderNids[i]];
    }
  }

  // Precompute subtree bottoms BEFORE sticky identification so that
  // candidate probing can use O(1) lookups instead of O(n)-per-candidate
  // subtree scans. Skip during animation: candidate probing bails on
  // animating nodes anyway, so the O(3N) precomputation is wasted. The
  // fallback per-candidate scan inside the computer is trivially cheap
  // since it also bails immediately. Also skip when nothing changed
  // since last precomputation (pure scrolling).
  if (_animationsWereActive && !hasAnimations) {
    _sticky.dirty = true; // animation just settled, one final pass
  }
  if (_maxStickyDepth > 0 && !hasAnimations && _sticky.dirty) {
    _sticky.precomputeStableSubtreeBottoms(
      visibleNodes: visibleNodes,
      nodeOffsetsByNid: _nodeOffsetsByNid,
      nodeExtentsByNid: _nodeExtentsByNid,
    );
    _sticky.dirty = false;
  } else if (hasAnimations || _maxStickyDepth == 0) {
    _sticky.invalidatePrecompute();
  }

  // Sticky headers are recomputed on EVERY layout, deliberately with no
  // throttle. Skipping frames during a structural animation would serve a
  // WRONG band rather than an approximate one: every row's extent moves
  // each frame, so `pinnedY` and the candidate probe's answer move with
  // it. (`_forEachStickyCandidate` does not bail on animating candidates
  // either, for the same reason.)
  //
  // The probe merge below is what pays for that: this block runs the
  // candidate walk once per computed layout, repeating it only when it
  // force-creates a row.
  //
  // Compute the sticky set and learn the candidates in one walk.
  //
  // `freshOffsetAt`: under the bulk-only fast path the per-nid arrays
  // are refreshed only for cache-region nids, but the pinned
  // candidate's header row sits ABOVE the viewport, outside the cache
  // region, so its slots hold pre-bulk values while its true offsets
  // move every frame (content before it growing or shrinking). The
  // probe's candidate SELECTION already reads the live bulk cumulatives
  // through `_findFirstVisibleIndex`; hand it the same authority for
  // its geometry (naturalY, the subtree bottom feeding pushUpY), or the
  // two disagree and the band blanks or mispositions for the whole
  // bulk animation. Mirrors `_structuralOffsetAt`'s gate.
  final stickyCandidates = _sticky.computeStickyHeaders(
    scrollOffset: scrollOffset,
    overlap: constraints.overlap,
    visibleNodes: visibleNodes,
    nodeOffsetsByNid: _nodeOffsetsByNid,
    nodeExtentsByNid: _nodeExtentsByNid,
    findFirstVisibleIndex: _findFirstVisibleIndex,
    // Guarded so a maxStickyDepth == 0 tree never pays the getter's
    // fall-off-frame cumulative build for a probe that returns
    // immediately.
    freshOffsetAt: _maxStickyDepth > 0 ? _stickyFreshOffsetAt : null,
  );

  // Force-create and layout any sticky nodes not already in cache region.
  // Filter by the cache-region flag rather than allocating a diff set.
  final newStickyNodes = <TKey>{};
  for (final id in stickyCandidates) {
    final nid = _controller.nidOf(id);
    if (nid < 0 || _inCacheRegionByNid[nid] == 0) {
      newStickyNodes.add(id);
    }
  }
  // True when the sticky block below measured a force-created row at a
  // different extent than its stored estimate and ran the full
  // `_recomputeOffsets`. The parentData refresh loop keys off this: the
  // measurement loop wrote cache-region parentData BEFORE that recompute,
  // so on these frames the cache-region skip premise is void and every
  // mounted child must be re-based on the recomputed offsets, or in-flow
  // rows paint one frame at pre-measure offsets while the sticky band
  // (served by the post-measure re-probe) sits at post-measure ones. The
  // visible symptom was a bare strip between an entering pinned header's
  // band bottom and the first in-flow row below it, exactly on its first
  // pinned frame (see `sticky_entering_root_handover_test.dart`).
  bool stickyMeasurementMovedOffsets = false;
  if (newStickyNodes.isNotEmpty) {
    invokeLayoutCallback<SliverConstraints>((
      SliverConstraints constraints,
    ) {
      for (final nodeId in newStickyNodes) {
        childManager?.createChild(nodeId);
      }
    });
    // Track whether any measured sticky extent actually differs from the
    // prior stored (estimated) extent. When all match, Pass 1's offsets
    // and subtree-bottom precompute are still valid, so both the O(N)
    // offset recompute and the O(3N) subtree-bottom precompute can be
    // skipped entirely.
    bool stickyExtentsChanged = false;
    for (final nodeId in newStickyNodes) {
      final nid = _controller.nidOf(nodeId);
      final priorExtent = _nodeExtentsByNid[nid];
      final extent = _layoutNodeChild(nodeId, crossAxisExtent);
      if (extent != null) {
        _nodeExtentsByNid[nid] = extent;
        if (extent != priorExtent) stickyExtentsChanged = true;
      }
    }
    if (stickyExtentsChanged) {
      if (_bulkCumulativesValid) {
        // Mirror Pass 2's extent-change handler: the full recompute
        // below walks EVERY visible nid, but under the bulk fast path
        // only cache-region slots are fresh, materialize the rest
        // first or the recompute overwrites correct offsets and this
        // frame's geometry.scrollExtent with stale-extent garbage.
        _materializeBulkStaleExtents(
          fromIndex: 0,
          cacheStartIndex: cacheStartIndex,
          cacheEndIndex: cacheEndIndex,
          visibleCount: visibleNodes.length,
        );
      }
      totalScrollExtent = _recomputeOffsets();
      // A full recompute rewrites every visible nid's slot, so the
      // array is authoritative even on a bulk frame.
      offsetsAuthoritative = true;
      stickyMeasurementMovedOffsets = true;
      if (_maxStickyDepth > 0 && !hasAnimations) {
        _sticky.precomputeStableSubtreeBottoms(
          visibleNodes: visibleNodes,
          nodeOffsetsByNid: _nodeOffsetsByNid,
          nodeExtentsByNid: _nodeExtentsByNid,
        );
        _sticky.dirty = false;
      }
    }
    // Always write the newly-created sticky children's layoutOffset,
    // they were outside the cache region during Pass 1 and never had it set.
    for (final nodeId in newStickyNodes) {
      final child = getChildForNode(nodeId);
      if (child == null) continue;
      final parentData = child.parentData! as SliverTreeParentData;
      parentData.layoutOffset =
          _nodeOffsetsByNid[_controller.nidOf(nodeId)];
    }

    // Re-probe, and ONLY here. Force-creating measured children, which
    // can change `_nodeExtentsByNid` and therefore every offset after
    // them, is the one thing between the first walk and now that can
    // move the probe's inputs. A never-measured candidate also entered
    // the first walk with `getEstimatedExtent`'s `defaultExtent`
    // fallback and needs its real height reflected in `pinnedY`.
    _sticky.computeStickyHeaders(
      scrollOffset: scrollOffset,
      overlap: constraints.overlap,
      visibleNodes: visibleNodes,
      nodeOffsetsByNid: _nodeOffsetsByNid,
      nodeExtentsByNid: _nodeExtentsByNid,
      findFirstVisibleIndex: _findFirstVisibleIndex,
      // Re-evaluated here rather than reused from the first probe:
      // the force-create block above can flip the authority mid-layout
      // (`_materializeBulkStaleExtents` + `_recomputeOffsets` drop the
      // fast path and refresh EVERY per-nid slot, so the getter then
      // correctly serves the fall-off cumulative or the arrays).
      freshOffsetAt: _stickyFreshOffsetAt,
    );
  }

  // ────────────────────────────────────────────────────────────────────────
  // Calculate paint extent
  // ────────────────────────────────────────────────────────────────────────
  double paintExtent = 0.0;

  final startIndex = _findFirstVisibleIndex(scrollOffset);
  final orderNids = controller.orderNidsView;
  for (int i = startIndex; i < visibleNodes.length; i++) {
    final nid = orderNids[i];
    final offset = _nodeOffsetsByNid[nid];
    final extent = _nodeExtentsByNid[nid];
    final endOfNode = offset + extent;

    if (offset >= scrollOffset + remainingPaintExtent) break;

    final visibleStart = math.max(offset, scrollOffset);
    final visibleEnd = math.min(
      endOfNode,
      scrollOffset + remainingPaintExtent,
    );
    // Only add positive contributions (can be negative when scrolled past
    // content)
    if (visibleEnd > visibleStart) {
      paintExtent += visibleEnd - visibleStart;
    }
  }

  // Ensure paintExtent covers sticky headers. Sticky headers
  // paint at pinnedY (near viewport top) but content may have scrolled far
  // enough that the natural paint extent doesn't cover them, causing
  // clipping.
  bool stickyInflationClamped = false;
  for (final sticky in _sticky.headers) {
    final stickyBottom = sticky.pinnedY + sticky.extent;
    if (stickyBottom > remainingPaintExtent) {
      // This header would extend past our paint budget and overlap the
      // next sliver. We cannot relocate it here (pinnedY is final), but
      // flagging visual overflow ensures the viewport clips us to
      // paintExtent so it doesn't bleed through.
      stickyInflationClamped = true;
    }
    if (stickyBottom > paintExtent) paintExtent = stickyBottom;
  }

  // Bound by both the viewport's remaining paint budget AND the sliver's own
  // maxPaintExtent (= totalScrollExtent below). Sticky inflation above can
  // push paintExtent above totalScrollExtent for a short tree with a tall
  // header; without this second cap, `SliverGeometry.debugAssertIsValid`
  // fails on `paintExtent <= maxPaintExtent`.
  paintExtent = paintExtent.clamp(
    0.0,
    math.min(remainingPaintExtent, totalScrollExtent),
  );

  geometry = SliverGeometry(
    scrollExtent: totalScrollExtent,
    paintExtent: paintExtent,
    maxPaintExtent: totalScrollExtent,
    // Protocol: the cache-region portion THIS sliver consumes,
    // accounts for where the cache region starts relative to the
    // sliver, unlike min(remainingCacheExtent, totalScrollExtent),
    // which over-reports once the sliver is scrolled past the cache
    // origin and starves every subsequent sliver's cache budget.
    cacheExtent: calculateCacheOffset(
      constraints,
      from: 0.0,
      to: totalScrollExtent,
    ),
    // Overflow means: our painted region would exceed the portion of the
    // scroll extent visible within our own paintExtent. Comparing against
    // remainingPaintExtent (which includes space occupied by later slivers)
    // gave false negatives and missed clipping. Also flag when a sticky
    // header's inflated bottom was clamped against remainingPaintExtent.
    // The `scrollOffset > 0` clause mirrors RenderSliverMultiBoxAdaptor:
    // when the first visible row starts before scrollOffset, it paints at
    // a negative y relative to the sliver's paint origin. Without this
    // flag, the viewport skips its clip layer and the partial top row
    // spills above the sliver, visible at max scroll extent, where the
    // "content extends below" clause is false.
    hasVisualOverflow:
        stickyInflationClamped ||
        scrollOffset + paintExtent < totalScrollExtent ||
        scrollOffset > 0.0,
  );

  // Refresh parentData (layoutOffset, indent, visibleExtent) for
  // children mounted in a prior frame that now fall outside
  // [cacheStartIndex, cacheEndIndex). The admission cap deliberately
  // limits *new* mounts to prevent mass-mounting during large
  // expansions, but already-mounted rows below an expanding subtree keep
  // their pre-expand parentData and would otherwise paint at stale
  // positions until something re-admits them to cache.
  //
  // Placed after the sticky pass so any _recomputeOffsets triggered by
  // stickyExtentsChanged has already landed in _nodeOffsetsByNid /
  // cumulatives before we write to parentData.
  //
  // Runs UNCONDITIONALLY when there are mounted children, gating on
  // `hasAnimations || hasActiveSlides` leaves a staleness window. A
  // structural mutation that installs no slides (e.g. a rapid cascaded
  // toggle whose composedY/X both round to 0, so the engine clears the
  // entry) updates parentData only for in-cache rows; a following pure
  // scroll then sees neither flag set and skips the refresh. Three ways
  // that shows up:
  //
  //   * Stale `layoutOffset`: the row paints at its OLD structural Y,
  //     "stuck until I scroll again".
  //   * Stale `indent` on a depth-changing reparent: painted X =
  //     `parentData.indent + slideDeltaX` resolves to
  //     `oldIndent + (oldIndent - newIndent)` at slide t=0.
  //   * Stale `visibleExtent`: `_paintRow`'s clip-and-translate slices
  //     the wrong portion of the child box.
  //
  // Cost is O(_children): bounded by the cache region plus retained
  // off-cache rows (edge ghosts, exit phantoms, slide-active rows),
  // NOT O(visibleNodes), which on a dense expandAll would walk 10^5
  // entries per frame for ~50 writes.
  //
  // In non-bulk mode `_nodeOffsetsByNid` is stale for off-cache rows, so
  // a fresh structural cumulative is computed on demand below.
  if (_children.isNotEmpty) {
    // Built LAZILY on the first off-cache, live, visible child the loop
    // actually encounters. On the common scroll frame every mounted
    // child is in-cache, so the O(N_visible) accumulation, a
    // Float64List(N+1) allocation plus N extent-chain resolutions, the
    // dominant steady-state cost for large trees if done
    // unconditionally, never runs.
    Float64List? freshCumulative;
    for (final nodeId in _children.keys) {
      debugLastParentDataRefreshIterationCount++;
      final child = _children[nodeId]!;
      final nid = _controller.nidOf(nodeId);
      if (nid < 0) {
        // Dead key: purge handled by stale-node eviction.
        continue;
      }
      // Cache-region children already had their parentData written by
      // the measurement loop. Skip them. Non-admitted-but-mounted
      // children inside [cacheStartIndex, cacheEndIndex) have
      // `_inCacheRegionByNid[nid] == 0` here and would also have been
      // touched by the measurement loop via `_layoutNodeChild`, letting
      // them through is a redundant (but correctness-safe) re-write of
      // the same offset. Cost is one field assignment per such row;
      // the case is rare (off-screen exits during a collapse).
      //
      // EXCEPT when the sticky block's force-create measurement moved the
      // offsets: the measurement loop's writes predate that recompute, so
      // the skip would leave cache-region rows painting one frame at
      // pre-measure offsets while the sticky band uses post-measure ones.
      if (!stickyMeasurementMovedOffsets &&
          nid < _inCacheRegionByNid.length &&
          _inCacheRegionByNid[nid] != 0) {
        continue;
      }
      final visIdx = _controller.visibleIndexOfNid(nid);
      if (visIdx < 0) {
        // Mounted but no longer in visible order, happens transiently
        // during structure changes. Eviction sweeps it next.
        continue;
      }
      final double offset;
      if (stickyMeasurementMovedOffsets) {
        // The sticky block's full `_recomputeOffsets` just rewrote every
        // visible nid's slot, so the array is authoritative for in-cache
        // and off-cache rows alike; no cumulative needs building. (The
        // bulk fast path cannot be active here: the sticky block
        // materializes stale bulk extents before recomputing, which
        // invalidates the cumulatives.)
        offset = _nodeOffsetsByNid[nid];
      } else if (_bulkCumulativesValid) {
        // Bulk-only fast path: per-nid offset slots are not kept fresh
        // for out-of-cache-region nids, derive from cumulatives.
        offset = _offsetAtVisibleIndex(visIdx);
      } else if (!offsetsAuthoritative) {
        // Bulk frame that left the fast path mid-frame: the slots
        // before the first changed row are still the previous
        // non-bulk frame's, so derive from a fresh cumulative.
        final cumulative =
            freshCumulative ??= _buildParentDataRefreshCumulative(
              visibleNodes.length,
            );
        offset = cumulative[visIdx];
      } else {
        // Non-bulk: the per-nid slot is authoritative for off-cache
        // rows too (see `offsetsAuthoritative` above), which spares the
        // O(N_visible) prefix sum this used to build on EVERY frame
        // that force-mounted a pinned header outside the cache region.
        offset = _nodeOffsetsByNid[nid];
        assert(() {
          // Cross-check against the cumulative under the invariant
          // suites only: building it here unconditionally would
          // reintroduce the cost in debug and defeat the perf pin.
          if (!TreeController.debugFullConsistencyChecks) {
            return true;
          }
          final cumulative =
              freshCumulative ??= _buildParentDataRefreshCumulative(
                visibleNodes.length,
              );
          assert(
            cumulative[visIdx] == offset,
            "_nodeOffsetsByNid stale for off-cache nid $nid: slot "
            "$offset, cumulative ${cumulative[visIdx]}",
          );
          return true;
        }());
      }
      final parentData = child.parentData! as SliverTreeParentData;
      parentData.layoutOffset = offset;
      // Refresh indent + visibleExtent against the controller's live
      // values. Both are read directly from the controller (no per-
      // child layout call required), matching the assignments
      // `_layoutNodeChild` would have performed on a cache-region row.
      // `controller.getIndent` reads `getDepth(key) * indentWidth`,
      // and `controller.getCurrentExtentNid` resolves the
      // bulk, then operation-group, then standalone, then fullExtent chain,
      // the
      // same chain `_layoutNodeChild` consumes via `getAnimatedExtent`.
      parentData.indent = controller.getIndent(nodeId);
      parentData.visibleExtent = controller.getCurrentExtentNid(nid);
    }
  }

  _lastVisibleNodeCount = visibleNodes.length;
  _lastTotalScrollExtent = totalScrollExtent;
  _animationsWereActive = hasAnimations;

  childManager?.didFinishLayout();
}