Repository navigation
Fix layout shift when content shrinks when using anchoredEndSpace prop of KeyboardAwareLegendList - #595
Fix layout shift when content shrinks when using anchoredEndSpace prop of KeyboardAwareLegendList#595trcoffman wants to merge 5 commits into
anchoredEndSpace prop of KeyboardAwareLegendList#595Conversation
Tapping an AI reply collapses it to six lines or re-expands it, and replies start expanded. Collapse state lives in a set of collapsed ids fed through extraData so recycled rows re-render. Long-pressing a reply deletes it, which shrinks the content the same way a collapse does but removes the row. Deleting a message above the anchored one moves anchorAtStartIndex down so the same user message stays anchored. Rows are keyed by unique message ids instead of their index. With index keys, a deleted row's cached height stayed under its key, and the next message sent took that key and its stale height, so it and the anchored blank space jumped when it was measured. The list also sets applyWorkaroundForContentInsetHitTestBug, as the translucent chat example does: on RN 0.81+ iOS, the ScrollView's contentInset area (here the anchored blank space) otherwise ignores touches and scroll gestures (react/react-native#54123). A "Collapse: instant / animated (300ms)" toggle switches replies from the numberOfLines collapse, which shrinks the row in one step, to a Reanimated withTiming of the text's maxHeight. The row's layout then changes every frame, so the list measures a run of small shrinks under the anchored blank space instead of one jump. The clipping wrapper uses overflow: "scroll": with "hidden", Yoga measures the text at most the animated maxHeight, which reports a clipped full height (expand jumps at the end) and narrows the text so its last visible line is cut off horizontally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398c3c6 to
3c5e572
Compare
3c5e572 to
2372293
Compare
anchoredEndSpace prop of KeyboardAwareLegendList
…e grows The list commits a shrinking content extent through React, which Fabric mounts at once, while the anchored blank space that grows to compensate reaches the native contentInset (iOS) or scroll padding (Android) on Reanimated's next frame. Both platforms clamp the offset in between, so the anchored message drops when a row after it collapses. Wrap the native containers layer, through a new internal contentExtentComponentInternal bridge, in a Reanimated view whose minHeight holds the previous extent. A layout effect releases it with runOnUI after React commits the shrink. That queues the release behind both the grown blank space and React's mount of the rows and ScrollAdjust, so the extent shrinks with or after the larger inset (within one commit iOS applies contentInset before contentSize and Android applies padding before child layout) and never ahead of MVCP. Growth still lands at once through the inner Animated size. The blank space write also moves to runOnUI: JS-thread shared value writes reach the UI runtime one by one, so a frame could land between them. The wrapper is resolved at import time on the new architecture only, like the committed minimum size; old architecture keeps its Animated extent. Apps need no changes: the ai-chat-keyboard fixture collapses replies with plain state, instantly or animated. Verified on the iOS simulator and an Android emulator. Tests cover the committed size and gap adjustment handed to the wrapper on both axes, the old-architecture opt-out, the held shrink releasing only after its commit, and the grown blank space being queued ahead of the release. Known limitation: a ListFooterComponent renders after the containers layer, so while the wrapper holds the previous extent the footer stays where it was. When a row collapses, a gap opens under the last row and the footer moves up only when the release reaches the UI runtime, one UI job after React's commit (which can be a frame later). The footer's onLayout fires once, at its final position. Holding the size below the footer instead isn't clean: Reanimated can't animate the scroll view's contentContainerStyle, wrapping all of the content in one view would break contentContainerStyle layouts and the align-items-at-end spacer, and a trailing spacer would need React and Reanimated to drive the same style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd space Follow-ups from review of the content extent hold: - ReanimatedContentExtent only holds a shrink when anchoredEndSpace is set, read from a context so the wrapper's identity never changes. Without anchored end space nothing grows to compensate, so the hold only delayed the content (and any footer below it) by a UI job. Swapping the component instead would remount every container whenever anchoredEndSpace toggled. - The blankSpace reset when anchoredEndSpace is removed goes through runOnUI like onSizeChanged, so it can't overtake a size queued in the same JS task and leave stale blank space behind. - A new isolated test mounts the real LegendList inside KeyboardAwareLegendList, collapses a measured row and replays the queued UI jobs in order, checking that extent + blank space never drops below the viewport. The existing ordering test drove both steps by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2372293 to
93817fd
Compare
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
Review: Fix/anchored end space layout shift (#595)Overall: The approach holds for the current setup. On Fabric, 1. Checked, not an issue: a stale hold size when anchored end space is turned back on
While Original findingWhen 2. Low: the
|
- Only hold the shrink on vertical lists. blankSpace is a bottom inset, so on a horizontal list nothing grows to compensate and the minWidth hold only delayed every width shrink by a UI job. Tested alongside the no-anchored-end-space case. - Say which Reanimated versions the runOnUI comments describe: runOnUI jobs reach the UI runtime in call order, while on Reanimated 4.3+ a JS-thread .value write takes a separate path and can overtake them (on 4.2 both share one queue). - Document why ReanimatedContentExtent is passed even without anchored end space: adding it later would remount every container, and when it isn't holding it's a plain Reanimated.View with a minimum size of 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Review, round 2: Fix/anchored end space layout shift (#595)Overall: The round-1 responses hold up against the code, and I found nothing significant in the latest commit. I traced each one through the source:
The focused suites pass at the PR head: 35 tests across 1. Nit: horizontal lists still get the wrapper, and the remount reason for keeping it doesn't apply there
The new comment justifies always passing Keeping it also leaves a stale style when Failure scenario: a vertical list with Fix (either one):
Checked and not reported:
|
Nit: horizontal lists still get the wrapper. Fixed with the first suggested option. The PR description's
|
Review, round 3: Fix/anchored end space layout shift (#595)Overall: The round-2 response holds up, and the latest commit (f510cf4) doesn't introduce anything significant. 1. Nit: the internal content extent contract still carries
|
Horizontal lists can never hold a shrink (KeyboardAwareLegendList's blankSpace is a bottom inset, so nothing grows to compensate), but they still got the wrapper: an extra native view, a shared value and a mapper that always returned minWidth: 0. Its style also only ever wrote one key, so switching a held vertical list to horizontal left the held minHeight stuck on the native view. ContainersLayer now only wraps vertical lists, so the content extent contract drops its horizontal prop and the horizontal end-margin adjustment, and ReanimatedContentExtent only handles minHeight. Switching horizontal at runtime swaps the wrapper in or out, which remounts the containers; that's rare enough to accept. Covered by a test that a horizontal layer isn't wrapped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f510cf4 to
363bf2c
Compare
Nit: the content extent contract still carries
|
Review, round 4: Fix/anchored end space layout shift (#595)Overall: The round-3 response holds up, and I found nothing significant in the latest commit (363bf2c). The content extent hook is now vertical-only, and the check sits in one place: No new findings. Checked and not reported:
Nothing else significant found. |
Fixes #594
Human-written section
Feel free to close my PR and solve this in a different way if you want, since you know the library so much better than I do.
Make it so that when using
KeyboardAwareLegendList'sanchoredEndSpaceprop, shrinking or removing rows in a way that causes theblankSpaceto increase doesn't cause a layout shift.Wrap the native container of the KeyboardAwareLegendList with
ReanimatedContentExtent, a Reanimated View component that we use to hold the previous size untilblankSpacehas grown to prevent layout shift.Use
runOnUIto make sure that the reanimated operations happen in the right order, because just doing.value =from the JS thread has doesn't guarantee ordering.When onSizeChanged runs, we handle it by:
blankSpaceinside of arunOnUIcallReanimatedContentExtentthen updates a shared value in arunOnUI(so that it happens after the blankSpace increase) that drives an animatedminHeightstyle.This ensures that when the extent component shrinks, the blankSpace has already grown enough to prevent layout shift.
These 2 operations may or may not arrive on the same frame.
LLM generated below
Excalidraw file is embedded in the image:

Known limitation:
ListFooterComponentListFooterComponentrenders after the containers layer, so while the content extent wrapper holds the previous size, the footer stays where it was. When a row collapses, a gap opens under the last row, and the footer moves up only when the release reaches the UI runtime. That's one UI job after React's commit, which can be a frame later. The footer'sonLayoutfires once, at its final position.Holding the size below the footer instead didn't work out cleanly:
contentContainerStyle.contentContainerStylelayouts and the align-items-at-end spacer.Combining
anchoredEndSpacewith a footer seems unusual, so this is left as a known limitation.