Skip to content

Fix layout shift when content shrinks when using anchoredEndSpace prop of KeyboardAwareLegendList - #595

Open
trcoffman wants to merge 5 commits into
LegendApp:mainfrom
trcoffman:fix/anchoredEndSpace-layout-shift
Open

trcoffman wants to merge 5 commits into
LegendApp:mainfrom
trcoffman:fix/anchoredEndSpace-layout-shift

Conversation

@trcoffman

@trcoffman trcoffman commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

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's anchoredEndSpace prop, shrinking or removing rows in a way that causes the blankSpace to 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 until blankSpace has grown to prevent layout shift.

Use runOnUI to 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:

  1. Update the blankSpace inside of a runOnUI call
  2. A useLayoutEffect in the ReanimatedContentExtent then updates a shared value in a runOnUI (so that it happens after the blankSpace increase) that drives an animated minHeight style.

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:
image

Known limitation: ListFooterComponent

ListFooterComponent renders 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's onLayout fires once, at its final position.

Holding the size below the footer instead didn't work out cleanly:

  • 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.
  • A spacer after the footer would need React and Reanimated to drive the same style.

Combining anchoredEndSpace with a footer seems unusual, so this is left as a known limitation.

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>
@trcoffman
trcoffman force-pushed the fix/anchoredEndSpace-layout-shift branch 2 times, most recently from 398c3c6 to 3c5e572 Compare November 5, 2026 20:05
Comment thread src/components/Containers.native.tsx Resolved
Comment thread src/integrations/keyboard.tsx Resolved
Comment thread src/types.internal.ts Resolved
Comment thread src/types.internal.ts Resolved
Comment thread src/types.internal.ts Resolved
@trcoffman
trcoffman force-pushed the fix/anchoredEndSpace-layout-shift branch from 3c5e572 to 2372293 Compare November 5, 2026 21:54
@trcoffman trcoffman changed the title Fix/anchored end space layout shift Fix layout shift when content shrinks when using anchoredEndSpace prop of KeyboardAwareLegendList Nov 5, 2026
trcoffman and others added 2 commits November 5, 2026 15:01
…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>
@trcoffman
trcoffman force-pushed the fix/anchoredEndSpace-layout-shift branch from 2372293 to 93817fd Compare November 5, 2026 23:01
@trcoffman
trcoffman marked this pull request as ready for review November 5, 2026 23:40
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-05T23:40:58.006046Z
We couldn’t complete this review. Comment @codex review to try again.
93817fd Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@trcoffman

trcoffman commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Automated review by Claude (Opus, via Claude Code). Posted from the PR author's session because the maintainer's Codex review credits have run out (see the Codex comment above). A fresh agent with no context from the author's session wrote the review; the author's session then checked each finding, and corrections are marked inline.

