Skip to content

Enhance view state management by integrating UiStore for scroll position and selected event persistence #160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions src/components/view/view-event-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
RTCConnection,
TlsTunnel
} from '../../types';
import { UiStore } from '../../model/ui/ui-store';

import {
getSummaryColor,
Expand Down Expand Up @@ -54,6 +55,7 @@ interface ViewEventListProps {
isPaused: boolean;

contextMenuBuilder: ViewEventContextMenuBuilder;
uiStore: UiStore;

moveSelection: (distance: number) => void;
onSelected: (event: CollectedEvent | undefined) => void;
Expand Down Expand Up @@ -767,6 +769,17 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
private listBodyRef = React.createRef<HTMLDivElement>();
private listRef = React.createRef<List>();

private setListBodyRef = (element: HTMLDivElement | null) => {
// Update the ref
(this.listBodyRef as any).current = element;

// If the element is being mounted and we haven't restored state yet, do it now
if (element && !this.hasRestoredScrollState) {
this.restoreScrollPosition();
this.hasRestoredScrollState = true;
}
};

private KeyBoundListWindow = observer(
React.forwardRef<HTMLDivElement>(
(props: any, ref) => <div
Expand Down Expand Up @@ -816,7 +829,7 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
: <AutoSizer>{({ height, width }) =>
<Observer>{() =>
<List
innerRef={this.listBodyRef}
innerRef={this.setListBodyRef}
outerElementType={this.KeyBoundListWindow}
ref={this.listRef}

Expand Down Expand Up @@ -881,17 +894,28 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
}

private wasListAtBottom = true;

private updateScrolledState = () => {
requestAnimationFrame(() => { // Measure async, once the scroll has actually happened
this.wasListAtBottom = this.isListAtBottom();

// Only save scroll position after we've restored the initial state
if (this.hasRestoredScrollState) {
const listWindow = this.listBodyRef.current?.parentElement;

const scrollPosition = this.wasListAtBottom
? 'end'
: (listWindow?.scrollTop ?? 'end');
if (listWindow) {
this.props.uiStore.setViewScrollPosition(scrollPosition);
}
}
});
}

componentDidMount() {
this.updateScrolledState();
}
private hasRestoredScrollState = false;

componentDidUpdate() {
componentDidUpdate(prevProps: ViewEventListProps) {
if (this.listBodyRef.current?.parentElement?.contains(document.activeElement)) {
// If we previously had something here focused, and we've updated, update
// the focus too, to make sure it's in the right place.
Expand All @@ -902,6 +926,25 @@ export class ViewEventList extends React.Component<ViewEventListProps> {
// scroll there again ourselves now.
if (this.wasListAtBottom && !this.isListAtBottom()) {
this.listRef.current?.scrollToItem(this.props.events.length - 1);
} else if (prevProps.selectedEvent !== this.props.selectedEvent && this.props.selectedEvent) {
// If the selected event changed and we have a selected event, scroll to it
// This handles restoring the selected event when returning to the tab
this.scrollToEvent(this.props.selectedEvent);
}
}

private restoreScrollPosition = () => {
const savedPosition = this.props.uiStore.viewScrollPosition;
const listWindow = this.listBodyRef.current?.parentElement;
if (listWindow) {
if (savedPosition === 'end') {
listWindow.scrollTop = listWindow.scrollHeight;
} else {
// Only restore if we're not close to the current position (avoid unnecessary scrolling)
if (Math.abs(listWindow.scrollTop - savedPosition) > 10) {
listWindow.scrollTop = savedPosition;
}
}
}
}

Expand Down
24 changes: 18 additions & 6 deletions src/components/view/view-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,13 @@ class ViewPage extends React.Component<ViewPageProps> {
filteredEventCount: [filteredEvents.length, events.length]
};
}

@computed
get selectedEvent() {
// First try to use the URL-based eventId, then fallback to the persisted selection
const targetEventId = this.props.eventId || this.props.uiStore.selectedEventId;

return _.find(this.props.eventsStore.events, {
id: this.props.eventId
id: targetEventId
});
}

Expand Down Expand Up @@ -242,12 +244,11 @@ class ViewPage extends React.Component<ViewPageProps> {
);

componentDidMount() {
// After first render, scroll to the selected event (or the end of the list) by default:
// After first render, if we're jumping to an event, then scroll to it:
requestAnimationFrame(() => {
if (this.props.eventId && this.selectedEvent) {
this.props.uiStore.setSelectedEventId(this.props.eventId);
this.onScrollToCenterEvent(this.selectedEvent);
} else {
this.onScrollToEnd();
}
});

Expand Down Expand Up @@ -328,6 +329,15 @@ class ViewPage extends React.Component<ViewPageProps> {
);
}

componentDidUpdate(prevProps: ViewPageProps) {
// Only clear persisted selection if we're explicitly navigating to a different event via URL
// Don't clear it when going from eventId to no eventId (which happens when clearing selection)
if (this.props.eventId && prevProps.eventId && this.props.eventId !== prevProps.eventId) {
// Clear persisted selection only when explicitly navigating between different events via URL
this.props.uiStore.setSelectedEventId(undefined);
}
}

isSendAvailable() {
return versionSatisfies(serverVersion.value as string, SERVER_SEND_API_SUPPORTED);
}
Expand Down Expand Up @@ -447,8 +457,8 @@ class ViewPage extends React.Component<ViewPageProps> {

moveSelection={this.moveSelection}
onSelected={this.onSelected}

contextMenuBuilder={this.contextMenuBuilder}
uiStore={this.props.uiStore}

ref={this.listRef}
/>
Expand Down Expand Up @@ -491,6 +501,8 @@ class ViewPage extends React.Component<ViewPageProps> {

@action.bound
onSelected(event: CollectedEvent | undefined) {
this.props.uiStore.setSelectedEventId(event?.id);

this.props.navigate(event
? `/view/${event.id}`
: '/view'
Expand Down
17 changes: 17 additions & 0 deletions src/model/ui/ui-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ export class UiStore {
@observable
expandedViewCard: ExpandableViewCardKey | undefined;

@observable
viewScrollPosition: number | 'end' = 'end';

@observable
selectedEventId: string | undefined;

@computed
get viewCardProps() {
return _.mapValues(this.viewCardStates, (state, key) => ({
Expand Down Expand Up @@ -436,6 +442,17 @@ export class UiStore {
@persist @observable
exportSnippetFormat: string | undefined;

// Actions for persisting view state when switching tabs
@action.bound
setViewScrollPosition(position: number | 'end') {
this.viewScrollPosition = position;
}

@action.bound
setSelectedEventId(eventId: string | undefined) {
this.selectedEventId = eventId;
}

/**
* This tracks the context menu state *only if it's not handled natively*. This state
* is rendered by React as a fallback when DesktopApi.openContextMenu is not available.
Expand Down