Skip to content
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

[Discover] Decouple data$ updates to prevent rows clearing on hook re-render #8806

Merged
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions changelogs/fragments/8806.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fix:
- Decouple data$ updates to prevent rows clearing on hook re-render ([#8806](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8806))
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { act, renderHook } from '@testing-library/react-hooks';
import { createMemoryHistory } from 'history';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { Subject } from 'rxjs';
import { createDataExplorerServicesMock } from '../../../../../data_explorer/public/utils/mocks';
import { DiscoverViewServices } from '../../../build_services';
import { discoverPluginMock } from '../../../mocks';
import { ResultStatus, useSearch } from './use_search';

jest.mock('./use_index_pattern', () => ({
useIndexPattern: jest.fn(),
}));

const mockSavedSearch = {
id: 'test-saved-search',
title: 'Test Saved Search',
searchSource: {
setField: jest.fn(),
getField: jest.fn(),
fetch: jest.fn(),
getSearchRequestBody: jest.fn().mockResolvedValue({}),
getOwnField: jest.fn(),
getDataFrame: jest.fn(() => ({ name: 'test-pattern' })),
},
getFullPath: jest.fn(),
getOpenSearchType: jest.fn(),
};

const createMockServices = (): DiscoverViewServices => {
const dataExplorerServicesMock = createDataExplorerServicesMock();
const discoverServicesMock = discoverPluginMock.createDiscoverServicesMock();
const services: DiscoverViewServices = {
...dataExplorerServicesMock,
...discoverServicesMock,
};

(services.data.query.timefilter.timefilter.getRefreshInterval as jest.Mock).mockReturnValue({
pause: false,
value: 10,
});
services.getSavedSearchById = jest.fn().mockResolvedValue(mockSavedSearch);
return services;
};

const history = createMemoryHistory();
const mockStore = {
getState: () => ({
discover: {
savedSearch: 'test-saved-search',
sort: [],
interval: 'auto',
savedQuery: undefined,
},
}),
subscribe: jest.fn(),
dispatch: jest.fn(),
};
const wrapper: React.FC = ({ children }) => {
return (
<Provider store={mockStore}>
<Router history={history}>{children}</Router>
</Provider>
);
};

describe('useSearch', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should initialize with loading state when search on page load is enabled', async () => {
const services = createMockServices();
(services.uiSettings.get as jest.Mock).mockReturnValueOnce(true);

const { result } = renderHook(() => useSearch(services), { wrapper });

expect(result.current.data$.getValue()).toEqual(
expect.objectContaining({ status: ResultStatus.LOADING })
);
});

it('should initialize with uninitialized state when search on page load is disabled', async () => {
const services = createMockServices();
(services.uiSettings.get as jest.Mock).mockReturnValueOnce(false);
(services.data.query.timefilter.timefilter.getRefreshInterval as jest.Mock).mockReturnValue({
pause: true,
value: 10,
});

const { result } = renderHook(() => useSearch(services), { wrapper });
expect(result.current.data$.getValue()).toEqual(
expect.objectContaining({ status: ResultStatus.UNINITIALIZED })
);
});

it('should update startTime when hook rerenders', async () => {
const services = createMockServices();

const { result, rerender } = renderHook(() => useSearch(services), {
wrapper,
});

const initialStartTime = result.current.data$.getValue().queryStatus?.startTime;
expect(initialStartTime).toBeDefined();

rerender();
const newStartTime = result.current.data$.getValue().queryStatus?.startTime;
expect(newStartTime).toBeDefined();
expect(newStartTime).not.toEqual(initialStartTime);
});

it('should reset data observable when dataset changes', async () => {
const services = createMockServices();
const mockDatasetUpdates$ = new Subject();
services.data.query.queryString.getUpdates$ = jest.fn().mockReturnValue(mockDatasetUpdates$);

const { result, waitForNextUpdate } = renderHook(() => useSearch(services), {
wrapper,
});

act(() => {
result.current.data$.next({ status: ResultStatus.READY });
});

expect(result.current.data$.getValue()).toEqual(
expect.objectContaining({ status: ResultStatus.READY })
);

act(() => {
mockDatasetUpdates$.next({
dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' },
});
mockDatasetUpdates$.next({
dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' },
});
mockDatasetUpdates$.next({
dataset: { id: 'new-dataset-id2', title: 'New Dataset', type: 'INDEX_PATTERN' },
});
});

await act(async () => {
await waitForNextUpdate();
});

expect(result.current.data$.getValue()).toEqual(
expect.objectContaining({ status: ResultStatus.LOADING })
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

import { useCallback, useMemo, useRef, useState } from 'react';
import { BehaviorSubject, Subject, merge } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { debounceTime, filter, pairwise } from 'rxjs/operators';
import { i18n } from '@osd/i18n';
import { useEffect } from 'react';
import { cloneDeep } from 'lodash';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { RequestAdapter } from '../../../../../inspector/public';
import { DiscoverViewServices } from '../../../build_services';
import { search } from '../../../../../data/public';
Expand Down Expand Up @@ -143,8 +144,45 @@
: ResultStatus.UNINITIALIZED,
queryStatus: { startTime },
}),
[shouldSearchOnPageLoad, startTime, skipInitialFetch]
// we only want data$ observable to be created once, updates will be done through useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);

// re-initialize data$ when the selected dataset changes
useEffectOnce(() => {
const subscription = data.query.queryString

Check warning on line 154 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L153-L154

Added lines #L153 - L154 were not covered by tests
.getUpdates$()
.pipe(
pairwise(),
filter(([prev, curr]) => prev.dataset?.id === curr.dataset?.id)

Check warning on line 158 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L158

Added line #L158 was not covered by tests
)
.subscribe(() => {
data$.next({

Check warning on line 161 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L161

Added line #L161 was not covered by tests
status:
shouldSearchOnPageLoad() && !skipInitialFetch.current
? ResultStatus.LOADING
: ResultStatus.UNINITIALIZED,
queryStatus: { startTime },
});
});
return () => subscription.unsubscribe();

Check warning on line 169 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L169

Added line #L169 was not covered by tests
});

useEffect(() => {
data$.next({ ...data$.value, queryStatus: { startTime } });

Check warning on line 173 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L172-L173

Added lines #L172 - L173 were not covered by tests
}, [data$, startTime]);

useEffect(() => {
data$.next({

Check warning on line 177 in src/plugins/discover/public/application/view_components/utils/use_search.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/discover/public/application/view_components/utils/use_search.ts#L176-L177

Added lines #L176 - L177 were not covered by tests
...data$.value,
status:
shouldSearchOnPageLoad() && !skipInitialFetch.current
? ResultStatus.LOADING
: ResultStatus.UNINITIALIZED,
});
}, [data$, shouldSearchOnPageLoad, skipInitialFetch]);
Comment on lines +172 to +184
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this is separated into two useEffect()'s? If mimicking the original useMemo(), could be done in a single update and reduce the number of useEffects().

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startTime updates should only update queryStatus.startTime and not reset status. it's the bug this PR is trying to fix, we should decouple queryStatus and status updates


const refetch$ = useMemo(() => new Subject<SearchRefetch>(), []);

const fetch = useCallback(async () => {
Expand Down
29 changes: 29 additions & 0 deletions src/plugins/discover/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
*/

import { DiscoverSetup, DiscoverStart } from '.';
import { coreMock } from '../../../core/public/mocks';
import { chartPluginMock } from '../../charts/public/mocks';
import { dataPluginMock } from '../../data/public/mocks';
import { embeddablePluginMock } from '../../embeddable/public/mocks';
import { inspectorPluginMock } from '../../inspector/public/mocks';
import { navigationPluginMock } from '../../navigation/public/mocks';
import { opensearchDashboardsLegacyPluginMock } from '../../opensearch_dashboards_legacy/public/mocks';
import { uiActionsPluginMock } from '../../ui_actions/public/mocks';
import { urlForwardingPluginMock } from '../../url_forwarding/public/mocks';
import { visualizationsPluginMock } from '../../visualizations/public/mocks';
import { buildServices, DiscoverServices } from './build_services';

export type Setup = jest.Mocked<DiscoverSetup>;
export type Start = jest.Mocked<DiscoverStart>;
Expand All @@ -55,7 +66,25 @@ const createStartContract = (): Start => {
return startContract;
};

const createDiscoverServicesMock = (): DiscoverServices =>
buildServices(
coreMock.createStart(),
{
data: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
embeddable: embeddablePluginMock.createStartContract(),
inspector: inspectorPluginMock.createStartContract(),
navigation: navigationPluginMock.createStartContract(),
uiActions: uiActionsPluginMock.createStartContract(),
urlForwarding: urlForwardingPluginMock.createStartContract(),
visualizations: visualizationsPluginMock.createStartContract(),
opensearchDashboardsLegacy: opensearchDashboardsLegacyPluginMock.createStartContract(),
},
coreMock.createPluginInitializerContext()
);

export const discoverPluginMock = {
createDiscoverServicesMock,
createSetupContract,
createStartContract,
};
Loading