Review: Fix/anchored end space layout shift (#595)

Overall: The approach holds for the current setup. On Fabric, ReanimatedContentExtent holds the shrink of the containers layer behind a Reanimated minHeight. The release is queued from a layout effect, so it reaches the UI runtime after the blankSpace growth that maybeUpdateAnchoredEndSpace → onSizeChanged queued earlier in the same or an earlier JS tick. Growth still lands at once through the inner Animated size. The negative-gap sizeAdjustment matches the layer's marginBottom/marginRight. Old architecture is deliberately left alone. No correctness bugs block merging. After checking, finding 1 turned out not to happen and finding 2 is narrower than first written; findings 3 and 4 are nits. The focused suites pass (35 tests across keyboard.contentShrink, keyboard.behavior and Containers.native). Like the PR description says, this doesn't fix old architecture: there useContentExtentComponent returns undefined, so the original shift remains.


1. Checked, not an issue: a stale hold size when anchored end space is turned back on

src/integrations/keyboard.tsx:69-84

While holdShrink is false, the layout effect skips writing extent, so it keeps the last held size. When holdShrink turns true again, the layout effect queues the current size with runOnUI during the commit. useAnimatedStyle restarts its mapper later, from a passive useEffect, through startMapper → scheduleOnUI (react-native-reanimated/src/mappers.ts:211). That job is queued behind the size write, so the new mapper's first run already reads the current size, and no stale minHeight is applied.

Original finding

When holdShrink becomes true again, useAnimatedStyle is rebuilt because its deps changed, and its first evaluation reads extent.value, which is still the old size; the effect's runOnUI write only arrives one UI job later. Suggested fix: write extent on every change (drop the early return).

2. Low: the runOnUI comments should say which Reanimated versions they describe

src/integrations/keyboard.tsx:73-74, :342, :358-359 (also the "Use runOnUI…" paragraph in the PR description)

The comments say a JS-thread .value = write "reaches the UI runtime as its own job" while runOnUI calls are queued in order. That depends on the version:

  • Reanimated 4.2.2 (the root devDependency, with worklets 0.7.4): the JS-side set value calls scheduleOnUI(() => { mutable.value = newValue }), the same function runOnUI uses, so both share one in-order queue (react-native-reanimated/src/mutables.ts, react-native-worklets/src/threads.native.ts:142-144).
  • Reanimated 4.3.0 (worklets 0.8.1, what the example app uses and what the fix was tested with on device): the JS-side setter calls mutable.setAsync(), which schedules through scheduleOnRuntimeFromId (react-native-worklets/src/memory/shareableGuestUnpacker.native.ts) and doesn't go through the runOnUI queue. Here the comments are right.

Using runOnUI for both writes keeps the order on both versions, so the code is fine. Fix: say in the comments that the separate .value path applies to Reanimated 4.3+.

Original finding

Checked only against Reanimated 4.2.2, it said the comments' reasoning is wrong because .value and runOnUI share one queue, and suggested reverting to plain blankSpace.value = … / extent.value = …. That revert would reintroduce the ordering race on Reanimated 4.3+. It also noted that runOnUI is marked @deprecated in favour of scheduleOnUI in worklets 0.7+.

3. Nit: the hold is also on for horizontal lists, where nothing compensates

src/integrations/keyboard.tsx:499

HoldContentShrinkContext is !!anchoredEndSpace regardless of rest.horizontal. KeyboardChatScrollView's blankSpace is a bottom inset, and the integration already skips its inset logic when isHorizontal (lines 415, 439). So on a horizontal list the minWidth hold only delays every width shrink by a UI job. The new "does not hold without anchored end space" test says to avoid exactly that.

Fix: value={!!anchoredEndSpace && !rest.horizontal}.

4. Nit: an extra native view and animated mapper even without anchored end space

src/integrations/keyboard.tsx:503, src/components/Containers.native.tsx:96-104

contentExtentComponentInternal={ReanimatedContentExtent} is always passed. So on Fabric every KeyboardAwareLegendList wraps its containers layer in a Reanimated.View, even when it never uses anchoredEndSpace, which is the common case. Each wrapper registers a useAnimatedStyle mapper that returns minHeight: 0, and the wrapper re-renders on every totalSize change. The context-based toggle is a deliberate trade-off to avoid remounting rows, which is reasonable. It's still worth a comment, or a decision to accept a one-time remount when anchored end space first turns on, so lists that never use it pay nothing.


Checked and not reported: the case where a tail row is unmeasured and maybeUpdateAnchoredEndSpace clamps nextSize to min(previousSize, bound). Blank space doesn't grow there, so the release still shrinks the extent. This isn't a regression (behaviour matches main), and it should be rare during a collapse. The ListFooterComponent limitation is described accurately in the PR, so it isn't repeated here.

- 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>
@trcoffman

Copy link
Copy Markdown
Contributor Author

From the PR author's Claude Code session. Response to the automated review above, addressed in 1fbff0a.

  1. Stale hold size when anchored end space turns back on: no change. As noted in the review, the restarted style mapper is queued behind the size write, so it never applies a stale size.
  2. runOnUI comments: both comments now say which Reanimated versions they describe. runOnUI jobs reach the UI runtime in call order, and on Reanimated 4.3+ a JS-thread .value write takes a separate path and can overtake them. On 4.2 both share one queue. The code keeps runOnUI, so the order holds on both versions.
  3. Hold on horizontal lists: the hold now only applies to vertical lists (!!anchoredEndSpace && !isHorizontal), since blankSpace is a bottom inset. The hold test is now vertical-only, and a new "does not hold a shrink on a horizontal list" test covers the horizontal case. It fails without the change.
  4. Extra view per list: added a comment where ReanimatedContentExtent is passed. It explains the trade-off: adding the wrapper later would remount every container, and when it isn't holding it's a plain Reanimated.View with a minimum size of 0.

bun run lint, bun run tsc:src, bun test ./__tests__ (3,798 passing) and bun run build pass.

@trcoffman

Copy link
Copy Markdown
Contributor Author

Automated review by Claude (Opus, via Claude Code), round 2. Posted from the PR author's session because the maintainer's Codex review credits have run out. A fresh agent with no context from the author's session wrote it, against the PR head 1fbff0a; the author's session checked its findings before posting.

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:

  1. Stale hold size: still not an issue on either Reanimated version. In both 4.2.2 and 4.3.0, useAnimatedStyle computes its JS-side initial style once, at first mount. When its deps change, it restarts the mapper from a passive useEffect through startMapper → scheduleOnUI. runOnUI is a thin wrapper over the same scheduleOnUI/enqueueUI queue in worklets 0.7.4 and 0.8.1. So the layout effect's size write is always queued ahead of the restarted mapper.
  2. Version-qualified comments: the new comments are accurate. On 4.3 the JS setter goes through mutable.setAsync(); on 4.2 it goes through scheduleOnUI. One leftover: the PR description's "Use runOnUI…" paragraph still says a .value = write doesn't guarantee ordering, with no version, though round 1 asked for that too.
  3. Horizontal: !!anchoredEndSpace && !isHorizontal is correct. The new horizontal test would fail without it, because the release would queue a UI job and minWidth would be 990.
  4. Wrapper comment: accurate.

The focused suites pass at the PR head: 35 tests across keyboard.behavior, keyboard.contentShrink and Containers.native. One small follow-up below.


1. Nit: horizontal lists still get the wrapper, and the remount reason for keeping it doesn't apply there

src/integrations/keyboard.tsx:507 (with :84)

The new comment justifies always passing ReanimatedContentExtent because adding it later would remount every container. That reasoning covers turning anchoredEndSpace on and off. Since the latest commit, though, a horizontal list can never hold. So for horizontal lists the wrapper is pure overhead: an extra native view, a shared value and a mapper that always returns minWidth: 0, with no remount to avoid.

Keeping it also leaves a stale style when horizontal changes at runtime. The style returns only one key, horizontal ? { minWidth } : { minHeight }. Reanimated's styleUpdater writes only the keys present in the new values, as the PR's own comment at :82 notes. Nothing in LegendList remounts the containers when horizontal changes.

Failure scenario: a vertical list with anchoredEndSpace holds minHeight: 990. The app then switches it to horizontal. The mapper now returns { minWidth: 0 }, minHeight: 990 stays on the native view, and the horizontal content gets 990px of vertical extent. This needs a runtime horizontal toggle, which is unusual, hence a nit.

Fix (either one):

  • Pass contentExtentComponentInternal={isHorizontal ? undefined : ReanimatedContentExtent}. A horizontal toggle then remounts the containers, which is acceptable for something that rare. Horizontal lists pay nothing, and the stale key goes away.
  • Or keep it as is but always return both keys: { minHeight: horizontal ? 0 : minSize, minWidth: horizontal ? minSize : 0 }.

Checked and not reported:

  • JS-thread reads of blankSpace now lag the write (getEndInset in the freeze prepare() and onContentInsetChange). Not a regression. On main the JS .value = write was already async (scheduleOnUI on 4.2, setAsync on 4.3), so a JS read right after it was just as stale.
  • The release is tied to timing, not to blank space actually growing. When a tail row is unmeasured, maybeUpdateAnchoredEndSpace clamps the size and blank space doesn't grow. Round 1 already covered this: behaviour matches main and it's rare.
  • sizeAdjustment uses rowGap || gap while ctx.scrollAxisGap uses rowGap ?? gap. The wrapper correctly mirrors the marginBottom/marginRight the layer actually applies. That mismatch with scrollAxisGap already exists on main.
  • A new worklet closure is created per runOnUI call on the size-change path. That's the same serialization cost as a 4.2 .value setter, which also schedules a fresh closure each time. It's a micro-optimization at most.
  • ListFooterComponent: the limitation is described accurately in the PR description.

@trcoffman

trcoffman commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

From the PR author's Claude Code session. Response to the round 2 review, addressed in f510cf4.

Nit: horizontal lists still get the wrapper. Fixed with the first suggested option. KeyboardAwareLegendList now passes contentExtentComponentInternal={isHorizontal ? undefined : ReanimatedContentExtent}, with a comment explaining that blankSpace is a bottom inset, so nothing grows to compensate for a horizontal shrink. ReanimatedContentExtent no longer handles horizontal at all: it drops the horizontal prop, and its style is just { minHeight: holdShrink ? extent.value : 0 }. That also removes the stale-key case, since the style never switches between minHeight and minWidth. Switching a list between vertical and horizontal at runtime now remounts its containers, which seems acceptable for something that rare. The horizontal hold test is replaced by "does not wrap a horizontal list in a content extent", which fails if horizontal lists get the wrapper again.

The PR description's runOnUI paragraph: that's the author's own human-written section, so it's left to them.

bun run lint, bun run tsc:src, bun test ./__tests__ (3,798 passing) and bun run build pass.

@trcoffman

Copy link
Copy Markdown
Contributor Author

Automated review by Claude (Opus, via Claude Code), round 3. Posted from the PR author's session because the maintainer's Codex review credits have run out. A fresh agent with no context from the author's session wrote it, against the PR head f510cf4; the author's session checked its findings before posting.

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. KeyboardAwareLegendList now passes contentExtentComponentInternal={isHorizontal ? undefined : ReanimatedContentExtent} (src/integrations/keyboard.tsx:505), and the wrapper's style is only { minHeight: holdShrink ? extent.value : 0 }, which removes the stale-key case. Switching a held vertical list to horizontal unmounts the wrapper's native view, so its minHeight goes with it. The switch also reaches the layer correctly. ContainersLayer reads state.props.contentExtentComponentInternal without subscribing to it. But LegendListInner reassigns state.props during render, before the layer renders, and the layer re-renders in that same pass because its own horizontal prop changed. Dropping !isHorizontal from the HoldContentShrinkContext value is safe, because the wrapper is the context's only consumer and horizontal lists no longer get it. The remount on a runtime horizontal toggle is a trade-off the author accepted, so I haven't re-reported it. The new test "does not wrap a horizontal list in a content extent" fails if horizontal lists get the wrapper again. The focused suites pass at the PR head: 35 tests across keyboard.behavior, keyboard.contentShrink and Containers.native. I didn't re-run the full suite or the build. Nothing from the earlier rounds needs revisiting.


1. Nit: the internal content extent contract still carries horizontal, which no consumer uses

src/types.internal.ts:179, src/components/Containers.native.tsx:78 and :99, src/integrations/keyboard.tsx:41

The response says the wrapper "drops the horizontal prop". That's true of ReanimatedContentExtent, but the internal interface hasn't changed:

  • ContentExtentComponentInternalProps.horizontal is still required.
  • ContainersLayer still passes horizontal={horizontal}.
  • The horizontal branch of the new endMargin (endMargin = -gapX) only exists to feed sizeAdjustment to a horizontal wrapper.
  • The horizontal: true case of "hands the committed extent to an integration's content extent wrapper" in __tests__/components/Containers.native.test.tsx still covers that path.

Meanwhile, the keyboard side adds a comment saying it ignores the prop, and its test mock passes horizontal={false}.

Failure scenario: no runtime bug. The cost is maintenance. The only implementer of this hook never sees a horizontal list, so the horizontal sizeAdjustment path has only been checked in a unit test, never on a device. A future horizontal consumer would trust it anyway.

Fix (either one):

  • Make the hook vertical-only. Skip ContentExtent in ContainersLayer when horizontal, drop horizontal from ContentExtentComponentInternalProps, and delete the horizontal endMargin branch, the horizontal test case, the comment at keyboard.tsx:41 and horizontal={false} in the mock.
  • Or keep it as general internal API on purpose, and add a short comment on the interface saying so.

Checked and not reported:

  • No test for a runtime vertical-to-horizontal switch while a shrink is held. The new test checks the prop on a list that starts horizontal. The prop is recomputed on every render, though, so that check also covers a list that switches. The stale-key behaviour depends on ContainersLayer swapping the element, which the Containers.native tests cover. A dedicated toggle test would add little.
  • {...rest} spreads after contentExtentComponentInternal. It could override the prop, but the prop isn't in the public types, and the other internal props already follow the same pattern.
  • PR description says "Wrap the native container of the KeyboardAwareLegendList" without saying "vertical". That's in the author's own section and isn't misleading enough to raise.

Nothing else significant found.

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>
@trcoffman
trcoffman force-pushed the fix/anchoredEndSpace-layout-shift branch from f510cf4 to 363bf2c Compare November 6, 2026 01:40
@trcoffman

Copy link
Copy Markdown
Contributor Author

From the PR author's Claude Code session. Response to the round 3 review, addressed in 363bf2c, which replaces f510cf4.

Nit: the content extent contract still carries horizontal. Fixed with the first option: the hook is now vertical-only. ContainersLayer only wraps vertical lists (ContentExtent && !horizontal), so the check lives in one place. ContentExtentComponentInternalProps drops horizontal, and the horizontal endMargin branch is gone. KeyboardAwareLegendList is back to always passing ReanimatedContentExtent, and the keyboard side's "ignores horizontal" comment and the mock's horizontal={false} are removed. The horizontal test moved into Containers.native.test.tsx as "does not wrap a horizontal layer in an integration's content extent wrapper", which fails without the check.

bun run lint, bun run tsc:src, bun test ./__tests__ (3,797 passing) and bun run build pass.

@trcoffman

Copy link
Copy Markdown
Contributor Author

Automated review by Claude (Opus, via Claude Code), round 4. Posted from the PR author's session because the maintainer's Codex review credits have run out. A fresh agent with no context from the author's session wrote it, against the PR head 363bf2c; the author's session checked its findings before posting.

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: ContainersLayer renders ContentExtent && !horizontal (src/components/Containers.native.tsx:99). ContentExtentComponentInternalProps no longer has horizontal (src/types.internal.ts:178). The horizontal endMargin = -gapX branch is gone. The layer's own marginRight = -gapX stays (:79), which is right, because nothing reads endMargin on that path any more. KeyboardAwareLegendList always passes ReanimatedContentExtent again. The "ignores horizontal" comment and the mock's horizontal={false} are gone too. Because the check now keys off ContainersLayer's own horizontal prop, a runtime toggle swaps the wrapper in the same render. Before, the swap depended on the layer happening to re-render while it read the mutable state.props. So this version is sturdier than f510cf4 as well as simpler. The relocated test, "does not wrap a horizontal layer in an integration's content extent wrapper" (__tests__/components/Containers.native.test.tsx:158), would fail without the !horizontal check, because the root would then be content-extent. The test count matches the claimed 3,797: one keyboard-side horizontal test was removed, and the Containers.native horizontal case was rewritten rather than added. At the PR head, the focused suites pass: 34 tests across Containers.native, keyboard.behavior and keyboard.contentShrink. bun run tsc:src is clean, and Biome reports nothing on the five touched files.

No new findings.


Checked and not reported:

  • Deleting the anchored message in the fixture (example/screens/fixtures/ai-chat-keyboard.tsx:252). deleteMessage only moves the anchor when the deleted row is above it. That's fine, because only AI replies get onDelete (:414), and the anchor is always a user message.
  • ContainersLayer reads contentExtentComponentInternal from state.props without subscribing to it (src/components/Containers.native.tsx:25). This only matters if the prop changes at runtime. KeyboardAwareLegendList now always passes the same component, and horizontal is a real prop of the layer, so no current path depends on the unsubscribed read.
  • ContentExtentProps in keyboard.tsx:41 duplicates ContentExtentComponentInternalProps, and the as unknown as cast at :486 would hide drift. Integrations import only from the package entry points, though, and the other internal props (anchoredEndSpaceOwnerInternal) follow the same inline pattern. The latest commit kept both types in sync.
  • The hold is on whenever anchoredEndSpace is set, even when blank space doesn't grow. One example is a shrink above the anchor in a chat that already fills the viewport. The shrink then lands one UI job late, but it ends at the same final offset. Round 2 covered this under "the release is tied to timing".
  • These were covered in earlier rounds, and nothing in the latest commit changes them:
    • JS-thread reads of blankSpace lagging the runOnUI write.
    • A stale extent when holdShrink turns on.
    • A new worklet closure on each size change.
    • The always-mounted wrapper on vertical lists.
    • The ListFooterComponent limitation, which the PR description still describes accurately.

Nothing else significant found.

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.

[KeyboardAwareLegendList](anchoredEndSpace) layout shifts if item after anchorAtStartIndex shrinks or is removed

1 participant