Skip to content

Commit

Permalink
[8.x] [Embeddable] Avoid rerendering loop due to search context reload (
Browse files Browse the repository at this point in the history
#203150) (#203818)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Embeddable] Avoid rerendering loop due to search context reload
(#203150)](#203150)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Marco
Liberati","email":"dej611@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-12-11T15:03:36Z","message":"[Embeddable]
Avoid rerendering loop due to search context reload (#203150)\n\n##
Summary\r\n\r\nFixes #202266\r\n\r\nThis PR fixes the underline
rerendering issue at the `useSearchApi` hook\r\nlevel, so any embeddable
component who uses this hook would benefit from\r\nthe
fix.\r\n\r\nIdeally the props passed to the Lens component should be
memoized, but\r\nthis assumption would break many integrations as the
previous embeddable\r\ncomponent did take care of filtering
duplicates.\r\nTo test this:\r\n* Go to `Observability > Alerts > Manage
rules` and `Add Rule`, pick the\r\n`Custom threshold` option and verify
the infinite reload does not happen\r\n\r\nOnce fixed this, another
problem bubbled up with the brushing use case:\r\nwhen brushing a chart
the chart was always one time range step behind.\r\nThe other bug was
due to the `fetch$(api)` function propagating a stale\r\n`data` search
context who the Lens embeddable was relying on.\r\nTo solve this other
problem the following changes have been applied:\r\n* read the
`searchSessionId` from the `api` directly (used
for\r\n`autoRefresh`)\r\n* make sure to test the `Refresh` feature with
both relative and\r\nabsolute time ranges\r\n* read the `search context`
from the `parentApi` directly if implements\r\nthe `unifiedSearch`
API\r\n* to test this, brush and check that the final time range matches
the\r\ncorrect time range\r\n\r\n**Note**: the fundamental issue for the
latter is `fetch# Backport

This will backport the following commits from `main` to `8.x`:
- [[Embeddable] Avoid rerendering loop due to search context reload
(#203150)](#203150)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT not emitting\r\nthe up-to-date `data` when the parentApi
search context updates. The\r\nretrieved `data` is stale and one step
behind, so it is not reliable.
cc\r\n@elastic/kibana-presentation\r\n\r\nAs @ThomThomson noticed in his
test failure investigation another issue\r\nwas found in this PR due to
the wrong handling of the searchSessionId\r\nwithin the Observability
page (for more details see
[his\r\nanalysis](https://github.com/elastic/kibana/pull/203150#issuecomment-2524080129)).\r\n@markov00
and @crespocarlos helped risolve this problem with some\r\nadditional
changes on the Observability side of things: this will lead\r\nto some
extra searchSessionId to be created, which will be eventually\r\nsolved
by Observability team [shortly moving away from
the\r\n`searchSessionId`\r\nmechanism](https://github.com/elastic/kibana/issues/203412)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Marco Vettorello
<marco.vettorello@elastic.co>\r\nCo-authored-by: Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"d4194ba5eb5a72960dad00cf956d9ea6e31219e0","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Presentation","v9.0.0","backport:prev-minor","Feature:Embeddables","ci:project-deploy-observability","Team:obs-ux-management"],"title":"[Embeddable]
Avoid rerendering loop due to search context
reload","number":203150,"url":"https://github.com/elastic/kibana/pull/203150","mergeCommit":{"message":"[Embeddable]
Avoid rerendering loop due to search context reload (#203150)\n\n##
Summary\r\n\r\nFixes #202266\r\n\r\nThis PR fixes the underline
rerendering issue at the `useSearchApi` hook\r\nlevel, so any embeddable
component who uses this hook would benefit from\r\nthe
fix.\r\n\r\nIdeally the props passed to the Lens component should be
memoized, but\r\nthis assumption would break many integrations as the
previous embeddable\r\ncomponent did take care of filtering
duplicates.\r\nTo test this:\r\n* Go to `Observability > Alerts > Manage
rules` and `Add Rule`, pick the\r\n`Custom threshold` option and verify
the infinite reload does not happen\r\n\r\nOnce fixed this, another
problem bubbled up with the brushing use case:\r\nwhen brushing a chart
the chart was always one time range step behind.\r\nThe other bug was
due to the `fetch$(api)` function propagating a stale\r\n`data` search
context who the Lens embeddable was relying on.\r\nTo solve this other
problem the following changes have been applied:\r\n* read the
`searchSessionId` from the `api` directly (used
for\r\n`autoRefresh`)\r\n* make sure to test the `Refresh` feature with
both relative and\r\nabsolute time ranges\r\n* read the `search context`
from the `parentApi` directly if implements\r\nthe `unifiedSearch`
API\r\n* to test this, brush and check that the final time range matches
the\r\ncorrect time range\r\n\r\n**Note**: the fundamental issue for the
latter is `fetch# Backport

This will backport the following commits from `main` to `8.x`:
- [[Embeddable] Avoid rerendering loop due to search context reload
(#203150)](#203150)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT not emitting\r\nthe up-to-date `data` when the parentApi
search context updates. The\r\nretrieved `data` is stale and one step
behind, so it is not reliable.
cc\r\n@elastic/kibana-presentation\r\n\r\nAs @ThomThomson noticed in his
test failure investigation another issue\r\nwas found in this PR due to
the wrong handling of the searchSessionId\r\nwithin the Observability
page (for more details see
[his\r\nanalysis](https://github.com/elastic/kibana/pull/203150#issuecomment-2524080129)).\r\n@markov00
and @crespocarlos helped risolve this problem with some\r\nadditional
changes on the Observability side of things: this will lead\r\nto some
extra searchSessionId to be created, which will be eventually\r\nsolved
by Observability team [shortly moving away from
the\r\n`searchSessionId`\r\nmechanism](https://github.com/elastic/kibana/issues/203412)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Marco Vettorello
<marco.vettorello@elastic.co>\r\nCo-authored-by: Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"d4194ba5eb5a72960dad00cf956d9ea6e31219e0"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/203150","number":203150,"mergeCommit":{"message":"[Embeddable]
Avoid rerendering loop due to search context reload (#203150)\n\n##
Summary\r\n\r\nFixes #202266\r\n\r\nThis PR fixes the underline
rerendering issue at the `useSearchApi` hook\r\nlevel, so any embeddable
component who uses this hook would benefit from\r\nthe
fix.\r\n\r\nIdeally the props passed to the Lens component should be
memoized, but\r\nthis assumption would break many integrations as the
previous embeddable\r\ncomponent did take care of filtering
duplicates.\r\nTo test this:\r\n* Go to `Observability > Alerts > Manage
rules` and `Add Rule`, pick the\r\n`Custom threshold` option and verify
the infinite reload does not happen\r\n\r\nOnce fixed this, another
problem bubbled up with the brushing use case:\r\nwhen brushing a chart
the chart was always one time range step behind.\r\nThe other bug was
due to the `fetch$(api)` function propagating a stale\r\n`data` search
context who the Lens embeddable was relying on.\r\nTo solve this other
problem the following changes have been applied:\r\n* read the
`searchSessionId` from the `api` directly (used
for\r\n`autoRefresh`)\r\n* make sure to test the `Refresh` feature with
both relative and\r\nabsolute time ranges\r\n* read the `search context`
from the `parentApi` directly if implements\r\nthe `unifiedSearch`
API\r\n* to test this, brush and check that the final time range matches
the\r\ncorrect time range\r\n\r\n**Note**: the fundamental issue for the
latter is `fetch# Backport

This will backport the following commits from `main` to `8.x`:
- [[Embeddable] Avoid rerendering loop due to search context reload
(#203150)](#203150)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT not emitting\r\nthe up-to-date `data` when the parentApi
search context updates. The\r\nretrieved `data` is stale and one step
behind, so it is not reliable.
cc\r\n@elastic/kibana-presentation\r\n\r\nAs @ThomThomson noticed in his
test failure investigation another issue\r\nwas found in this PR due to
the wrong handling of the searchSessionId\r\nwithin the Observability
page (for more details see
[his\r\nanalysis](https://github.com/elastic/kibana/pull/203150#issuecomment-2524080129)).\r\n@markov00
and @crespocarlos helped risolve this problem with some\r\nadditional
changes on the Observability side of things: this will lead\r\nto some
extra searchSessionId to be created, which will be eventually\r\nsolved
by Observability team [shortly moving away from
the\r\n`searchSessionId`\r\nmechanism](https://github.com/elastic/kibana/issues/203412)\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Marco Vettorello
<marco.vettorello@elastic.co>\r\nCo-authored-by: Carlos Crespo
<crespocarlos@users.noreply.github.com>","sha":"d4194ba5eb5a72960dad00cf956d9ea6e31219e0"}}]}]
BACKPORT-->

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
  • Loading branch information
kibanamachine and dej611 authored Dec 11, 2024
1 parent 35488c3 commit 8eb5049
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import {
AggregateQuery,
COMPARE_ALL_OPTIONS,
Filter,
Query,
TimeRange,
onlyDisabledFiltersChanged,
} from '@kbn/es-query';
import fastIsEqual from 'fast-deep-equal';
import { useEffect, useMemo } from 'react';
import { BehaviorSubject } from 'rxjs';
import { PublishingSubject } from '../../publishing_subject';
Expand Down Expand Up @@ -112,15 +120,27 @@ export function useSearchApi({
}, []);

useEffect(() => {
searchApi.filters$.next(filters);
if (
!onlyDisabledFiltersChanged(searchApi.filters$.getValue(), filters, {
...COMPARE_ALL_OPTIONS,
// do not compare $state to avoid refreshing when filter is pinned/unpinned (which does not impact results)
state: false,
})
) {
searchApi.filters$.next(filters);
}
}, [filters, searchApi.filters$]);

useEffect(() => {
searchApi.query$.next(query);
if (!fastIsEqual(searchApi.query$.getValue(), query)) {
searchApi.query$.next(query);
}
}, [query, searchApi.query$]);

useEffect(() => {
searchApi.timeRange$.next(timeRange);
if (!fastIsEqual(searchApi.timeRange$.getValue(), timeRange)) {
searchApi.timeRange$.next(timeRange);
}
}, [timeRange, searchApi.timeRange$]);

return searchApi;
Expand Down
54 changes: 24 additions & 30 deletions x-pack/plugins/lens/public/react_embeddable/data_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
*/

import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import { fetch$, type FetchContext } from '@kbn/presentation-publishing';
import { apiPublishesSearchSession } from '@kbn/presentation-publishing/interfaces/fetch/publishes_search_session';
import { apiPublishesUnifiedSearch, fetch$ } from '@kbn/presentation-publishing';
import { type KibanaExecutionContext } from '@kbn/core/public';
import {
BehaviorSubject,
Expand All @@ -21,6 +20,7 @@ import {
map,
} from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { pick } from 'lodash';
import { getEditPath } from '../../common/constants';
import type {
GetStateType,
Expand Down Expand Up @@ -54,6 +54,24 @@ type ReloadReason =
| 'viewMode'
| 'searchContext';

function getSearchContext(parentApi: unknown) {
const unifiedSearch$ = apiPublishesUnifiedSearch(parentApi)
? pick(parentApi, 'filters$', 'query$', 'timeslice$', 'timeRange$')
: {
filters$: new BehaviorSubject(undefined),
query$: new BehaviorSubject(undefined),
timeslice$: new BehaviorSubject(undefined),
timeRange$: new BehaviorSubject(undefined),
};

return {
filters: unifiedSearch$.filters$.getValue(),
query: unifiedSearch$.query$.getValue(),
timeRange: unifiedSearch$.timeRange$.getValue(),
timeslice: unifiedSearch$.timeslice$?.getValue(),
};
}

/**
* The function computes the expression used to render the panel and produces the necessary props
* for the ExpressionWrapper component, binding any outer context to them.
Expand Down Expand Up @@ -112,16 +130,6 @@ export function loadEmbeddableData(
}
};

const unifiedSearch$ = new BehaviorSubject<
Pick<FetchContext, 'query' | 'filters' | 'timeRange' | 'timeslice' | 'searchSessionId'>
>({
query: undefined,
filters: undefined,
timeRange: undefined,
timeslice: undefined,
searchSessionId: undefined,
});

async function reload(
// make reload easier to debug
sourceId: ReloadReason
Expand All @@ -142,8 +150,6 @@ export function loadEmbeddableData(

const currentState = getState();

const { searchSessionId, ...unifiedSearch } = unifiedSearch$.getValue();

const getExecutionContext = () => {
const parentContext = getParentContext(parentApi);
const lastState = getState();
Expand Down Expand Up @@ -198,7 +204,7 @@ export function loadEmbeddableData(

const searchContext = getMergedSearchContext(
currentState,
unifiedSearch,
getSearchContext(parentApi),
api.timeRange$,
parentApi,
services
Expand All @@ -216,7 +222,7 @@ export function loadEmbeddableData(
},
renderMode: getRenderMode(parentApi),
services,
searchSessionId,
searchSessionId: api.searchSessionId$.getValue(),
abortController: internalApi.expressionAbortController$.getValue(),
getExecutionContext,
logError: getLogError(getExecutionContext),
Expand Down Expand Up @@ -259,20 +265,8 @@ export function loadEmbeddableData(
}

const mergedSubscriptions = merge(
// on data change from the parentApi, reload
fetch$(api).pipe(
tap((data) => {
const searchSessionId = apiPublishesSearchSession(parentApi) ? data.searchSessionId : '';
unifiedSearch$.next({
query: data.query,
filters: data.filters,
timeRange: data.timeRange,
timeslice: data.timeslice,
searchSessionId,
});
}),
map(() => 'searchContext' as ReloadReason)
),
// on search context change, reload
fetch$(api).pipe(map(() => 'searchContext' as ReloadReason)),
// On state change, reload
// this is used to refresh the chart on inline editing
// just make sure to avoid to rerender if there's no substantial change
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const LensApiMock: LensApi = {
disabledActionIds: new BehaviorSubject<string[] | undefined>(undefined),
setDisabledActionIds: jest.fn(),
rendered$: new BehaviorSubject<boolean>(false),
searchSessionId$: new BehaviorSubject<string | undefined>(undefined),
};

const LensSerializedStateMock: LensSerializedState = createEmptyLensState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function LensRenderer({
filters,
timeRange,
disabledActions,
searchSessionId,
hidePanelTitles,
...props
}: LensRendererProps) {
Expand All @@ -72,6 +73,7 @@ export function LensRenderer({
}, []);
const disabledActionIds$ = useObservableVariable(disabledActions);
const viewMode$ = useObservableVariable(viewMode);
const searchSessionId$ = useObservableVariable(searchSessionId);
const hidePanelTitles$ = useObservableVariable(hidePanelTitles);

// Lens API will be set once, but when set trigger a reflow to adopt the latest attributes
Expand Down Expand Up @@ -136,6 +138,7 @@ export function LensRenderer({
...props,
// forward the unified search context
...searchApi,
searchSessionId$,
disabledActionIds: disabledActionIds$,
setDisabledActionIds: (ids: string[] | undefined) => disabledActionIds$.next(ids),
viewMode: viewMode$,
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/lens/public/react_embeddable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import type { AllowedPartitionOverrides } from '@kbn/expression-partition-vis-pl
import type { AllowedXYOverrides } from '@kbn/expression-xy-plugin/common';
import type { Action } from '@kbn/ui-actions-plugin/public';
import { PresentationContainer } from '@kbn/presentation-containers';
import { PublishesSearchSession } from '@kbn/presentation-publishing/interfaces/fetch/publishes_search_session';
import type { LegacyMetricState } from '../../common';
import type { LensDocument } from '../persistence';
import type { LensInspector } from '../lens_inspector_service';
Expand Down Expand Up @@ -364,6 +365,8 @@ export type LensApi = Simplify<
PublishesBlockingError &
// This is used by dashboard/container to show filters/queries on the panel
PublishesUnifiedSearch &
// Forward the search session id
PublishesSearchSession &
// Let the container know the loading state
PublishesDataLoading &
// Let the container know when the rendering has completed rendering
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export function useDatePicker({
(newDateRange: TimeRange) => {
setUrlState({ dateRange: newDateRange });
setParsedDateRange(parseDateRange(newDateRange));
updateSearchSessionId();
},
[setUrlState]
[setUrlState, updateSearchSessionId]
);

const onRefresh = useCallback(
Expand All @@ -62,12 +63,10 @@ export function useDatePicker({
if (autoRefreshEnabled) {
autoRefreshTick$.next(null);
} else {
updateSearchSessionId();
setDateRange(newDateRange);
}

setDateRange(newDateRange);
},
[autoRefreshEnabled, autoRefreshTick$, setDateRange, updateSearchSessionId]
[autoRefreshEnabled, autoRefreshTick$, setDateRange]
);

const setAutoRefresh = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export const useUnifiedSearch = () => {

const {
data: {
query: { filterManager: filterManagerService, queryString: queryStringService },
query: {
filterManager: filterManagerService,
queryString: queryStringService,
timefilter: timeFilterService,
},
},
telemetry,
} = services;
Expand All @@ -68,29 +72,33 @@ export const useUnifiedSearch = () => {
const onFiltersChange = useCallback(
(filters: Filter[]) => {
setSearch({ type: 'SET_FILTERS', filters });
updateSearchSessionId();
},
[setSearch]
[setSearch, updateSearchSessionId]
);

const onPanelFiltersChange = useCallback(
(panelFilters: Filter[]) => {
setSearch({ type: 'SET_PANEL_FILTERS', panelFilters });
updateSearchSessionId();
},
[setSearch]
[setSearch, updateSearchSessionId]
);

const onLimitChange = useCallback(
(limit: number) => {
setSearch({ type: 'SET_LIMIT', limit });
updateSearchSessionId();
},
[setSearch]
[setSearch, updateSearchSessionId]
);

const onDateRangeChange = useCallback(
(dateRange: StringDateRange) => {
setSearch({ type: 'SET_DATE_RANGE', dateRange });
updateSearchSessionId();
},
[setSearch]
[setSearch, updateSearchSessionId]
);

const onQueryChange = useCallback(
Expand All @@ -99,19 +107,19 @@ export const useUnifiedSearch = () => {
setError(null);
validateQuery(query);
setSearch({ type: 'SET_QUERY', query });
updateSearchSessionId();
} catch (err) {
setError(err);
}
},
[validateQuery, setSearch]
[validateQuery, setSearch, updateSearchSessionId]
);

const onSubmit = useCallback(
({ dateRange }: { dateRange: TimeRange }) => {
onDateRangeChange(dateRange);
updateSearchSessionId();
},
[onDateRangeChange, updateSearchSessionId]
[onDateRangeChange]
);

const getDateRangeAsTimestamp = useCallback(() => {
Expand Down Expand Up @@ -168,6 +176,16 @@ export const useUnifiedSearch = () => {
.subscribe()
);

subscription.add(
timeFilterService.timefilter
.getTimeUpdate$()
.pipe(
map(() => timeFilterService.timefilter.getTime()),
tap((dateRange) => onDateRangeChange(dateRange))
)
.subscribe()
);

subscription.add(
queryStringService
.getUpdates$()
Expand All @@ -181,7 +199,14 @@ export const useUnifiedSearch = () => {
return () => {
subscription.unsubscribe();
};
}, [filterManagerService, queryStringService, onQueryChange, onFiltersChange]);
}, [
filterManagerService,
queryStringService,
onQueryChange,
onFiltersChange,
timeFilterService.timefilter,
onDateRangeChange,
]);

// Track telemetry event on query/filter/date changes
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,21 @@ export function RuleConditionChart({
const errorDiv = document.querySelector('.lnsEmbeddedError');
if (errorDiv) {
const paragraphElements = errorDiv.querySelectorAll('p');
if (!paragraphElements || paragraphElements.length < 2) return;
if (!paragraphElements) return;
paragraphElements[0].innerText = i18n.translate(
'xpack.observability.ruleCondition.chart.error_equation.title',
{
defaultMessage: 'An error occurred while rendering the chart',
}
);
paragraphElements[1].innerText = i18n.translate(
'xpack.observability.ruleCondition.chart.error_equation.description',
{
defaultMessage: 'Check the rule equation.',
}
);
if (paragraphElements.length > 1) {
paragraphElements[1].innerText = i18n.translate(
'xpack.observability.ruleCondition.chart.error_equation.description',
{
defaultMessage: 'Check the rule equation.',
}
);
}
}
});
}, [chartLoading, attributes]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./pages/alerts/rule_stats'));
loadTestFile(require.resolve('./pages/alerts/state_synchronization'));
loadTestFile(require.resolve('./pages/alerts/table_storage'));
loadTestFile(require.resolve('./pages/alerts/custom_threshold_preview_chart'));
loadTestFile(require.resolve('./pages/alerts/custom_threshold'));
loadTestFile(require.resolve('./pages/cases/case_details'));
loadTestFile(require.resolve('./pages/overview/alert_table'));
Expand Down
Loading

0 comments on commit 8eb5049

Please sign in to comment.