fix(ui): preserve stream scroll on session switch
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||||
import { CheckSquare, Trash, X } from "lucide-solid"
|
import { CheckSquare, Trash, X } from "lucide-solid"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
@@ -9,6 +9,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import { getSessionInfo } from "../stores/sessions"
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { showToastNotification } from "../lib/notifications"
|
import { showToastNotification } from "../lib/notifications"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
@@ -17,6 +18,7 @@ import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
|||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -43,6 +45,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
|
||||||
|
const scrollCache = useScrollCache({
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
sessionId: props.sessionId,
|
||||||
|
scope: MESSAGE_SCROLL_CACHE_SCOPE,
|
||||||
|
})
|
||||||
|
|
||||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||||
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
|
||||||
const sessionInfo = createMemo(() =>
|
const sessionInfo = createMemo(() =>
|
||||||
@@ -221,6 +229,32 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
||||||
|
|
||||||
|
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||||
|
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||||
|
|
||||||
|
const [didRestoreScroll, setDidRestoreScroll] = createSignal(false)
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.sessionId,
|
||||||
|
() => {
|
||||||
|
setDidRestoreScroll(false)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Persist scroll position when switching sessions. This effect's cleanup runs
|
||||||
|
// when `props.sessionId` changes, before the next session is rendered.
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionId = props.sessionId
|
||||||
|
onCleanup(() => {
|
||||||
|
const element = streamElement()
|
||||||
|
if (!element) return
|
||||||
|
const scrollTop = element.scrollTop
|
||||||
|
const atBottom = element.scrollHeight - (element.scrollTop + element.clientHeight) <= 48
|
||||||
|
store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, { scrollTop, atBottom })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -231,6 +265,33 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Restore scroll position when the stream element is available.
|
||||||
|
createEffect(() => {
|
||||||
|
const element = streamElement()
|
||||||
|
const api = listApi()
|
||||||
|
if (!element || !api) return
|
||||||
|
if (props.loading) return
|
||||||
|
if (messageIds().length === 0) return
|
||||||
|
if (didRestoreScroll()) return
|
||||||
|
|
||||||
|
scrollCache.restore(element, {
|
||||||
|
behavior: "auto",
|
||||||
|
fallback: () => {
|
||||||
|
api.setAutoScroll(true)
|
||||||
|
api.scrollToBottom({ immediate: true })
|
||||||
|
},
|
||||||
|
onApplied: (snapshot) => {
|
||||||
|
// Keep follow mode consistent with the restored state.
|
||||||
|
api.setAutoScroll(snapshot?.atBottom ?? true)
|
||||||
|
setDidRestoreScroll(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
scrollCache.persist(streamElement())
|
||||||
|
})
|
||||||
|
|
||||||
function clearQuoteSelection() {
|
function clearQuoteSelection() {
|
||||||
setQuoteSelection(null)
|
setQuoteSelection(null)
|
||||||
}
|
}
|
||||||
@@ -551,24 +612,31 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
|
||||||
data-scroll-buttons={scrollButtonsCount()}
|
data-scroll-buttons={scrollButtonsCount()}
|
||||||
>
|
>
|
||||||
<VirtualFollowList
|
<VirtualFollowList
|
||||||
items={messageIds}
|
items={messageIds}
|
||||||
getKey={(messageId) => messageId}
|
getKey={(messageId) => messageId}
|
||||||
getAnchorId={getMessageAnchorId}
|
getAnchorId={getMessageAnchorId}
|
||||||
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
getKeyFromAnchorId={getMessageIdFromAnchorId}
|
||||||
overscanPx={800}
|
overscanPx={800}
|
||||||
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX}
|
||||||
suspendMeasurements={() => !isActive()}
|
suspendMeasurements={() => !isActive()}
|
||||||
loading={() => Boolean(props.loading)}
|
loading={() => Boolean(props.loading)}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
followToken={followToken}
|
scrollToBottomOnActivate={() => false}
|
||||||
onScroll={() => clearQuoteSelection()}
|
initialScrollToBottom={() => false}
|
||||||
onMouseUp={() => handleStreamMouseUp()}
|
initialAutoScroll={initialAutoScroll}
|
||||||
onActiveKeyChange={setActiveMessageId}
|
resetKey={() => props.sessionId}
|
||||||
onScrollElementChange={(element) => {
|
followToken={followToken}
|
||||||
setStreamElement(element)
|
onScroll={() => {
|
||||||
if (!element) clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
}}
|
scrollCache.persist(streamElement())
|
||||||
|
}}
|
||||||
|
onMouseUp={() => handleStreamMouseUp()}
|
||||||
|
onActiveKeyChange={setActiveMessageId}
|
||||||
|
onScrollElementChange={(element) => {
|
||||||
|
setStreamElement(element)
|
||||||
|
if (!element) clearQuoteSelection()
|
||||||
|
}}
|
||||||
onShellElementChange={(element) => {
|
onShellElementChange={(element) => {
|
||||||
setStreamShellElement(element)
|
setStreamShellElement(element)
|
||||||
if (!element) clearQuoteSelection()
|
if (!element) clearQuoteSelection()
|
||||||
|
|||||||
@@ -56,12 +56,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||||
|
|
||||||
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
|
|
||||||
let promptInputApi: PromptInputApi | null = null
|
let promptInputApi: PromptInputApi | null = null
|
||||||
let pendingPromptText: string | null = null
|
let pendingPromptText: string | null = null
|
||||||
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||||
|
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
function shouldScrollToBottomOnActivate() {
|
||||||
|
const current = session()
|
||||||
|
if (!current) return true
|
||||||
|
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
|
||||||
|
return !snapshot || snapshot.atBottom
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleScrollToBottom() {
|
function scheduleScrollToBottom() {
|
||||||
if (!scrollToBottomHandle) return
|
if (!scrollToBottomHandle) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -70,6 +80,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.isActive) return
|
if (!props.isActive) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
scheduleScrollToBottom()
|
scheduleScrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -321,7 +332,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
registerScrollToBottom={(fn) => {
|
registerScrollToBottom={(fn) => {
|
||||||
scrollToBottomHandle = fn
|
scrollToBottomHandle = fn
|
||||||
if (props.isActive) {
|
if (props.isActive) {
|
||||||
scheduleScrollToBottom()
|
if (shouldScrollToBottomOnActivate()) {
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,34 @@ export interface VirtualFollowListProps<T> {
|
|||||||
loading?: Accessor<boolean>
|
loading?: Accessor<boolean>
|
||||||
isActive?: Accessor<boolean>
|
isActive?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When switching back to an inactive (cached) pane, the list historically
|
||||||
|
* re-pinned to the bottom if autoScroll was enabled.
|
||||||
|
*
|
||||||
|
* Disable this to preserve the existing scroll position across pane switches.
|
||||||
|
*/
|
||||||
|
scrollToBottomOnActivate?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls whether the list should scroll to bottom the first time items
|
||||||
|
* appear (default behavior for chat streams).
|
||||||
|
*
|
||||||
|
* Set to false when an outer component restores scroll from a cache.
|
||||||
|
*/
|
||||||
|
initialScrollToBottom?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value for the internal autoScroll signal.
|
||||||
|
* Useful when restoring scroll state (e.g. start in non-follow mode).
|
||||||
|
*/
|
||||||
|
initialAutoScroll?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When this value changes, the list resets internal follow/anchor state.
|
||||||
|
* Useful when reusing the same list instance across different datasets.
|
||||||
|
*/
|
||||||
|
resetKey?: Accessor<string | number>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If this value changes and autoScroll is enabled, the list will
|
* If this value changes and autoScroll is enabled, the list will
|
||||||
* anchor-scroll to the bottom (unless suppressed).
|
* anchor-scroll to the bottom (unless suppressed).
|
||||||
@@ -103,11 +131,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const bottomSentinel = () => bottomSentinelSignal()
|
const bottomSentinel = () => bottomSentinelSignal()
|
||||||
|
|
||||||
const isActive = () => (props.isActive ? props.isActive() : true)
|
const isActive = () => (props.isActive ? props.isActive() : true)
|
||||||
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
|
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||||
const isLoading = () => Boolean(props.loading?.())
|
const isLoading = () => Boolean(props.loading?.())
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||||
@@ -138,6 +169,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
let userScrollIntentUntil = 0
|
let userScrollIntentUntil = 0
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
|
||||||
|
let lastResetKey: string | number | undefined
|
||||||
|
|
||||||
const state: VirtualFollowListState = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
showScrollTopButton,
|
showScrollTopButton,
|
||||||
@@ -352,21 +385,49 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
pendingScrollFrame = requestAnimationFrame(() => {
|
pendingScrollFrame = requestAnimationFrame(() => {
|
||||||
pendingScrollFrame = null
|
pendingScrollFrame = null
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
|
const previousScrollTop = lastKnownScrollTop
|
||||||
const currentScrollTop = containerRef.scrollTop
|
const currentScrollTop = containerRef.scrollTop
|
||||||
|
const deltaScrollTop = currentScrollTop - previousScrollTop
|
||||||
if (currentScrollTop !== lastKnownScrollTop) {
|
if (currentScrollTop !== lastKnownScrollTop) {
|
||||||
lastKnownScrollTop = currentScrollTop
|
lastKnownScrollTop = currentScrollTop
|
||||||
}
|
}
|
||||||
const atBottom = bottomSentinelVisible()
|
const atBottom = bottomSentinelVisible()
|
||||||
|
|
||||||
|
const beforeAutoScroll = autoScroll()
|
||||||
|
|
||||||
|
const inferredDirection: "up" | "down" | null =
|
||||||
|
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
|
||||||
|
|
||||||
// If the user scrolls manually, exit key-anchored mode.
|
// If the user scrolls manually, exit key-anchored mode.
|
||||||
if (isUserScroll && anchorLock()) {
|
if (isUserScroll && anchorLock()) {
|
||||||
clearAnchorLock()
|
clearAnchorLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUserScroll) {
|
if (isUserScroll) {
|
||||||
if (atBottom) {
|
// If the user is actively scrolling upward, exit follow-to-bottom mode
|
||||||
if (!autoScroll()) setAutoScroll(true)
|
// immediately. The bottom sentinel can remain "visible" for a short
|
||||||
} else if (autoScroll()) {
|
// distance due to its observer margin, which otherwise keeps autoScroll
|
||||||
|
// enabled and makes the list feel stuck.
|
||||||
|
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not re-enable follow mode while the user's current scroll intent
|
||||||
|
// is upward. This prevents transient anchor/pin scrolls from pulling
|
||||||
|
// the list back into autoScroll(true).
|
||||||
|
if (inferredDirection !== "up") {
|
||||||
|
if (atBottom) {
|
||||||
|
if (!autoScroll()) setAutoScroll(true)
|
||||||
|
} else if (autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
} else if (!atBottom && autoScroll()) {
|
||||||
|
// If the user is scrolling up and we are no longer at the bottom,
|
||||||
|
// ensure follow mode is disabled.
|
||||||
setAutoScroll(false)
|
setAutoScroll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,12 +593,58 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
props.registerState?.(state)
|
props.registerState?.(state)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const nextKey = props.resetKey?.()
|
||||||
|
if (nextKey === undefined) return
|
||||||
|
if (lastResetKey === undefined) {
|
||||||
|
lastResetKey = nextKey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextKey === lastResetKey) return
|
||||||
|
lastResetKey = nextKey
|
||||||
|
|
||||||
|
// Reset internal state when consumers swap datasets (e.g. session switch).
|
||||||
|
if (pendingScrollFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
|
pendingScrollFrame = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorScroll !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
|
pendingAnchorScroll = null
|
||||||
|
}
|
||||||
|
if (pendingAnchorCorrectionFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingAnchorCorrectionFrame)
|
||||||
|
pendingAnchorCorrectionFrame = null
|
||||||
|
}
|
||||||
|
clearScrollToBottomFrames()
|
||||||
|
|
||||||
|
scrollCompensationGen += 1
|
||||||
|
pendingScrollCompensationScheduled = false
|
||||||
|
pendingScrollCompensations = new Map()
|
||||||
|
pendingAutoPin = false
|
||||||
|
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
pendingActiveScroll = false
|
||||||
|
pendingInitialScroll = true
|
||||||
|
|
||||||
|
setAnchorLock(null)
|
||||||
|
setActiveKey(null)
|
||||||
|
setShowScrollTopButton(false)
|
||||||
|
setShowScrollBottomButton(false)
|
||||||
|
setTopSentinelVisible(true)
|
||||||
|
setBottomSentinelVisible(true)
|
||||||
|
setAutoScroll(Boolean(initialAutoScroll()))
|
||||||
|
|
||||||
|
lastKnownScrollTop = containerRef?.scrollTop ?? 0
|
||||||
|
lastUserScrollIntentDirection = null
|
||||||
|
})
|
||||||
|
|
||||||
let lastActiveState = false
|
let lastActiveState = false
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = isActive()
|
const active = isActive()
|
||||||
if (active) {
|
if (active) {
|
||||||
resolvePendingActiveScroll()
|
resolvePendingActiveScroll()
|
||||||
if (!lastActiveState && autoScroll()) {
|
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
|
||||||
requestScrollToBottom(true)
|
requestScrollToBottom(true)
|
||||||
|
|
||||||
// When switching back to a cached session pane, items can mount/measure
|
// When switching back to a cached session pane, items can mount/measure
|
||||||
@@ -549,7 +656,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (autoScroll()) {
|
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||||
pendingActiveScroll = true
|
pendingActiveScroll = true
|
||||||
}
|
}
|
||||||
lastActiveState = active
|
lastActiveState = active
|
||||||
@@ -569,6 +676,12 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const sentinel = bottomSentinel()
|
const sentinel = bottomSentinel()
|
||||||
if (!container || !sentinel || props.items().length === 0) return
|
if (!container || !sentinel || props.items().length === 0) return
|
||||||
|
|
||||||
|
if (!initialScrollToBottom()) {
|
||||||
|
// An outer component is responsible for restoring scroll.
|
||||||
|
pendingInitialScroll = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure we're in follow-to-bottom mode for the initial position.
|
// Ensure we're in follow-to-bottom mode for the initial position.
|
||||||
if (anchorLock()) {
|
if (anchorLock()) {
|
||||||
clearAnchorLock()
|
clearAnchorLock()
|
||||||
@@ -599,9 +712,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (autoScroll()) {
|
if (autoScroll()) scheduleAnchorScroll(true)
|
||||||
scheduleAnchorScroll(true)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop anchor lock if the anchored key is removed.
|
// Drop anchor lock if the anchored key is removed.
|
||||||
|
|||||||
Reference in New Issue
Block a user