[Performance][Security Solution][2/4] - Timeline Performance (#212478)

## Summary
Part 2 of https://github.com/elastic/kibana/pull/212173

### Testing
For setup see testing section here:
https://github.com/elastic/kibana/pull/212173#issue-2870522020

**Areas/How to test:**
- For the following pages, test there are no `fields` api requests in
the inspector network tab when visiting from another page. IF YOU
REFRESH on any of these pages, you will see these requests as they are
called by the Query Search Bar and the `useInitSourcerer` call
  - Cases Page
  - Dashboard Page
  - Timelines Page
- Timeline
  - All Tabs
    - Does it show the loading screen on first interaction?
    - Does the `fields` api fire on first interaction with the tab
    - When you navigate back to those tabs, do they not re-render?
- All other pages hosting timeline
 - Do you feel like the performance is generally better?


### Background

When investigating the performance of the security solution application,
one of the issues that was observed was queries to the `fields` api on
pages that had no reason making that request (such as Cases, or the
Dashboards list view). This was due to the background background loaded
tabs of timeline loading the relevant `dataView` necessary for their
search functionality. When the fields request is significantly large
this can have a massive impact on the experience of users on pages that
should be relatively responsive.

To fix this a few changes were made. 

1. First the `withDataView` HOC was removed as it was only used in 2
components that shared a parent - child relationship, and the child
`UnifiedTimeline` was only used in the parent. The hook that HOC calls
was not caching the dataView being created, so `dataView.create` was
being called up to 6 times unnecessarily. Now it is only called once in
each tab.

2. A new wrapper `OnDemandRenderer` (open to different naming 😅) was
created that will not render any of the nested tabs until they are
opened. Once they are opened, they stay in memory, to avoid re-calling
expensive api's every time a user switches tabs.
_Note_: There is currently a known issue where navigating between
various routes in security solution causes the whole application to
unmount and re-mount. Which means every page change will lead to
timeline needing to be re-loaded when the tab is opened. This is being
resolved in a separate effort.

3. Additional checks were added to the `useTimelineEvents` hook to limit
additional re-renders caused by unnecessary reference changes when the
underlying values never actually change

### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
### Identify risks
This commit is contained in:
Michael Olorunnisola 2025-03-11 12:56:45 -04:00 committed by GitHub
parent 93adbd8c0e
commit 2d8f3c1544
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 706 additions and 272 deletions

View file

@ -16,7 +16,7 @@ import {
SecurityCellActionType,
} from '../cell_actions';
import { getSourcererScopeId } from '../../../helpers';
import { TimelineContext } from '../../../timelines/components/timeline';
import { TimelineContext } from '../../../timelines/components/timeline/context';
import { TableContext } from '../events_viewer/shared';

View file

@ -15,7 +15,7 @@ import { AddTimelineButton } from './add_timeline_button';
import { timelineActions } from '../../store';
import { TimelineSaveStatus } from '../save_status';
import { AddToFavoritesButton } from '../add_to_favorites';
import { TimelineEventsCountBadge } from '../../../common/hooks/use_timeline_events_count';
import TimelineQueryTabEventsCount from '../timeline/tabs/query/events_count';
interface TimelineBottomBarProps {
/**
@ -63,9 +63,9 @@ export const TimelineBottomBar = React.memo<TimelineBottomBarProps>(
{title}
</EuiLink>
</EuiFlexItem>
{!show && ( // this is a hack because TimelineEventsCountBadge is using react-reverse-portal so the component which is used in multiple places cannot be visible in multiple places at the same time
{!show && ( // We only want to show this when the timeline modal is closed
<EuiFlexItem grow={false} data-test-subj="timeline-event-count-badge">
<TimelineEventsCountBadge />
<TimelineQueryTabEventsCount timelineId={timelineId} />
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>

View file

@ -9,7 +9,7 @@ import React, { useContext, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/css';
import classNames from 'classnames';
import { TimelineContext } from '../../timeline';
import { TimelineContext } from '../../timeline/context';
import { getSourcererScopeId } from '../../../../helpers';
import { escapeDataProviderId } from '../../../../common/components/drag_and_drop/helpers';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';

View file

@ -14,6 +14,9 @@ import type { UnifiedTimelineBodyProps } from './unified_timeline_body';
import { UnifiedTimelineBody } from './unified_timeline_body';
import { render } from '@testing-library/react';
import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../common/mock';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { mockSourcererScope } from '../../../../sourcerer/containers/mocks';
import { DataView } from '@kbn/data-views-plugin/common';
jest.mock('../unified_components', () => {
return {
@ -21,6 +24,18 @@ jest.mock('../unified_components', () => {
};
});
const mockDataView = new DataView({
spec: mockSourcererScope.sourcererDataView,
fieldFormats: fieldFormatsMock,
});
// Not returning an actual dataView here, just an object as a non-null value;
const mockUseGetScopedSourcererDataView = jest.fn().mockImplementation(() => mockDataView);
jest.mock('../../../../sourcerer/components/use_get_sourcerer_data_view', () => ({
useGetScopedSourcererDataView: () => mockUseGetScopedSourcererDataView(),
}));
const mockEventsData = structuredClone(mockTimelineData);
const defaultProps: UnifiedTimelineBodyProps = {
@ -77,4 +92,11 @@ describe('UnifiedTimelineBody', () => {
{}
);
});
it('should render the dataview error component when no dataView is provided', () => {
mockUseGetScopedSourcererDataView.mockImplementationOnce(() => undefined);
const { queryByTestId } = renderTestComponents();
expect(queryByTestId('dataViewErrorComponent')).toBeInTheDocument();
});
});

View file

@ -8,11 +8,15 @@
import type { ComponentProps, ReactElement } from 'react';
import React, { useMemo } from 'react';
import { RootDragDropProvider } from '@kbn/dom-drag-drop';
import { useGetScopedSourcererDataView } from '../../../../sourcerer/components/use_get_sourcerer_data_view';
import { DataViewErrorComponent } from '../../../../common/components/with_data_view/data_view_error';
import { StyledTableFlexGroup, StyledUnifiedTableFlexItem } from '../unified_components/styles';
import { UnifiedTimeline } from '../unified_components';
import { defaultUdtHeaders } from './column_headers/default_headers';
import { SourcererScopeName } from '../../../../sourcerer/store/model';
export interface UnifiedTimelineBodyProps extends ComponentProps<typeof UnifiedTimeline> {
export interface UnifiedTimelineBodyProps
extends Omit<ComponentProps<typeof UnifiedTimeline>, 'dataView'> {
header: ReactElement;
}
@ -37,7 +41,9 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => {
leadingControlColumns,
onUpdatePageIndex,
} = props;
const dataView = useGetScopedSourcererDataView({
sourcererScope: SourcererScopeName.timeline,
});
const columnsHeader = useMemo(() => columns ?? defaultUdtHeaders, [columns]);
return (
@ -48,26 +54,31 @@ export const UnifiedTimelineBody = (props: UnifiedTimelineBodyProps) => {
data-test-subj="unifiedTimelineBody"
>
<RootDragDropProvider>
<UnifiedTimeline
columns={columnsHeader}
rowRenderers={rowRenderers}
isSortEnabled={isSortEnabled}
timelineId={timelineId}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
sort={sort}
events={events}
refetch={refetch}
dataLoadingState={dataLoadingState}
totalCount={totalCount}
onFetchMoreRecords={onFetchMoreRecords}
activeTab={activeTab}
updatedAt={updatedAt}
isTextBasedQuery={false}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
onUpdatePageIndex={onUpdatePageIndex}
/>
{dataView ? (
<UnifiedTimeline
columns={columnsHeader}
dataView={dataView}
rowRenderers={rowRenderers}
isSortEnabled={isSortEnabled}
timelineId={timelineId}
itemsPerPage={itemsPerPage}
itemsPerPageOptions={itemsPerPageOptions}
sort={sort}
events={events}
refetch={refetch}
dataLoadingState={dataLoadingState}
totalCount={totalCount}
onFetchMoreRecords={onFetchMoreRecords}
activeTab={activeTab}
updatedAt={updatedAt}
isTextBasedQuery={false}
trailingControlColumns={trailingControlColumns}
leadingControlColumns={leadingControlColumns}
onUpdatePageIndex={onUpdatePageIndex}
/>
) : (
<DataViewErrorComponent />
)}
</RootDragDropProvider>
</StyledUnifiedTableFlexItem>
</StyledTableFlexGroup>

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createContext } from 'react';
export const TimelineContext = createContext<{
timelineId: string | null;
}>({ timelineId: null });

View file

@ -7,7 +7,7 @@
import { pick } from 'lodash/fp';
import { EuiPanel, EuiProgress, EuiText } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, createContext } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
@ -30,6 +30,7 @@ import { EXIT_FULL_SCREEN_CLASS_NAME } from '../../../common/components/exit_ful
import { useResolveConflict } from '../../../common/hooks/use_resolve_conflict';
import { sourcererSelectors } from '../../../common/store';
import { defaultUdtHeaders } from './body/column_headers/default_headers';
import { TimelineContext } from './context';
const TimelineBody = styled.div`
height: 100%;
@ -37,7 +38,6 @@ const TimelineBody = styled.div`
flex-direction: column;
`;
export const TimelineContext = createContext<{ timelineId: string | null }>({ timelineId: null });
export interface Props {
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];

View file

@ -45,17 +45,7 @@ import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../st
import { fetchNotesBySavedObjectIds, makeSelectNotesBySavedObjectId } from '../../../../notes';
import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>(
({ $isVisible = false, isOverflowYScroll = false }) => ({
style: {
display: $isVisible ? 'flex' : 'none',
overflow: isOverflowYScroll ? 'hidden scroll' : 'hidden',
},
})
)<{ $isVisible: boolean; isOverflowYScroll?: boolean }>`
flex: 1;
`;
import { LazyTimelineTabRenderer, TimelineTabFallback } from './lazy_timeline_tab_renderer';
/**
* A HOC which supplies React.Suspense with a fallback component
@ -76,13 +66,35 @@ const tabWithSuspense = <P extends {}, R = {}>(
return Comp;
};
const QueryTab = tabWithSuspense(lazy(() => import('./query')));
const EqlTab = tabWithSuspense(lazy(() => import('./eql')));
const GraphTab = tabWithSuspense(lazy(() => import('./graph')));
const NotesTab = tabWithSuspense(lazy(() => import('./notes')));
const PinnedTab = tabWithSuspense(lazy(() => import('./pinned')));
const SessionTab = tabWithSuspense(lazy(() => import('./session')));
const EsqlTab = tabWithSuspense(lazy(() => import('./esql')));
const QueryTab = tabWithSuspense(
lazy(() => import('./query')),
<TimelineTabFallback />
);
const EqlTab = tabWithSuspense(
lazy(() => import('./eql')),
<TimelineTabFallback />
);
const GraphTab = tabWithSuspense(
lazy(() => import('./graph')),
<TimelineTabFallback />
);
const NotesTab = tabWithSuspense(
lazy(() => import('./notes')),
<TimelineTabFallback />
);
const PinnedTab = tabWithSuspense(
lazy(() => import('./pinned')),
<TimelineTabFallback />
);
const SessionTab = tabWithSuspense(
lazy(() => import('./session')),
<TimelineTabFallback />
);
const EsqlTab = tabWithSuspense(
lazy(() => import('./esql')),
<TimelineTabFallback />
);
interface BasicTimelineTab {
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
@ -138,60 +150,60 @@ const ActiveTimelineTab = memo<ActiveTimelineTabProps>(
[activeTimelineTab]
);
/* Future developer -> why are we doing that
* It is really expansive to re-render the QueryTab because the drag/drop
* Therefore, we are only hiding its dom when switching to another tab
* to avoid mounting/un-mounting === re-render
*/
return (
<>
<HideShowContainer
$isVisible={TimelineTabs.query === activeTimelineTab}
data-test-subj={`timeline-tab-content-${TimelineTabs.query}`}
<LazyTimelineTabRenderer
timelineId={timelineId}
shouldShowTab={TimelineTabs.query === activeTimelineTab}
dataTestSubj={`timeline-tab-content-${TimelineTabs.query}`}
>
<QueryTab
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</HideShowContainer>
</LazyTimelineTabRenderer>
{showTimeline && shouldShowESQLTab && activeTimelineTab === TimelineTabs.esql && (
<HideShowContainer
$isVisible={true}
data-test-subj={`timeline-tab-content-${TimelineTabs.esql}`}
<LazyTimelineTabRenderer
timelineId={timelineId}
shouldShowTab={true}
dataTestSubj={`timeline-tab-content-${TimelineTabs.esql}`}
>
<EsqlTab timelineId={timelineId} />
</HideShowContainer>
</LazyTimelineTabRenderer>
)}
<HideShowContainer
$isVisible={TimelineTabs.pinned === activeTimelineTab}
data-test-subj={`timeline-tab-content-${TimelineTabs.pinned}`}
<LazyTimelineTabRenderer
timelineId={timelineId}
shouldShowTab={TimelineTabs.pinned === activeTimelineTab}
dataTestSubj={`timeline-tab-content-${TimelineTabs.pinned}`}
>
<PinnedTab
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</HideShowContainer>
</LazyTimelineTabRenderer>
{timelineType === TimelineTypeEnum.default && (
<HideShowContainer
$isVisible={TimelineTabs.eql === activeTimelineTab}
data-test-subj={`timeline-tab-content-${TimelineTabs.eql}`}
<LazyTimelineTabRenderer
timelineId={timelineId}
shouldShowTab={TimelineTabs.eql === activeTimelineTab}
dataTestSubj={`timeline-tab-content-${TimelineTabs.eql}`}
>
<EqlTab
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
timelineId={timelineId}
/>
</HideShowContainer>
</LazyTimelineTabRenderer>
)}
<HideShowContainer
$isVisible={isGraphOrNotesTabs}
<LazyTimelineTabRenderer
timelineId={timelineId}
shouldShowTab={isGraphOrNotesTabs}
isOverflowYScroll={activeTimelineTab === TimelineTabs.session}
data-test-subj={`timeline-tab-content-${TimelineTabs.graph}-${TimelineTabs.notes}`}
dataTestSubj={`timeline-tab-content-${TimelineTabs.graph}-${TimelineTabs.notes}`}
>
{isGraphOrNotesTabs && getTab(activeTimelineTab)}
</HideShowContainer>
{isGraphOrNotesTabs ? getTab(activeTimelineTab) : null}
</LazyTimelineTabRenderer>
</>
);
}

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { render } from '@testing-library/react';
import type { LazyTimelineTabRendererProps } from './lazy_timeline_tab_renderer';
import { LazyTimelineTabRenderer } from './lazy_timeline_tab_renderer';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { TimelineId } from '../../../../../common/types';
jest.mock('../../../../common/hooks/use_selector');
describe('LazyTimelineTabRenderer', () => {
const mockUseDeepEqualSelector = useDeepEqualSelector as jest.Mock;
const defaultProps = {
dataTestSubj: 'test',
shouldShowTab: true,
isOverflowYScroll: false,
timelineId: TimelineId.test,
};
const TestComponent = ({ children, ...restProps }: Partial<LazyTimelineTabRendererProps>) => (
<LazyTimelineTabRenderer {...defaultProps} {...restProps}>
<div>{children ?? 'test component'}</div>
</LazyTimelineTabRenderer>
);
const renderTestComponents = (props?: Partial<LazyTimelineTabRendererProps>) => {
const { children, ...restProps } = props ?? {};
return render(<TestComponent {...restProps}>{children}</TestComponent>);
};
beforeEach(() => {
mockUseDeepEqualSelector.mockClear();
});
describe('timeline visibility', () => {
it('should NOT render children when the timeline show status is false', () => {
mockUseDeepEqualSelector.mockReturnValue({ show: false });
const { queryByText } = renderTestComponents();
expect(queryByText('test component')).not.toBeInTheDocument();
});
it('should render children when the timeline show status is true', () => {
mockUseDeepEqualSelector.mockReturnValue({ show: true });
const { getByText } = renderTestComponents();
expect(getByText('test component')).toBeInTheDocument();
});
});
describe('tab visibility', () => {
it('should not render children when show tab is false', () => {
const { queryByText } = renderTestComponents({ shouldShowTab: false });
expect(queryByText('test component')).not.toBeInTheDocument();
});
});
describe('re-rendering', () => {
const testChildString = 'new content';
const mockFnShouldThatShouldOnlyRunOnce = jest.fn();
const TestChild = () => {
useEffect(() => {
mockFnShouldThatShouldOnlyRunOnce();
}, []);
return <div>{testChildString}</div>;
};
const RerenderTestComponent = (props?: Partial<LazyTimelineTabRendererProps>) => (
<TestComponent {...props}>
<TestChild />
</TestComponent>
);
beforeEach(() => {
jest.resetAllMocks();
mockUseDeepEqualSelector.mockReturnValue({ show: true });
});
it('should NOT re-render children after the first render', () => {
const { queryByText } = render(<RerenderTestComponent />);
expect(queryByText(testChildString)).toBeInTheDocument();
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
});
it('should NOT re-render children even if timeline show status changes', () => {
const { rerender, queryByText } = render(<RerenderTestComponent />);
mockUseDeepEqualSelector.mockReturnValue({ show: false });
rerender(<RerenderTestComponent />);
expect(queryByText(testChildString)).toBeInTheDocument();
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
});
it('should NOT re-render children even if tab visibility status changes', () => {
const { rerender, queryByText } = render(<RerenderTestComponent />);
rerender(<RerenderTestComponent shouldShowTab={false} />);
rerender(<RerenderTestComponent shouldShowTab={true} />);
expect(queryByText(testChildString)).toBeInTheDocument();
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(1);
});
it('should re-render if the component is unmounted and remounted', () => {
const { rerender, queryByText, unmount } = render(<RerenderTestComponent />);
unmount();
rerender(<RerenderTestComponent shouldShowTab={true} />);
expect(queryByText(testChildString)).toBeInTheDocument();
expect(mockFnShouldThatShouldOnlyRunOnce).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState, useEffect } from 'react';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic } from '@elastic/eui';
import type { TimelineId } from '../../../../../common/types';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { getTimelineShowStatusByIdSelector } from '../../../store/selectors';
export interface LazyTimelineTabRendererProps {
children: React.ReactElement | null;
dataTestSubj: string;
isOverflowYScroll?: boolean;
shouldShowTab: boolean;
timelineId: TimelineId;
}
/**
* We check for the timeline open status to request the fields for the fields browser. The fields request
* is often a much longer running request for customers with a significant number of indices and fields in those indices.
* This request should only be made after the user has decided to interact with a specific tab in the timeline to prevent any performance impacts
* to the underlying security solution views, as this query will always run when the timeline exists on the page.
*
* `hasTimelineTabBeenOpenedOnce` - We want to keep timeline loading times as fast as possible after the user
* has chosen to interact with timeline at least once, so we use this flag to prevent re-requesting of this fields data
* every time timeline is closed and re-opened after the first interaction.
*/
export const LazyTimelineTabRenderer = React.memo(
({
children,
dataTestSubj,
shouldShowTab,
isOverflowYScroll,
timelineId,
}: LazyTimelineTabRendererProps) => {
const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []);
const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId));
const [hasTimelineTabBeenOpenedOnce, setHasTimelineTabBeenOpenedOnce] = useState(false);
useEffect(() => {
if (!hasTimelineTabBeenOpenedOnce && show && shouldShowTab) {
setHasTimelineTabBeenOpenedOnce(true);
}
}, [hasTimelineTabBeenOpenedOnce, shouldShowTab, show]);
return (
<div
// The shouldShowTab check here is necessary for the flex container to accurately size to the modal window when it's opened
css={css`
display: ${shouldShowTab ? 'flex' : 'none'};
overflow: ${isOverflowYScroll ? 'hidden scroll' : 'hidden'};
flex: 1;
`}
data-test-subj={dataTestSubj}
>
{hasTimelineTabBeenOpenedOnce ? children : <TimelineTabFallback />}
</div>
);
}
);
LazyTimelineTabRenderer.displayName = 'LazyTimelineTabRenderer';
export const TimelineTabFallback = () => (
<EuiFlexGroup direction="row" justifyContent="spaceAround">
<EuiFlexItem
grow={false}
css={css`
justify-content: center;
`}
>
<EuiLoadingElastic size="xxl" />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,227 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import React, { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { RunTimeMappings } from '@kbn/timelines-plugin/common/search_strategy';
import { EuiLoadingSpinner } from '@elastic/eui';
import { DataLoadingState } from '@kbn/unified-data-table';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { useTimelineDataFilters } from '../../../../containers/use_timeline_data_filters';
import { useInvalidFilterQuery } from '../../../../../common/hooks/use_invalid_filter_query';
import { timelineActions, timelineSelectors } from '../../../../store';
import type { Direction } from '../../../../../../common/search_strategy';
import { useTimelineEvents } from '../../../../containers';
import { useKibana } from '../../../../../common/lib/kibana';
import { combineQueries } from '../../../../../common/lib/kuery';
import type {
KueryFilterQuery,
KueryFilterQueryKind,
} from '../../../../../../common/types/timeline';
import type { inputsModel } from '../../../../../common/store';
import { inputsSelectors } from '../../../../../common/store';
import { SourcererScopeName } from '../../../../../sourcerer/store/model';
import { timelineDefaults } from '../../../../store/defaults';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { isActiveTimeline } from '../../../../../helpers';
import type { TimelineModel } from '../../../../store/model';
import { useTimelineColumns } from '../shared/use_timeline_columns';
import { EventsCountBadge } from '../shared/layout';
/**
* TODO: This component is a pared down duplicate of the logic used in timeline/tabs/query/index.tsx
* This is only done to support the events count badge that shows in the bottom bar of the application,
* without needing to render the entire query tab, which is expensive to render at a significant enough fields count.
* The long term solution is a centralized query either via RTK or useQuery, that both can read from, but that is out of scope
* at this current time.
*/
const emptyFieldsList: string[] = [];
export const TimelineQueryTabEventsCountComponent: React.FC<{ timelineId: string }> = ({
timelineId,
}) => {
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const getKqlQueryTimeline = useMemo(() => timelineSelectors.getKqlFilterKuerySelector(), []);
const getInputsTimeline = useMemo(() => inputsSelectors.getTimelineSelector(), []);
const timeline: TimelineModel = useDeepEqualSelector(
(state) => getTimeline(state, timelineId) ?? timelineDefaults
);
const input: inputsModel.InputsRange = useDeepEqualSelector((state) => getInputsTimeline(state));
const { timerange: { to: end, from: start, kind: timerangeKind } = {} } = input;
const {
columns,
dataProviders,
filters: currentTimelineFilters,
kqlMode,
sort,
timelineType,
} = timeline;
const kqlQueryTimeline: KueryFilterQuery | null = useDeepEqualSelector((state) =>
getKqlQueryTimeline(state, timelineId)
);
const filters = useMemo(
() => (kqlMode === 'filter' ? currentTimelineFilters || [] : []),
[currentTimelineFilters, kqlMode]
);
// return events on empty search
const kqlQueryExpression =
isEmpty(dataProviders) &&
isEmpty(kqlQueryTimeline?.expression ?? '') &&
timelineType === 'template'
? ' '
: kqlQueryTimeline?.expression ?? '';
const kqlQueryLanguage =
isEmpty(dataProviders) && timelineType === 'template'
? 'kuery'
: kqlQueryTimeline?.kind ?? 'kuery';
const dispatch = useDispatch();
const {
browserFields,
dataViewId,
loading: loadingSourcerer,
// important to get selectedPatterns from useSourcererDataView
// in order to include the exclude filters in the search that are not stored in the timeline
selectedPatterns,
sourcererDataView,
} = useSourcererDataView(SourcererScopeName.timeline);
/*
* `pageIndex` needs to be maintained for each table in each tab independently
* and consequently it cannot be the part of common redux state
* of the timeline.
*
*/
const { uiSettings, timelineDataService } = useKibana().services;
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
const kqlQuery: {
query: string;
language: KueryFilterQueryKind;
} = useMemo(
() => ({ query: kqlQueryExpression.trim(), language: kqlQueryLanguage }),
[kqlQueryExpression, kqlQueryLanguage]
);
const combinedQueries = useMemo(() => {
return combineQueries({
config: esQueryConfig,
dataProviders,
indexPattern: sourcererDataView,
browserFields,
filters,
kqlQuery,
kqlMode,
});
}, [esQueryConfig, dataProviders, sourcererDataView, browserFields, filters, kqlQuery, kqlMode]);
useInvalidFilterQuery({
id: timelineId,
filterQuery: combinedQueries?.filterQuery,
kqlError: combinedQueries?.kqlError,
query: kqlQuery,
startDate: start,
endDate: end,
});
const isBlankTimeline: boolean =
isEmpty(dataProviders) &&
isEmpty(filters) &&
isEmpty(kqlQuery.query) &&
combinedQueries?.filterQuery === undefined;
const canQueryTimeline = useMemo(
() =>
combinedQueries != null &&
loadingSourcerer != null &&
!loadingSourcerer &&
!isEmpty(start) &&
!isEmpty(end) &&
combinedQueries?.filterQuery !== undefined,
[combinedQueries, end, loadingSourcerer, start]
);
const timelineQuerySortField = useMemo(() => {
return sort.map(({ columnId, columnType, esTypes, sortDirection }) => ({
field: columnId,
direction: sortDirection as Direction,
esTypes: esTypes ?? [],
type: columnType,
}));
}, [sort]);
const { defaultColumns } = useTimelineColumns(columns);
const [dataLoadingState, { totalCount }] = useTimelineEvents({
dataViewId,
endDate: end,
fields: emptyFieldsList,
filterQuery: combinedQueries?.filterQuery,
id: timelineId,
indexNames: selectedPatterns,
language: kqlQuery.language,
limit: 0, // We only care about the totalCount here
runtimeMappings: sourcererDataView.runtimeFieldMap as RunTimeMappings,
skip: !canQueryTimeline,
sort: timelineQuerySortField,
startDate: start,
timerangeKind,
});
useEffect(() => {
dispatch(
timelineActions.initializeTimelineSettings({
id: timelineId,
defaultColumns,
})
);
}, [dispatch, timelineId, defaultColumns]);
// NOTE: The timeline is blank after browser FORWARD navigation (after using back button to navigate to
// the previous page from the timeline), yet we still see total count. This is because the timeline
// is not getting refreshed when using browser navigation.
const showEventsCountBadge = !isBlankTimeline && totalCount >= 0;
// <Synchronisation of the timeline data service>
// Sync the timerange
const timelineFilters = useTimelineDataFilters(isActiveTimeline(timelineId));
useEffect(() => {
timelineDataService.query.timefilter.timefilter.setTime({
from: timelineFilters.from,
to: timelineFilters.to,
});
}, [timelineDataService.query.timefilter.timefilter, timelineFilters.from, timelineFilters.to]);
// Sync the base query
useEffect(() => {
timelineDataService.query.queryString.setQuery(
// We're using the base query of all combined queries here, to account for all
// of timeline's query dependencies (data providers, query etc.)
combinedQueries?.baseKqlQuery || { language: kqlQueryLanguage, query: '' }
);
}, [timelineDataService, combinedQueries, kqlQueryLanguage]);
// </Synchronisation of the timeline data service>
if (!showEventsCountBadge) return null;
return dataLoadingState === DataLoadingState.loading ||
dataLoadingState === DataLoadingState.loadingMore ? (
<EuiLoadingSpinner size="s" />
) : (
<EventsCountBadge data-test-subj="query-events-count">{totalCount}</EventsCountBadge>
);
};
const TimelineQueryTabEventsCount = React.memo(TimelineQueryTabEventsCountComponent);
// eslint-disable-next-line import/no-default-export
export { TimelineQueryTabEventsCount as default };

View file

@ -10,14 +10,16 @@ import React from 'react';
import { TimelineDataTable } from '.';
import { TimelineId, TimelineTabs } from '../../../../../../common/types';
import { DataLoadingState } from '@kbn/unified-data-table';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { DataView } from '@kbn/data-views-plugin/common';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import type { ComponentProps } from 'react';
import { getColumnHeaders } from '../../body/column_headers/helpers';
import { mockSourcererScope } from '../../../../../sourcerer/containers/mocks';
import * as timelineActions from '../../../../store/actions';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { defaultUdtHeaders } from '../../body/column_headers/default_headers';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
jest.mock('../../../../../sourcerer/containers');
@ -54,6 +56,11 @@ type TestComponentProps = Partial<ComponentProps<typeof TimelineDataTable>> & {
// that is why we are setting it to 10s
const SPECIAL_TEST_TIMEOUT = 50000;
const mockDataView = new DataView({
spec: mockSourcererScope.sourcererDataView,
fieldFormats: fieldFormatsMock,
});
const TestComponent = (props: TestComponentProps) => {
const { store = createMockStore(), ...restProps } = props;
useSourcererDataView();
@ -62,6 +69,7 @@ const TestComponent = (props: TestComponentProps) => {
<TimelineDataTable
columns={initialEnrichedColumns}
columnIds={initialEnrichedColumnsIds}
dataView={mockDataView}
activeTab={TimelineTabs.query}
timelineId={TimelineId.test}
itemsPerPage={50}

View file

@ -24,7 +24,6 @@ import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_det
import { selectTimelineById } from '../../../../store/selectors';
import { RowRendererCount } from '../../../../../../common/api/timeline';
import { EmptyComponent } from '../../../../../common/lib/cell_actions/helpers';
import { withDataView } from '../../../../../common/components/with_data_view';
import { StatefulEventContext } from '../../../../../common/components/events_viewer/stateful_event_context';
import type { TimelineItem } from '../../../../../../common/search_strategy';
import { useKibana } from '../../../../../common/lib/kibana';
@ -432,7 +431,7 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
}
);
export const TimelineDataTable = withDataView<DataTableProps>(TimelineDataTableComponent);
export const TimelineDataTable = React.memo(TimelineDataTableComponent);
// eslint-disable-next-line import/no-default-export
export { TimelineDataTable as default };

View file

@ -34,6 +34,8 @@ import { DataLoadingState } from '@kbn/unified-data-table';
import { getColumnHeaders } from '../body/column_headers/helpers';
import { defaultUdtHeaders } from '../body/column_headers/default_headers';
import type { ColumnHeaderType } from '../../../../../common/types';
import { DataView } from '@kbn/data-views-plugin/common';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
jest.mock('../../../containers', () => ({
useTimelineEvents: jest.fn(),
@ -85,6 +87,11 @@ const SPECIAL_TEST_TIMEOUT = 50000;
const localMockedTimelineData = structuredClone(mockTimelineData);
const mockDataView = new DataView({
spec: mockSourcererScope.sourcererDataView,
fieldFormats: fieldFormatsMock,
});
const TestComponent = (
props: Partial<ComponentProps<typeof UnifiedTimeline>> & { show?: boolean }
) => {
@ -92,6 +99,7 @@ const TestComponent = (
const testComponentDefaultProps: ComponentProps<typeof QueryTabContent> = {
columns: getColumnHeaders(columnsToDisplay, mockSourcererScope.browserFields),
activeTab: TimelineTabs.query,
dataView: mockDataView,
rowRenderers: [],
timelineId: TimelineId.test,
itemsPerPage: 10,
@ -513,50 +521,14 @@ describe('unified timeline', () => {
});
describe('unified field list', () => {
describe('render', () => {
let TestProviderWithNewStore: FC<PropsWithChildren<unknown>>;
beforeEach(() => {
const freshStore = createMockStore();
// eslint-disable-next-line react/display-name
TestProviderWithNewStore = ({ children }) => {
return <TestProviders store={freshStore}>{children}</TestProviders>;
};
});
it(
'should not render when timeline has never been opened',
async () => {
render(<TestComponent show={false} />, {
wrapper: TestProviderWithNewStore,
});
expect(await screen.queryByTestId('timeline-sidebar')).not.toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
it(
'should render when timeline has been opened',
async () => {
render(<TestComponent />, {
wrapper: TestProviderWithNewStore,
});
expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
it(
'should not re-render when timeline has been opened at least once',
async () => {
const { rerender } = render(<TestComponent />, {
wrapper: TestProviderWithNewStore,
});
rerender(<TestComponent show={false} />);
// Even after timeline is closed, it should still exist in the background
expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
});
it(
'should render',
async () => {
renderTestComponents();
expect(await screen.queryByTestId('timeline-sidebar')).toBeInTheDocument();
},
SPECIAL_TEST_TIMEOUT
);
it(
'should be able to add filters',

View file

@ -6,7 +6,7 @@
*/
import type { EuiDataGridProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiHideFor, useEuiTheme } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { generateFilters } from '@kbn/data-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-plugin/common';
@ -26,8 +26,6 @@ import { UnifiedFieldListSidebarContainer } from '@kbn/unified-field-list';
import type { EuiTheme } from '@kbn/react-kibana-context-styled';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { withDataView } from '../../../../common/components/with_data_view';
import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context';
import type { TimelineItem } from '../../../../../common/search_strategy';
import { useKibana } from '../../../../common/lib/kibana';
@ -47,7 +45,6 @@ import TimelineDataTable from './data_table';
import { timelineActions } from '../../../store';
import { getFieldsListCreationOptions } from './get_fields_list_creation_options';
import { defaultUdtHeaders } from '../body/column_headers/default_headers';
import { getTimelineShowStatusByIdSelector } from '../../../store/selectors';
const TimelineBodyContainer = styled.div.attrs(({ className = '' }) => ({
className: `${className}`,
@ -347,31 +344,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
onFieldEdited();
}, [onFieldEdited]);
// PERFORMANCE ONLY CODE BLOCK
/**
* We check for the timeline open status to request the fields for the fields browser as the fields request
* is often a much longer running request for customers with a significant number of indices and fields in those indices.
* This request should only be made after the user has decided to interact with timeline to prevent any performance impacts
* to the underlying security solution views, as this query will always run when the timeline exists on the page.
*
* `hasTimelineBeenOpenedOnce` - We want to keep timeline loading times as fast as possible after the user
* has chosen to interact with timeline at least once, so we use this flag to prevent re-requesting of this fields data
* every time timeline is closed and re-opened after the first interaction.
*/
const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []);
const { show } = useDeepEqualSelector((state) => getTimelineShowStatus(state, timelineId));
const [hasTimelineBeenOpenedOnce, setHasTimelineBeenOpenedOnce] = useState(false);
useEffect(() => {
if (!hasTimelineBeenOpenedOnce && show) {
setHasTimelineBeenOpenedOnce(true);
}
}, [hasTimelineBeenOpenedOnce, show]);
// END PERFORMANCE ONLY CODE BLOCK
return (
<TimelineBodyContainer className="timelineBodyContainer" ref={setSidebarContainer}>
<TimelineResizableLayout
@ -380,7 +352,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
sidebarPanel={
<SidebarPanelFlexGroup gutterSize="none">
<EuiFlexItem className="sidebarContainer">
{dataView && hasTimelineBeenOpenedOnce ? (
{dataView && (
<UnifiedFieldListSidebarContainer
ref={unifiedFieldListContainerRef}
showFieldList
@ -396,7 +368,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
onAddFilter={onAddFilter}
onFieldEdited={wrappedOnFieldEdited}
/>
) : null}
)}
</EuiFlexItem>
<EuiHideFor sizes={HIDE_FOR_SIZES}>
<EuiFlexItem
@ -430,6 +402,7 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
<DataGridMemoized
columns={columns}
columnIds={currentColumnIds}
dataView={dataView}
rowRenderers={rowRenderers}
timelineId={timelineId}
isSortEnabled={isSortEnabled}
@ -463,6 +436,6 @@ const UnifiedTimelineComponent: React.FC<Props> = ({
);
};
export const UnifiedTimeline = React.memo(withDataView<Props>(UnifiedTimelineComponent));
export const UnifiedTimeline = React.memo(UnifiedTimelineComponent);
// eslint-disable-next-line import/no-default-export
export { UnifiedTimeline as default };

View file

@ -219,22 +219,27 @@ export const useTimelineEventsHandler = ({
setActiveBatch(0);
}, [limit]);
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>({
id,
inspect: {
dsl: [],
response: [],
},
refetch: () => {},
totalCount: -1,
pageInfo: {
activePage: 0,
querySize: 0,
},
events: [],
loadNextBatch,
refreshedAt: 0,
});
const defaultTimelineResponse = useMemo(
() => ({
id,
inspect: {
dsl: [],
response: [],
},
refetch: () => {},
totalCount: -1,
pageInfo: {
activePage: 0,
querySize: 0,
},
events: [],
loadNextBatch,
refreshedAt: 0,
}),
[id, loadNextBatch]
);
const [timelineResponse, setTimelineResponse] = useState<TimelineArgs>(defaultTimelineResponse);
const timelineSearch = useCallback(
async (
@ -375,95 +380,98 @@ export const useTimelineEventsHandler = ({
return;
}
setTimelineRequest((prevRequest) => {
const prevEqlRequest = prevRequest as TimelineEqlRequestOptionsInput;
const prevSearchParameters = {
defaultIndex: prevRequest?.defaultIndex ?? [],
filterQuery: prevRequest?.filterQuery ?? '',
sort: prevRequest?.sort ?? initSortDefault,
timerange: prevRequest?.timerange ?? {},
runtimeMappings: (prevRequest?.runtimeMappings ?? {}) as unknown as RunTimeMappings,
...deStructureEqlOptions(prevEqlRequest),
};
const timerange =
startDate && endDate
? { timerange: { interval: '12h', from: startDate, to: endDate } }
: {};
const currentSearchParameters = {
defaultIndex: indexNames,
filterQuery: createFilter(filterQuery),
sort,
runtimeMappings: runtimeMappings ?? {},
...timerange,
...deStructureEqlOptions(eqlOptions),
};
const areSearchParamsSame = deepEqual(prevSearchParameters, currentSearchParameters);
const newActiveBatch = !areSearchParamsSame ? 0 : activeBatch;
/*
* optimization to avoid unnecessary network request when a field
* has already been fetched
*
*/
let finalFieldRequest = fields;
const newFieldsRequested = fields.filter(
(field) => !prevRequest?.fieldRequested?.includes(field)
);
if (newFieldsRequested.length > 0) {
finalFieldRequest = [...(prevRequest?.fieldRequested ?? []), ...newFieldsRequested];
} else {
finalFieldRequest = prevRequest?.fieldRequested ?? [];
}
let newPagination = {
/*
*
* fetches data cumulatively for the batches upto the activeBatch
* This is needed because, we want to get incremental data as well for the old batches
* For example, newly requested fields
*
* */
activePage: newActiveBatch,
querySize: limit,
};
if (newFieldsRequested.length > 0) {
newPagination = {
activePage: 0,
querySize: (newActiveBatch + 1) * limit,
// Only set timeline request when an actual query exists
if (filterQuery || eqlOptions?.query) {
setTimelineRequest((prevRequest) => {
const prevEqlRequest = prevRequest as TimelineEqlRequestOptionsInput;
const prevSearchParameters = {
defaultIndex: prevRequest?.defaultIndex ?? [],
filterQuery: prevRequest?.filterQuery ?? '',
sort: prevRequest?.sort ?? initSortDefault,
timerange: prevRequest?.timerange ?? {},
runtimeMappings: (prevRequest?.runtimeMappings ?? {}) as unknown as RunTimeMappings,
...deStructureEqlOptions(prevEqlRequest),
};
}
const currentRequest = {
defaultIndex: indexNames,
factoryQueryType: TimelineEventsQueries.all,
fieldRequested: finalFieldRequest,
fields: finalFieldRequest,
filterQuery: createFilter(filterQuery),
pagination: newPagination,
language,
runtimeMappings,
sort,
...timerange,
...(eqlOptions ? eqlOptions : {}),
} as const;
const timerange =
startDate && endDate
? { timerange: { interval: '12h', from: startDate, to: endDate } }
: {};
const currentSearchParameters = {
defaultIndex: indexNames,
filterQuery: createFilter(filterQuery),
sort,
runtimeMappings: runtimeMappings ?? {},
...timerange,
...deStructureEqlOptions(eqlOptions),
};
if (activeBatch !== newActiveBatch) {
setActiveBatch(newActiveBatch);
if (id === TimelineId.active) {
activeTimeline.setActivePage(newActiveBatch);
const areSearchParamsSame = deepEqual(prevSearchParameters, currentSearchParameters);
const newActiveBatch = !areSearchParamsSame ? 0 : activeBatch;
/*
* optimization to avoid unnecessary network request when a field
* has already been fetched
*
*/
let finalFieldRequest = fields;
const newFieldsRequested = fields.filter(
(field) => !prevRequest?.fieldRequested?.includes(field)
);
if (newFieldsRequested.length > 0) {
finalFieldRequest = [...(prevRequest?.fieldRequested ?? []), ...newFieldsRequested];
} else {
finalFieldRequest = prevRequest?.fieldRequested ?? [];
}
}
if (!deepEqual(prevRequest, currentRequest)) {
return currentRequest;
}
return prevRequest;
});
let newPagination = {
/*
*
* fetches data cumulatively for the batches upto the activeBatch
* This is needed because, we want to get incremental data as well for the old batches
* For example, newly requested fields
*
* */
activePage: newActiveBatch,
querySize: limit,
};
if (newFieldsRequested.length > 0) {
newPagination = {
activePage: 0,
querySize: (newActiveBatch + 1) * limit,
};
}
const currentRequest = {
defaultIndex: indexNames,
factoryQueryType: TimelineEventsQueries.all,
fieldRequested: finalFieldRequest,
fields: finalFieldRequest,
filterQuery: createFilter(filterQuery),
pagination: newPagination,
language,
runtimeMappings,
sort,
...timerange,
...(eqlOptions ? eqlOptions : {}),
} as const;
if (activeBatch !== newActiveBatch) {
setActiveBatch(newActiveBatch);
if (id === TimelineId.active) {
activeTimeline.setActivePage(newActiveBatch);
}
}
if (!deepEqual(prevRequest, currentRequest)) {
return currentRequest;
}
return prevRequest;
});
}
}, [
dispatch,
indexNames,
@ -486,24 +494,9 @@ export const useTimelineEventsHandler = ({
*/
useEffect(() => {
if (isEmpty(filterQuery)) {
setTimelineResponse({
id,
inspect: {
dsl: [],
response: [],
},
refetch: () => {},
totalCount: -1,
pageInfo: {
activePage: 0,
querySize: 0,
},
events: [],
loadNextBatch,
refreshedAt: 0,
});
setTimelineResponse(defaultTimelineResponse);
}
}, [filterQuery, id, loadNextBatch]);
}, [defaultTimelineResponse, filterQuery]);
const timelineSearchHandler = useCallback(
async (onNextHandler?: OnNextResponseHandler) => {
@ -529,6 +522,8 @@ export const useTimelineEventsHandler = ({
return [loading, finalTimelineLineResponse, timelineSearchHandler];
};
const defaultEvents: TimelineItem[][] = [];
export const useTimelineEvents = ({
dataViewId,
endDate,
@ -545,7 +540,7 @@ export const useTimelineEvents = ({
skip = false,
timerangeKind,
}: UseTimelineEventsProps): [DataLoadingState, TimelineArgs] => {
const [eventsPerPage, setEventsPerPage] = useState<TimelineItem[][]>([[]]);
const [eventsPerPage, setEventsPerPage] = useState<TimelineItem[][]>(defaultEvents);
const [dataLoadingState, timelineResponse, timelineSearchHandler] = useTimelineEventsHandler({
dataViewId,
endDate,
@ -576,9 +571,15 @@ export const useTimelineEvents = ({
const { activePage, querySize } = timelineResponse.pageInfo;
setEventsPerPage((prev) => {
let result = [...prev];
let result = structuredClone(prev);
const newEventsLength = timelineResponse.events.length;
const oldEventsLength = result.length;
if (querySize === limit && activePage > 0) {
result[activePage] = timelineResponse.events;
} else if (oldEventsLength === 0 && newEventsLength === 0) {
// don't change array reference if no actual changes take place
result = prev;
} else {
result = [timelineResponse.events];
}