From d007a73a378a0f5f8c675fd606949bf49c7cb99e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:53:32 +0200 Subject: [PATCH 1/7] fix(mobile): Stabilize thread feed layout and Markdown rendering Co-authored-by: codex --- .../ios/T3MarkdownTextShadowNode.h | 25 ++- .../ios/T3MarkdownTextShadowNode.mm | 49 +++-- .../src/SelectableMarkdownText.ios.tsx | 11 +- .../src/SelectableMarkdownText.types.ts | 1 + .../features/threads/ThreadDetailScreen.tsx | 25 ++- .../src/features/threads/ThreadFeed.tsx | 84 ++++++++- .../src/native/SelectableMarkdownText.ios.tsx | 24 ++- .../src/native/SelectableMarkdownText.tsx | 4 +- patches/@legendapp__list@3.2.0.patch | 172 ++++++++++++------ 9 files changed, 301 insertions(+), 94 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63..737330db1d4 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + + bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default; }; struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + + bool operator==(const T3MarkdownTextAttachmentRange&) const = default; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> { const LayoutConstraints& layoutConstraints) const override; private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; + // Content must be derived from the current children whenever it is needed. + // Yoga can invoke layout() on a fresh clone without ever calling + // measureContent() on it (for example when both dimensions are already + // exact), so caching measure-time content in mutable members and publishing + // it from layout() lets state fall behind the children and drop text. + struct Content { + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + }; + + Content buildContent(const LayoutContext& layoutContext) const; + void updateStateIfNeeded(Content&& content); }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb9..7a02094a4d3 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -72,15 +72,8 @@ static void applyAttachments( } } -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { +T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent( + const LayoutContext& layoutContext) const { const auto &baseProps = getConcreteProps(); auto baseTextAttributes = TextAttributes::defaultTextAttributes(); @@ -207,14 +200,23 @@ static void applyAttachments( } } - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; + return Content{ + std::move(baseAttributedString), + std::move(paragraphStyleRanges), + std::move(attachmentRanges), + }; +} + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + const auto content = buildContent(layoutContext); NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); + [RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges); + applyAttachments(convertedAttributedString, content.attachmentRanges); const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width @@ -255,10 +257,21 @@ static void applyAttachments( void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { ensureUnsealed(); + updateStateIfNeeded(buildContent(layoutContext)); +} + +void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) { + const auto &stateData = getStateData(); + if (stateData.attributedString == content.attributedString && + stateData.paragraphStyleRanges == content.paragraphStyleRanges && + stateData.attachmentRanges == content.attachmentRanges) { + return; + } + setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, + std::move(content.attributedString), + std::move(content.paragraphStyleRanges), + std::move(content.attachmentRanges), }); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ad..5c8d5bc97c2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -34,6 +34,7 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + fillWidth = false, preserveSoftBreaks = false, onLinkPress, marginTop = 0, @@ -63,7 +64,15 @@ export function SelectableMarkdownText({ // shrink-to-fit containers such as user-message bubbles. Yoga then gives // the native text node an unbounded second pass and the parent only clips // the resulting single-line width instead of reflowing it. - + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb6..c1287a3565f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly fillWidth?: boolean; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..10cadcc7b32 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,7 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -176,6 +176,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray { + onComposerLayout(event); + const height = event.nativeEvent.layout.height; + setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, + [onComposerLayout], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -408,6 +424,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread freeze={freeze} anchorMessageId={anchorMessageId} contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndEstimate={composerOverlayHeight + THREAD_FEED_COMPOSER_GAP} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} @@ -430,7 +447,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - + ; readonly anchorMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; + /** Effective resting end inset: measured composer overlay plus feed gap. */ + readonly contentInsetEndEstimate: number; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly contentMaxWidth?: number; @@ -937,6 +940,7 @@ function renderFeedEntry( hasNativeSelectableMarkdownText() ? ( + {props.loading ? : null} {props.title} {props.detail} @@ -1265,6 +1271,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); + const underflowCorrectionFrameRef = useRef(null); + const previousContentUnderflowsViewportRef = useRef(false); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); @@ -1273,6 +1281,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.layoutVariant === "split" ? 0 : windowWidth, ); const [viewportHeight, setViewportHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1395,6 +1404,47 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + const handleContentSizeChange = useCallback((_width: number, height: number) => { + setContentHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, []); + const contentUnderflowsViewport = + contentHeight > 0 && + viewportHeight > 0 && + contentHeight + props.contentInsetEndEstimate < viewportHeight - CONTENT_FIT_EPSILON; + + useEffect(() => { + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + underflowCorrectionFrameRef.current = null; + } + + const previouslyUnderflowed = previousContentUnderflowsViewportRef.current; + previousContentUnderflowsViewportRef.current = contentUnderflowsViewport; + if (contentUnderflowsViewport) { + underflowCorrectionFrameRef.current = requestAnimationFrame(() => { + underflowCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToOffset({ + animated: false, + offset: -anchorTopInset, + }); + }); + return; + } + + // A disclosure transition owns its visible-content anchor, so consuming + // the underflow transition without an end scroll is intentional there. + if (previouslyUnderflowed && !disclosureToggleSettling) { + void props.listRef.current?.scrollToEnd({ animated: true }); + } + }, [ + anchorTopInset, + contentHeight, + contentUnderflowsViewport, + disclosureToggleSettling, + props.listRef, + viewportHeight, + ]); + useEffect(() => { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); @@ -1419,14 +1469,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], ); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above + // A hydrating thread reserves the filled identity so detail arrival does not + // replace the native list during the draft-to-thread transition. An already + // open empty thread still remounts when its first message arrives. That + // remount resets its imperative content-inset override — and + // useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the // composer inset to the fresh instance. Without this, the remounted list's // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountState = + props.contentPresentation.kind === "loading" + ? "filled" + : props.feed.length === 0 + ? "empty" + : "filled"; + const listMountKey = `${props.threadId}:${listMountState}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1496,6 +1555,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + } }; }, []); @@ -1711,7 +1773,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || contentUnderflowsViewport ? false : { animated: true, @@ -1750,6 +1812,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd + onContentSizeChange={handleContentSizeChange} onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ @@ -1772,6 +1835,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { /> ) : null} + {props.contentPresentation.kind === "loading" ? ( + + + + ) : null} setExpandedImage(null)} + presentationStyle="overFullScreen" swipeToCloseEnabled doubleTapToZoomEnabled /> diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f3695..a614247a4ed 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -2,10 +2,13 @@ import { SelectableMarkdownText as T3SelectableMarkdownText, type SelectableMarkdownTextProps, } from "@t3tools/mobile-markdown-text/renderer"; +import { View } from "react-native"; import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, @@ -16,6 +19,21 @@ export function hasNativeSelectableMarkdownText(): boolean { return true; } -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; +export function SelectableMarkdownText({ + fillWidth = false, + ...props +}: MobileSelectableMarkdownTextProps) { + const content = ( + + ); + + if (!fillWidth) { + return content; + } + + return {content}; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de4..e536048a241 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,6 +1,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch index 686ea249b7a..92701a64af3 100644 --- a/patches/@legendapp__list@3.2.0.patch +++ b/patches/@legendapp__list@3.2.0.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 5a115ea..2c65d31 100644 +index 5a115ea2b..2c65d3158 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 5a115ea..2c65d31 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 736286a3f..fbf1814f7 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -50,7 +50,7 @@ index 736286a..8218172 100644 ); React.useLayoutEffect(() => { var _a; -@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -84,14 +84,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -62,7 +62,15 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = React.useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = reactNativeReanimated.useSharedValue(0); +@@ -109,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -80,15 +88,18 @@ index 736286a..8218172 100644 const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -124,8 +133,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -135,9 +146,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -96,7 +107,11 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -149,6 +162,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -105,7 +120,7 @@ index 736286a..8218172 100644 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index c1dd27020..1df18da00 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -132,7 +147,7 @@ index c1dd270..cb0d142 100644 ); useLayoutEffect(() => { var _a; -@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { +@@ -63,14 +63,19 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { } var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { const { @@ -144,7 +159,15 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...rest + } = props; ++ // Keyboard scroll math must respect the list's adjusted top inset or short ++ // content gets pinned at offset 0 under a translucent header. ++ const contentInsetStartCompensation = typeof rest.contentInsetStartAdjustment === "number" && Number.isFinite(rest.contentInsetStartAdjustment) ? Math.max(0, rest.contentInsetStartAdjustment) : 0; + const refLegendList = useRef(null); + const combinedRef = useCombinedRef(forwardedRef, refLegendList); + const blankSpace = useSharedValue(0); +@@ -88,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( includeInEndInset: true, onSizeChanged: (size) => { var _a; @@ -162,15 +185,18 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -103,8 +112,10 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, + adjustedInsetCompensation, applyWorkaroundForContentInsetHitTestBug, blankSpace, ++ contentInsetStartCompensation, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + freeze, + keyboardLiftBehavior, +@@ -114,9 +125,11 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -178,7 +204,11 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, contentInsetEndAdjustment, -@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ++ contentInsetStartCompensation, + freeze, + keyboardLiftBehavior, + keyboardOffset, +@@ -128,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, @@ -187,7 +217,7 @@ index c1dd270..cb0d142 100644 renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 72d3f59..435a5fc 100644 +index 72d3f599b..435a5fcde 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -284,6 +284,12 @@ interface LegendListSpecificProps { @@ -204,10 +234,26 @@ index 72d3f59..435a5fc 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 8d4ff89..18f0d62 100644 +index 8d4ff89d9..37b48722b 100644 --- a/react-native.js +++ b/react-native.js -@@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { +@@ -986,13 +986,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1195,7 +1196,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +262,7 @@ index 8d4ff89..18f0d62 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1480,18 +1481,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +288,7 @@ index 8d4ff89..18f0d62 100644 return clampedOffset; } -@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1626,10 +1632,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,7 +301,7 @@ index 8d4ff89..18f0d62 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1676,7 +1682,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -267,7 +313,7 @@ index 8d4ff89..18f0d62 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1737,9 +1746,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -287,7 +333,7 @@ index 8d4ff89..18f0d62 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1759,9 +1777,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +355,7 @@ index 8d4ff89..18f0d62 100644 } setTimeout( () => { -@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1888,7 +1915,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -320,7 +366,7 @@ index 8d4ff89..18f0d62 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1950,7 +1979,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -329,7 +375,7 @@ index 8d4ff89..18f0d62 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2083,7 +2112,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -338,7 +384,7 @@ index 8d4ff89..18f0d62 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { +@@ -2374,8 +2403,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -460,7 +506,7 @@ index 8d4ff89..18f0d62 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2804,7 +2946,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -471,7 +517,7 @@ index 8d4ff89..18f0d62 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4646,7 +4790,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -481,7 +527,7 @@ index 8d4ff89..18f0d62 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4664,6 +4809,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -494,7 +540,7 @@ index 8d4ff89..18f0d62 100644 } return nextSize; } -@@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6462,6 +6613,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -502,7 +548,7 @@ index 8d4ff89..18f0d62 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6492,6 +6644,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -510,7 +556,7 @@ index 8d4ff89..18f0d62 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6710,6 +6863,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -518,7 +564,7 @@ index 8d4ff89..18f0d62 100644 data: dataProp, dataVersion, drawDistance, -@@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6789,6 +6943,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -532,7 +578,7 @@ index 8d4ff89..18f0d62 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6995,6 +7156,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -545,7 +591,7 @@ index 8d4ff89..18f0d62 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7019,6 +7186,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -554,10 +600,26 @@ index 8d4ff89..18f0d62 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 2e96ca7..6e8913e 100644 +index 2e96ca740..10ea1d456 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { +@@ -965,13 +965,14 @@ function checkAtBottom(ctx) { + if (contentSize > 0 && queuedInitialLayout) { + const insetEnd = getContentInsetEnd(ctx); + const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; ++ const distanceFromScrollEnd = contentSize - scroll - scrollLength; + const isContentLess = contentSize < scrollLength; + set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); + set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); + set$( + ctx, + "isWithinMaintainScrollAtEndThreshold", +- isContentLess || distanceFromEnd <= maintainScrollAtEndThreshold * scrollLength ++ isContentLess || distanceFromScrollEnd <= maintainScrollAtEndThreshold * scrollLength + ); + const shouldSkipThresholdChecks = hasActiveInitialScroll(state) || maintainingScrollAtEnd; + if (!shouldSkipThresholdChecks) { +@@ -1174,7 +1175,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -566,7 +628,7 @@ index 2e96ca7..6e8913e 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal"); -@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1459,18 +1460,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -592,7 +654,7 @@ index 2e96ca7..6e8913e 100644 return clampedOffset; } -@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1605,10 +1611,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -605,7 +667,7 @@ index 2e96ca7..6e8913e 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { +@@ -1655,7 +1661,10 @@ function checkFinishedScrollFallback(ctx) { }); scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); } else if (shouldRetryUnalignedEndScroll) { @@ -617,7 +679,7 @@ index 2e96ca7..6e8913e 100644 scheduleFallbackCheck(100); } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { finishScrollTo(ctx); -@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1716,9 +1725,18 @@ function doMaintainScrollAtEnd(ctx) { } state.pendingMaintainScrollAtEnd = false; if (shouldMaintainScrollAtEnd) { @@ -637,7 +699,7 @@ index 2e96ca7..6e8913e 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1738,9 +1756,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -659,7 +721,7 @@ index 2e96ca7..6e8913e 100644 } setTimeout( () => { -@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1867,7 +1894,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -670,7 +732,7 @@ index 2e96ca7..6e8913e 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1929,7 +1958,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -679,7 +741,7 @@ index 2e96ca7..6e8913e 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -2062,7 +2091,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -688,7 +750,7 @@ index 2e96ca7..6e8913e 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { +@@ -2353,8 +2382,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -810,7 +872,7 @@ index 2e96ca7..6e8913e 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2783,7 +2925,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -821,7 +883,7 @@ index 2e96ca7..6e8913e 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4625,7 +4769,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { } contentBelowAnchor += footerSize + stylePaddingBottom; isReady = !hasUnknownTailSize; @@ -831,7 +893,7 @@ index 2e96ca7..6e8913e 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { +@@ -4643,6 +4788,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { updateScroll(ctx, state.scroll, true); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); @@ -844,7 +906,7 @@ index 2e96ca7..6e8913e 100644 } return nextSize; } -@@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6441,6 +6592,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -852,7 +914,7 @@ index 2e96ca7..6e8913e 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6471,6 +6623,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -860,7 +922,7 @@ index 2e96ca7..6e8913e 100644 onRefresh, onScroll: onScrollProp, onStartReached, -@@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6689,6 +6842,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -868,7 +930,7 @@ index 2e96ca7..6e8913e 100644 data: dataProp, dataVersion, drawDistance, -@@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6768,6 +6922,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -882,7 +944,7 @@ index 2e96ca7..6e8913e 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6974,6 +7135,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onMomentumScrollEnd(event); } }, @@ -895,7 +957,7 @@ index 2e96ca7..6e8913e 100644 onScroll: (event) => onScroll(ctx, event) }), [] -@@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -6998,6 +7165,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -904,7 +966,7 @@ index 2e96ca7..6e8913e 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 7e2d11f..d5b0d66 100644 +index 7e2d11ffd..d5b0d66d4 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -285,6 +285,12 @@ interface LegendListSpecificProps { From b5b2ecd466a4aab327824f3cab3c41f0a5afdafb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:54:09 +0200 Subject: [PATCH 2/7] chore: update patched dependency lockfile Co-authored-by: codex --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..adb60288d4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,7 +70,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 + '@legendapp/list@3.2.0': 29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca @@ -206,7 +206,7 @@ importers: version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -532,7 +532,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -12880,7 +12880,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12888,7 +12888,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.2.0(patch_hash=29c4e1e5dbfdc53ec39fccaca43bb939e88daf02d63f1f6e70b929c0eb85eb71)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From 3c97855b6d5a2d525bd3af69c4992a98fa3af335 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:36:12 +0200 Subject: [PATCH 3/7] fix(mobile): Include native header inset in feed fit checks Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadFeed.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b6502a034ae..9c14d2ceb0e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1410,7 +1410,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const contentUnderflowsViewport = contentHeight > 0 && viewportHeight > 0 && - contentHeight + props.contentInsetEndEstimate < viewportHeight - CONTENT_FIT_EPSILON; + contentHeight + + props.contentInsetEndEstimate + + (usesNativeAutomaticInsets ? anchorTopInset : 0) < + viewportHeight - CONTENT_FIT_EPSILON; useEffect(() => { if (underflowCorrectionFrameRef.current !== null) { From 5fcfe270291f18796e42e1db07aa0d84d282e551 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:37:15 +0200 Subject: [PATCH 4/7] fix(mobile): Refresh thread feed composer height estimates Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 10cadcc7b32..07f0a17ba86 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -253,6 +253,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); + useEffect(() => { + setComposerOverlayHeight((current) => + Math.abs(current - estimatedOverlayHeight) > 1 ? estimatedOverlayHeight : current, + ); + }, [estimatedOverlayHeight]); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a From 8b0ce3c3340226b218d976aef009f3242b24fb7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:46:34 +0200 Subject: [PATCH 5/7] fix(mobile): Preserve measured thread composer height Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 07f0a17ba86..9a8611cf0f6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -254,9 +254,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); useEffect(() => { - setComposerOverlayHeight((current) => - Math.abs(current - estimatedOverlayHeight) > 1 ? estimatedOverlayHeight : current, - ); + // A measured overlay can include approval or input cards that are not part + // of this baseline estimate. Keep that larger measurement until layout + // reports a new height, while ensuring chrome changes never under-estimate + // the composer inset. + setComposerOverlayHeight((current) => Math.max(current, estimatedOverlayHeight)); }, [estimatedOverlayHeight]); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes From 6abdfd3ee334f0aac79f7f9dadaa41ab40846341 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:55:47 +0200 Subject: [PATCH 6/7] fix(mobile): Reset thread composer measurements Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 9a8611cf0f6..8a062a788d7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -260,6 +260,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // the composer inset. setComposerOverlayHeight((current) => Math.max(current, estimatedOverlayHeight)); }, [estimatedOverlayHeight]); + useEffect(() => { + // Layout callbacks from the previous thread can arrive after navigation. + // Reset their larger measurement to this thread's known baseline; its own + // overlay layout will immediately replace it if it contains extra cards. + setComposerOverlayHeight(estimatedOverlayHeight); + }, [estimatedOverlayHeight, selectedThreadKey]); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a From 775d0b125fac17d328c145ace2d88572901f0ced Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:59:35 +0200 Subject: [PATCH 7/7] fix(mobile): Preserve composer card measurements Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8a062a788d7..f432f7229a9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -253,6 +253,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); + const estimatedOverlayHeightRef = useRef(estimatedOverlayHeight); + useEffect(() => { + estimatedOverlayHeightRef.current = estimatedOverlayHeight; + }, [estimatedOverlayHeight]); useEffect(() => { // A measured overlay can include approval or input cards that are not part // of this baseline estimate. Keep that larger measurement until layout @@ -264,8 +268,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // Layout callbacks from the previous thread can arrive after navigation. // Reset their larger measurement to this thread's known baseline; its own // overlay layout will immediately replace it if it contains extra cards. - setComposerOverlayHeight(estimatedOverlayHeight); - }, [estimatedOverlayHeight, selectedThreadKey]); + setComposerOverlayHeight(estimatedOverlayHeightRef.current); + }, [selectedThreadKey]); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a