authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 00:32:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 21:32:01-07:00
log1c7d059f550a53dd94d5b9802640ecd11b759d1a
tree5182c84b4739a354616240fde9020aadb74a3094
parent6fa457607886caf096e7871b67e447f76d3772f6
signature Signed by PGP key B5690EEEBB952194

fix: scrolling up during a running thread no longer snaps back to the bottom (#5566)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 258 insertions(+), 35 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+69-2
......@@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13351335 );
13361336 const [viewportHeight, setViewportHeight] = useState(0);
13371337 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
1338 // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
1339 // whenever the viewport drifts back inside its geometric threshold, which
1340 // yanked users off history they were reading every time a stream chunk grew
1341 // a row. Follow breaks when the user scrolls up and away, and re-arms only
1342 // when the list actually returns to the end (or on send / thread switch).
1343 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
1344 const endFollowEnabledRef = useRef(true);
1345 // A "user scroll session" spans from drag start through the end of its
1346 // momentum; only motion inside a session can break follow, so MVCP
1347 // compensations and programmatic scrolls never strand a follower.
1348 const userScrollSessionRef = useRef(false);
1349 const setEndFollow = useCallback((enabled: boolean) => {
1350 if (endFollowEnabledRef.current === enabled) {
1351 return;
1352 }
1353 endFollowEnabledRef.current = enabled;
1354 setEndFollowEnabled(enabled);
1355 }, []);
13381356 const [interactionState, setInteractionState] = useState<{
13391357 readonly copiedRowId: string | null;
13401358 readonly expandedWorkGroups: Record<string, boolean>;
......@@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14541472 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
14551473 nearListEnd.value =
14561474 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
1475
1476 // Latch bookkeeping. LegendList recomputes its inset-aware end distance
1477 // before invoking this handler, so getState() is current. Returning to
1478 // the end re-arms follow no matter who scrolled (the user, or our own
1479 // scroll-to-end); moving away breaks it only during a user-initiated
1480 // scroll session, so MVCP compensations and programmatic repositioning
1481 // can never strand a follower.
1482 const listState = props.listRef.current?.getState();
1483 if (listState) {
1484 if (listState.isWithinMaintainScrollAtEndThreshold) {
1485 setEndFollow(true);
1486 } else if (userScrollSessionRef.current) {
1487 setEndFollow(false);
1488 }
1489 }
14571490 },
1458 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],
1491 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow],
14591492 );
1493 const handleScrollBeginDrag = useCallback(() => {
1494 userScrollSessionRef.current = true;
1495 }, []);
1496 // The session must survive past finger-lift so momentum that carries the
1497 // user away from the end still breaks follow; a drag released with no
1498 // momentum ends its session at the release itself, otherwise at momentum
1499 // end. Leaving a session open would let a later animated maintain-scroll
1500 // read as user motion and break follow spuriously.
1501 const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
1502 const velocity = event.nativeEvent.velocity?.y ?? 0;
1503 if (Math.abs(velocity) < 0.05) {
1504 userScrollSessionRef.current = false;
1505 }
1506 }, []);
1507 const handleMomentumScrollEnd = useCallback(() => {
1508 userScrollSessionRef.current = false;
1509 }, []);
14601510
14611511 // Gated variant of the 180ms feed layout slide. Instant while browsing
14621512 // history: maintainVisibleContentPosition compensates the scroll offset in
......@@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14961546 reportHeaderMaterialVisibility(false);
14971547 }, [props.threadId, reportHeaderMaterialVisibility]);
14981548
1549 // A thread switch opens pinned to the end; a send explicitly returns to the
1550 // live edge (ThreadDetailScreen scrolls the new message into place). Both
1551 // re-arm follow regardless of where the user had scrolled before.
1552 useEffect(() => {
1553 userScrollSessionRef.current = false;
1554 setEndFollow(true);
1555 }, [props.threadId, setEndFollow]);
1556 useEffect(() => {
1557 if (props.anchorMessageId !== null) {
1558 userScrollSessionRef.current = false;
1559 setEndFollow(true);
1560 }
1561 }, [props.anchorMessageId, setEndFollow]);
1562
14991563 const expandedWorkGroupIds = useMemo(() => {
15001564 const ids = new Set<string>();
15011565 for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) {
......@@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18471911 // anchor scrolls also lets it correct a scroll that landed on a
18481912 // stale end target once the anchor row finishes measuring.
18491913 maintainScrollAtEnd={
1850 disclosureToggleSettling
1914 disclosureToggleSettling || !endFollowEnabled
18511915 ? false
18521916 : {
18531917 animated: true,
......@@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18961960 alignItemsAtEnd
18971961 initialScrollAtEnd
18981962 onScroll={handleScroll}
1963 onScrollBeginDrag={handleScrollBeginDrag}
1964 onScrollEndDrag={handleScrollEndDrag}
1965 onMomentumScrollEnd={handleMomentumScrollEnd}
18991966 scrollEventThrottle={16}
19001967 ListHeaderComponent={
19011968 <>
apps/web/src/components/ChatView.tsx+119-24
......@@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
244244import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
245245import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
246246import { MessagesTimeline } from "./chat/MessagesTimeline";
247import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
247248import { ChatHeader } from "./chat/ChatHeader";
248249import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
249250import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
......@@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) {
35673568 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
35683569 );
35693570 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
3571 // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
3572 // re-pins on its own (independent of the refs), so the timeline needs a
3573 // render-visible flag to switch it off once the user scrolls away.
3574 const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true);
35703575 const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
35713576 const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
35723577 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
......@@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) {
35833588 anchorUserScrollGenerationRef.current += 1;
35843589 timelineScrollModeRef.current = "free-scrolling";
35853590 liveFollowUserScrollGenerationRef.current = null;
3591 setTimelineLiveFollowEnabled(false);
35863592 pendingTimelineAnchorRef.current = null;
35873593 positionedTimelineAnchorRef.current = null;
35883594 settledTimelineAnchorRef.current = null;
......@@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) {
36543660 isAtEndRef.current = true;
36553661 timelineScrollModeRef.current = "following-end";
36563662 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3663 setTimelineLiveFollowEnabled(true);
36573664 pendingTimelineAnchorRef.current = null;
36583665 activeTimelineAnchorIndexRef.current = null;
36593666 showScrollDebouncer.current.cancel();
......@@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) {
36623669 }, []);
36633670 useEffect(() => {
36643671 let removeListeners: (() => void) | null = null;
3665 const frame = requestAnimationFrame(() => {
3666 const scrollNode = legendListRef.current?.getScrollableNode();
3667 if (!scrollNode) {
3668 return;
3669 }
3670 const handleManualNavigation = () => {
3671 cancelTimelineLiveFollowForUserNavigationRef.current();
3672 };
3673 scrollNode.addEventListener("wheel", handleManualNavigation, {
3674 passive: true,
3675 });
3676 scrollNode.addEventListener("touchmove", handleManualNavigation, {
3677 passive: true,
3678 });
3679 scrollNode.addEventListener("pointerdown", handleManualNavigation, {
3680 passive: true,
3672 let frame: number | null = null;
3673 const attach = (remainingAttempts: number) => {
3674 frame = requestAnimationFrame(() => {
3675 frame = null;
3676 const scrollNode = legendListRef.current?.getScrollableNode();
3677 if (!scrollNode) {
3678 // The list may not have mounted on the first frame after a thread
3679 // switch — without a retry the opt-out listeners never attach and
3680 // live-follow becomes impossible to escape for the whole thread.
3681 if (remainingAttempts > 0) {
3682 attach(remainingAttempts - 1);
3683 }
3684 return;
3685 }
3686 const handleManualNavigation = () => {
3687 cancelTimelineLiveFollowForUserNavigationRef.current();
3688 };
3689 // The gestures below must only break follow when they can actually
3690 // move the viewport away from the live edge. Follow now gates
3691 // LegendList's maintainScrollAtEnd, so a spurious break while pinned
3692 // at the end produces no scroll event, never re-arms, and streaming
3693 // silently stops following. Underflowing content can't scroll at all,
3694 // so nothing there should break follow.
3695 const contentScrollsUp = () => timelineRealContentOverflowsViewport();
3696 // The follow re-arm band, not the strict flag: streaming growth makes
3697 // isAtEnd flicker false for a frame before the follow scroll catches
3698 // up, and a gesture landing in that window while still pinned would
3699 // otherwise break follow with no scroll event left to re-arm it.
3700 const viewportIsAwayFromEnd = () =>
3701 resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) ===
3702 false;
3703 // Only an upward wheel is a navigation intent; wheeling down while
3704 // following either does nothing (at the end) or moves toward it.
3705 const handleWheel = (event: WheelEvent) => {
3706 if (event.deltaY < 0 && contentScrollsUp()) {
3707 handleManualNavigation();
3708 }
3709 };
3710 // Touch direction isn't observable here (touchmove fires on any
3711 // finger motion, scrolling or not), so break only once the drag has
3712 // actually carried the viewport out of the end band — an upward flick
3713 // gets there within its first few events and later touchmoves break.
3714 const handleTouchMove = () => {
3715 if (viewportIsAwayFromEnd()) {
3716 handleManualNavigation();
3717 }
3718 };
3719 // Scrollbar drags produce no wheel/touch events; they are the only
3720 // pointerdowns whose target is the scroll node itself rather than a
3721 // message row. Content clicks break follow only away from the end
3722 // (reading or selecting up there must hold position); clicking near
3723 // the live edge keeps following.
3724 const handlePointerDown = (event: PointerEvent) => {
3725 if (event.target === scrollNode) {
3726 if (contentScrollsUp()) {
3727 handleManualNavigation();
3728 }
3729 return;
3730 }
3731 if (viewportIsAwayFromEnd()) {
3732 handleManualNavigation();
3733 }
3734 };
3735 // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and
3736 // pointer events entirely; without this the timeline yanks back to
3737 // the end on the next stream chunk.
3738 const handleKeyDown = (event: KeyboardEvent) => {
3739 switch (event.key) {
3740 case "PageUp":
3741 case "Home":
3742 case "ArrowUp":
3743 if (contentScrollsUp()) {
3744 handleManualNavigation();
3745 }
3746 break;
3747 default:
3748 break;
3749 }
3750 };
3751 scrollNode.addEventListener("wheel", handleWheel, {
3752 passive: true,
3753 });
3754 scrollNode.addEventListener("touchmove", handleTouchMove, {
3755 passive: true,
3756 });
3757 scrollNode.addEventListener("pointerdown", handlePointerDown, {
3758 passive: true,
3759 });
3760 scrollNode.addEventListener("keydown", handleKeyDown);
3761 removeListeners = () => {
3762 scrollNode.removeEventListener("wheel", handleWheel);
3763 scrollNode.removeEventListener("touchmove", handleTouchMove);
3764 scrollNode.removeEventListener("pointerdown", handlePointerDown);
3765 scrollNode.removeEventListener("keydown", handleKeyDown);
3766 };
36813767 });
3682 removeListeners = () => {
3683 scrollNode.removeEventListener("wheel", handleManualNavigation);
3684 scrollNode.removeEventListener("touchmove", handleManualNavigation);
3685 scrollNode.removeEventListener("pointerdown", handleManualNavigation);
3686 };
3687 });
3768 };
3769 attach(12);
36883770
36893771 return () => {
3690 cancelAnimationFrame(frame);
3772 if (frame !== null) {
3773 cancelAnimationFrame(frame);
3774 }
36913775 removeListeners?.();
36923776 };
3693 }, [activeThread?.id]);
3777 }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]);
36943778
36953779 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
3780 // Anchored-end space can be remeasured when the turn completes. Once the
3781 // user has scrolled away (or returned to ordinary end-following), that
3782 // remeasurement must not restart the send-time anchor positioning.
3783 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
3784 return;
3785 }
36963786 if (pendingTimelineAnchorRef.current === messageId) {
36973787 pendingTimelineAnchorRef.current = null;
36983788 }
......@@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) {
37983888 if (isAtEnd) {
37993889 timelineScrollModeRef.current = "following-end";
38003890 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3891 setTimelineLiveFollowEnabled(true);
38013892 showScrollDebouncer.current.cancel();
38023893 setShowScrollToBottom(false);
38033894 } else {
......@@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) {
38783969 isAtEndRef.current = true;
38793970 timelineScrollModeRef.current = "following-end";
38803971 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3972 setTimelineLiveFollowEnabled(true);
38813973 pendingTimelineAnchorRef.current = null;
38823974 positionedTimelineAnchorRef.current = null;
38833975 settledTimelineAnchorRef.current = null;
......@@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) {
49455037 isAtEndRef.current = true;
49465038 timelineScrollModeRef.current = "anchoring-new-turn";
49475039 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5040 setTimelineLiveFollowEnabled(true);
49485041 pendingTimelineAnchorRef.current = messageIdForSend;
49495042 activeTimelineAnchorIndexRef.current = null;
49505043 showScrollDebouncer.current.cancel();
......@@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) {
53895482 isAtEndRef.current = true;
53905483 timelineScrollModeRef.current = "anchoring-new-turn";
53915484 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5485 setTimelineLiveFollowEnabled(true);
53925486 pendingTimelineAnchorRef.current = messageIdForSend;
53935487 activeTimelineAnchorIndexRef.current = null;
53945488 showScrollDebouncer.current.cancel();
......@@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) {
60556149 onAnchorReady={onTimelineAnchorReady}
60566150 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
60576151 contentInsetEndAdjustment={composerOverlayHeight}
6152 liveFollowEnabled={timelineLiveFollowEnabled}
60586153 onIsAtEndChange={onIsAtEndChange}
60596154 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
60606155 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
apps/web/src/components/chat/MessagesTimeline.logic.ts+29-3
......@@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
1818
1919export interface TimelineEndState {
2020 readonly isAtEnd?: boolean;
21 readonly isNearEnd?: boolean;
21 readonly contentLength?: number;
22 readonly scroll?: number;
23 readonly scrollLength?: number;
2224}
2325
24export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
25 return state?.isNearEnd ?? state?.isAtEnd;
26/**
27 * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's
28 * isNearEnd fires within half a viewport, which re-armed live-follow while the
29 * user was reading history and yanked them back down on the next stream chunk.
30 * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming
31 * reliable while streaming content is still growing under the viewport.
32 */
33export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
34
35export function resolveTimelineIsAtEnd(
36 state: TimelineEndState | undefined,
37 endInset = 0,
38): boolean | undefined {
39 if (!state) {
40 return undefined;
41 }
42 if (state.isAtEnd) {
43 return true;
44 }
45 const { contentLength, scroll, scrollLength } = state;
46 if (contentLength === undefined || scroll === undefined || scrollLength === undefined) {
47 return state.isAtEnd;
48 }
49 // contentLength includes the end inset (composer overlay), so subtract it to
50 // measure the distance to the real content bottom.
51 return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
2652}
2753
2854export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
apps/web/src/components/chat/MessagesTimeline.test.tsx+30-3
......@@ -194,6 +194,7 @@ function buildProps() {
194194 onAnchorReady: () => {},
195195 onAnchorSizeChanged: () => {},
196196 contentInsetEndAdjustment: 0,
197 liveFollowEnabled: true,
197198 onIsAtEndChange: () => {},
198199 onManualNavigation: () => {},
199200 };
......@@ -296,7 +297,7 @@ describe("MessagesTimeline", () => {
296297 expect(markup).toContain("1 changed file");
297298 });
298299
299 it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {
300 it("treats only the strict list end as the live edge", async () => {
300301 const {
301302 resolveTimelineIsAtEnd,
302303 resolveTimelineMinimapHasPersistentGutter,
......@@ -307,10 +308,36 @@ describe("MessagesTimeline", () => {
307308 resolveTimelineMinimapTopPercent,
308309 } = await import("./MessagesTimeline.logic");
309310
310 expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
311 expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
312311 expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
313312 expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined();
313 // Within the pixel band above the content bottom counts as the end...
314 expect(
315 resolveTimelineIsAtEnd({
316 isAtEnd: false,
317 contentLength: 2000,
318 scroll: 1170,
319 scrollLength: 800,
320 }),
321 ).toBe(true);
322 // ...but half a viewport up (LegendList's isNearEnd territory) does not.
323 expect(
324 resolveTimelineIsAtEnd({
325 isAtEnd: false,
326 contentLength: 2000,
327 scroll: 900,
328 scrollLength: 800,
329 }),
330 ).toBe(false);
331 // The composer inset is part of contentLength and must not count as
332 // distance-to-end.
333 expect(
334 resolveTimelineIsAtEnd(
335 { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 },
336 100,
337 ),
338 ).toBe(true);
339 // Geometry missing (older state shape): fall back to the strict flag.
340 expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false);
314341
315342 expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))");
316343 expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50);
apps/web/src/components/chat/MessagesTimeline.tsx+11-3
......@@ -219,6 +219,13 @@ interface MessagesTimelineProps {
219219 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
220220 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
221221 contentInsetEndAdjustment: number;
222 /**
223 * Whether the timeline should keep pinning to the live edge as content
224 * grows. Off while the user is reading history; LegendList's own
225 * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's
226 * scroll-mode refs whenever the user drifts near the bottom.
227 */
228 liveFollowEnabled: boolean;
222229 onIsAtEndChange: (isAtEnd: boolean) => void;
223230 onManualNavigation: () => void;
224231 hideEmptyPlaceholder?: boolean;
......@@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
258265 onAnchorReady,
259266 onAnchorSizeChanged,
260267 contentInsetEndAdjustment,
268 liveFollowEnabled,
261269 onIsAtEndChange,
262270 onManualNavigation,
263271 hideEmptyPlaceholder = false,
......@@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
401409
402410 const handleScroll = useCallback(() => {
403411 const state = listRef.current?.getState?.();
404 const isAtEnd = resolveTimelineIsAtEnd(state);
412 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);
405413 if (isAtEnd !== undefined) {
406414 onIsAtEndChange(isAtEnd);
407415 }
......@@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
427435
428436 strip.dataset.inView = inView ? "true" : "false";
429437 }
430 }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
438 }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]);
431439
432440 useEffect(() => {
433441 const frame = requestAnimationFrame(handleScroll);
......@@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
543551 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
544552 contentInsetEndAdjustment={contentInsetEndAdjustment}
545553 maintainScrollAtEnd={
546 anchoredEndSpace
554 anchoredEndSpace || !liveFollowEnabled
547555 ? false
548556 : {
549557 animated: false,