You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2026/03/27 15:50:03 UTC
(superset) branch master updated: fix(explore): migrate from window.history to React Router history API (#38887)
This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new fc705d94e3f fix(explore): migrate from window.history to React Router history API (#38887)
fc705d94e3f is described below
commit fc705d94e3f593418b6cecda4207dd1ba159f7fc
Author: Kamil Gabryjelski <ka...@gmail.com>
AuthorDate: Fri Mar 27 16:49:54 2026 +0100
fix(explore): migrate from window.history to React Router history API (#38887)
---
.../src/explore/actions/hydrateExplore.ts | 2 +-
.../ExploreViewContainer.test.tsx | 98 ++++++++++++----------
.../components/ExploreViewContainer/index.tsx | 40 ++-------
.../src/explore/components/SaveModal.test.tsx | 2 +-
.../src/explore/components/SaveModal.tsx | 7 +-
.../src/hooks/useUnsavedChangesPrompt/index.ts | 32 +++++--
.../useUnsavedChangesPrompt.test.tsx | 9 +-
superset-frontend/src/pages/Chart/Chart.test.tsx | 61 +++++++++++++-
superset-frontend/src/pages/Chart/index.tsx | 76 +++++++++++++----
9 files changed, 216 insertions(+), 111 deletions(-)
diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts
index 0639ff9f949..cb51686b955 100644
--- a/superset-frontend/src/explore/actions/hydrateExplore.ts
+++ b/superset-frontend/src/explore/actions/hydrateExplore.ts
@@ -211,7 +211,7 @@ export const hydrateExplore =
sliceFormData,
queryController: null,
queriesResponse: null,
- triggerQuery: false,
+ triggerQuery: !!saveAction,
lastRendered: 0,
};
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index 848e20f2f18..85d875aefb9 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -26,7 +26,8 @@ import {
VizType,
} from '@superset-ui/core';
import { QUERY_MODE_REQUISITES } from 'src/explore/constants';
-import { MemoryRouter, Route } from 'react-router-dom';
+import { Router, Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
import {
render,
screen,
@@ -124,11 +125,13 @@ const renderWithRouter = ({
overridePathname,
initialState = reduxState,
store,
+ history: existingHistory,
}: {
search?: string;
overridePathname?: string;
initialState?: object;
store?: Store;
+ history?: ReturnType<typeof createMemoryHistory>;
} = {}) => {
const path = overridePathname ?? defaultPath;
Object.defineProperty(window, 'location', {
@@ -136,14 +139,18 @@ const renderWithRouter = ({
return { pathname: path, search };
},
});
- return render(
- <MemoryRouter initialEntries={[`${path}${search}`]}>
+ const history =
+ existingHistory ??
+ createMemoryHistory({ initialEntries: [`${path}${search}`] });
+ const result = render(
+ <Router history={history}>
<Route path={path}>
<ExploreViewContainer />
</Route>
- </MemoryRouter>,
+ </Router>,
{ useRedux: true, useDnd: true, initialState, store },
);
+ return { ...result, history };
};
test('generates a new form_data param when none is available', async () => {
@@ -155,19 +162,18 @@ test('generates a new form_data param when none is available', async () => {
useLegacyApi: false,
}),
);
- const replaceState = jest.spyOn(window.history, 'replaceState');
- await waitFor(() => renderWithRouter());
- expect(replaceState).toHaveBeenCalledWith(
- expect.anything(),
- undefined,
+ const history = createMemoryHistory({ initialEntries: [defaultPath] });
+ const replaceSpy = jest.spyOn(history, 'replace');
+ await waitFor(() => renderWithRouter({ history }));
+ expect(replaceSpy).toHaveBeenCalledWith(
expect.stringMatching('form_data_key'),
- );
- expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
- undefined,
+ );
+ expect(replaceSpy).toHaveBeenCalledWith(
expect.stringMatching('datasource_id'),
+ expect.anything(),
);
- replaceState.mockRestore();
+ replaceSpy.mockRestore();
});
test('renders chart in standalone mode', () => {
@@ -180,40 +186,37 @@ test('renders chart in standalone mode', () => {
expect(queryByTestId('standalone-app')).toBeInTheDocument();
});
-test('generates a different form_data param when one is provided and is mounting', async () => {
- const replaceState = jest.spyOn(window.history, 'replaceState');
- await waitFor(() => renderWithRouter({ search: SEARCH }));
- expect(replaceState).not.toHaveBeenLastCalledWith(
- 0,
- expect.anything(),
- undefined,
- expect.stringMatching(KEY),
- );
- expect(replaceState).toHaveBeenCalledWith(
- expect.anything(),
- undefined,
+test('generates a form_data param with datasource_id when mounting with existing key', async () => {
+ const history = createMemoryHistory({
+ initialEntries: [`${defaultPath}${SEARCH}`],
+ });
+ const replaceSpy = jest.spyOn(history, 'replace');
+ await waitFor(() => renderWithRouter({ search: SEARCH, history }));
+ expect(replaceSpy).toHaveBeenCalledWith(
expect.stringMatching('datasource_id'),
+ expect.anything(),
);
- replaceState.mockRestore();
+ replaceSpy.mockRestore();
});
test('reuses the same form_data param when updating', async () => {
getChartControlPanelRegistry().registerValue('table', {
controlPanelSections: [],
});
- const replaceState = jest.spyOn(window.history, 'replaceState');
- const pushState = jest.spyOn(window.history, 'pushState');
- await waitFor(() => renderWithRouter({ search: SEARCH }));
- expect(replaceState.mock.calls.length).toBe(1);
+ const history = createMemoryHistory({
+ initialEntries: [`${defaultPath}${SEARCH}`],
+ });
+ const replaceSpy = jest.spyOn(history, 'replace');
+ await waitFor(() => renderWithRouter({ search: SEARCH, history }));
+ expect(replaceSpy.mock.calls.length).toBe(1);
userEvent.click(screen.getByText('Update chart'));
- await waitFor(() => expect(pushState.mock.calls.length).toBe(1));
- expect(replaceState.mock.calls[0]).toEqual(pushState.mock.calls[0]);
- replaceState.mockRestore();
- pushState.mockRestore();
+ await waitFor(() => expect(replaceSpy.mock.calls.length).toBe(2));
+ expect(replaceSpy.mock.calls[0]).toEqual(replaceSpy.mock.calls[1]);
+ replaceSpy.mockRestore();
getChartControlPanelRegistry().remove('table');
});
-test('doesnt call replaceState when pathname is not /explore', async () => {
+test('doesnt call replace when pathname is not /explore', async () => {
getChartMetadataRegistry().registerValue(
'table',
new ChartMetadata({
@@ -222,24 +225,29 @@ test('doesnt call replaceState when pathname is not /explore', async () => {
useLegacyApi: false,
}),
);
- const replaceState = jest.spyOn(window.history, 'replaceState');
- await waitFor(() => renderWithRouter({ overridePathname: '/dashboard' }));
- expect(replaceState).not.toHaveBeenCalled();
- replaceState.mockRestore();
+ const history = createMemoryHistory({ initialEntries: ['/dashboard'] });
+ const replaceSpy = jest.spyOn(history, 'replace');
+ await waitFor(() =>
+ renderWithRouter({ overridePathname: '/dashboard', history }),
+ );
+ expect(replaceSpy).not.toHaveBeenCalled();
+ replaceSpy.mockRestore();
});
test('preserves unknown parameters', async () => {
- const replaceState = jest.spyOn(window.history, 'replaceState');
const unknownParam = 'test=123';
+ const history = createMemoryHistory({
+ initialEntries: [`${defaultPath}${SEARCH}&${unknownParam}`],
+ });
+ const replaceSpy = jest.spyOn(history, 'replace');
await waitFor(() =>
- renderWithRouter({ search: `${SEARCH}&${unknownParam}` }),
+ renderWithRouter({ search: `${SEARCH}&${unknownParam}`, history }),
);
- expect(replaceState).toHaveBeenCalledWith(
- expect.anything(),
- undefined,
+ expect(replaceSpy).toHaveBeenCalledWith(
expect.stringMatching(unknownParam),
+ expect.anything(),
);
- replaceState.mockRestore();
+ replaceSpy.mockRestore();
});
test('retains query mode requirements when query_mode is enabled', async () => {
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index 26110471a7f..73d732d0cce 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -46,6 +46,7 @@ import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
import { Resizable } from 're-resizable';
+import { useHistory } from 'react-router-dom';
import { Tooltip } from '@superset-ui/core/components';
import { usePluginContext } from 'src/components';
import { Global } from '@emotion/react';
@@ -63,7 +64,6 @@ import {
LOG_ACTIONS_MOUNT_EXPLORER,
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils';
-import { ensureAppRoot } from 'src/utils/pathUtils';
import { getUrlParam } from 'src/utils/urlUtils';
import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction';
@@ -170,10 +170,11 @@ const updateHistory = debounce(
force,
title,
tabId,
+ history,
) => {
const payload = { ...formData };
const chartId = formData.slice_id;
- const params = new URLSearchParams(window.location.search);
+ const params = new URLSearchParams(history.location.search);
const additionalParam = Object.fromEntries(params);
if (chartId) {
@@ -192,7 +193,6 @@ const updateHistory = debounce(
try {
let key: string | null | undefined;
- let stateModifier: 'replaceState' | 'pushState';
if (isReplace) {
key = await postFormData(
datasourceId,
@@ -201,7 +201,6 @@ const updateHistory = debounce(
chartId,
tabId,
);
- stateModifier = 'replaceState';
} else {
key = getUrlParam(URL_PARAMS.formDataKey);
if (key) {
@@ -214,10 +213,9 @@ const updateHistory = debounce(
tabId,
);
}
- stateModifier = 'pushState';
}
// avoid race condition in case user changes route before explore updates the url
- if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) {
+ if (history.location.pathname.startsWith('/explore')) {
const url = mountExploreUrl(
standalone ? URL_PARAMS.standalone.name : 'base',
{
@@ -225,8 +223,9 @@ const updateHistory = debounce(
...additionalParam,
},
force,
+ false,
);
- window.history[stateModifier](payload, title, url);
+ history.replace(url, payload);
}
} catch (e) {
logging.warn('Failed at altering browser history', e);
@@ -387,6 +386,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
getSidebarWidths(LocalStorageKeys.DatasourceWidth),
);
const tabId = useTabId();
+ const history = useHistory();
const theme = useTheme();
@@ -431,6 +431,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
props.force,
title,
tabId,
+ history,
);
},
[
@@ -441,22 +442,10 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
props.standalone,
props.force,
tabId,
+ history,
],
);
- const handlePopstate = useCallback(() => {
- const formData = window.history.state;
- if (formData && Object.keys(formData).length) {
- props.actions.setExploreControls(formData);
- props.actions.postChartFormData(
- formData,
- props.force,
- props.timeout,
- props.chart.id,
- );
- }
- }, [props.actions, props.chart.id, props.timeout]);
-
const onQuery = useCallback(() => {
props.actions.setForceQuery(false);
@@ -526,17 +515,6 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
}
});
- const previousHandlePopstate = usePrevious(handlePopstate);
- useEffect(() => {
- if (previousHandlePopstate) {
- window.removeEventListener('popstate', previousHandlePopstate);
- }
- window.addEventListener('popstate', handlePopstate);
- return () => {
- window.removeEventListener('popstate', handlePopstate);
- };
- }, [handlePopstate, previousHandlePopstate]);
-
const previousHandleKeyDown = usePrevious(handleKeydown);
useEffect(() => {
if (previousHandleKeyDown) {
diff --git a/superset-frontend/src/explore/components/SaveModal.test.tsx b/superset-frontend/src/explore/components/SaveModal.test.tsx
index e04994b1a2a..1c79be556f5 100644
--- a/superset-frontend/src/explore/components/SaveModal.test.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.test.tsx
@@ -381,7 +381,7 @@ test('removes form_data_key from URL parameters after save', () => {
// other parameters should remain
expect(result.get('other_param')).toEqual('value');
expect(result.get('slice_id')).toEqual('1');
- expect(result.get('save_action')).toEqual('overwrite');
+ expect(result.has('save_action')).toBe(false);
});
test('dispatches removeChartState when saving and going to dashboard', async () => {
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx
index a78b4ce7dc1..5dff843dbbb 100644
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -196,10 +196,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
handleRedirect = (windowLocationSearch: string, chart: any) => {
const searchParams = new URLSearchParams(windowLocationSearch);
- searchParams.set('save_action', this.state.action);
-
searchParams.delete('form_data_key');
-
searchParams.set('slice_id', chart.id.toString());
return searchParams;
};
@@ -343,7 +340,9 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
return;
}
const searchParams = this.handleRedirect(window.location.search, value);
- this.props.history.replace(`/explore/?${searchParams.toString()}`);
+ this.props.history.replace(`/explore/?${searchParams.toString()}`, {
+ saveAction: this.state.action,
+ });
this.setState({ isLoading: false });
this.onHide();
diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
index 50204693aab..2301b2c515d 100644
--- a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
+++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts
@@ -21,7 +21,7 @@ import { getClientErrorObject } from '@superset-ui/core';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useBeforeUnload } from 'src/hooks/useBeforeUnload';
-import type { Location } from 'history';
+import type { Location, Action } from 'history';
type UseUnsavedChangesPromptProps = {
hasUnsavedChanges: boolean;
@@ -71,13 +71,23 @@ export const useUnsavedChangesPrompt = ({
}, [onSave]);
const blockCallback = useCallback(
- ({
- pathname,
- state,
- }: {
- pathname: Location['pathname'];
- state: Location['state'];
- }) => {
+ (
+ {
+ pathname,
+ search,
+ state,
+ }: {
+ pathname: Location['pathname'];
+ search: Location['search'];
+ state: Location['state'];
+ },
+ action: Action,
+ ) => {
+ // REPLACE actions are URL sync (e.g. updating form_data_key), not navigation
+ if (action === 'REPLACE') {
+ return undefined;
+ }
+
if (manualSaveRef.current) {
manualSaveRef.current = false;
return undefined;
@@ -85,7 +95,11 @@ export const useUnsavedChangesPrompt = ({
confirmNavigationRef.current = () => {
unblockRef.current?.();
- history.push(pathname, state);
+ if (action === 'POP') {
+ history.go(-1);
+ } else {
+ history.push({ pathname, search }, state);
+ }
};
setShowModal(true);
diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
index ccd259f22f5..889ba89a91e 100644
--- a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
+++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx
@@ -125,7 +125,7 @@ test('should close modal when handleConfirmNavigation is called', () => {
expect(result.current.showModal).toBe(false);
});
-test('should preserve pathname and state when confirming navigation', () => {
+test('should preserve pathname, search, and state when confirming navigation', () => {
const onSave = jest.fn();
const history = createMemoryHistory();
const wrapper = ({ children }: any) => (
@@ -134,6 +134,7 @@ test('should preserve pathname and state when confirming navigation', () => {
const locationState = { fromDashboard: true, dashboardId: 123 };
const pathname = '/another-page';
+ const search = '?slice_id=42&foo=bar';
const { result } = renderHook(
() => useUnsavedChangesPrompt({ hasUnsavedChanges: true, onSave }),
@@ -144,7 +145,7 @@ test('should preserve pathname and state when confirming navigation', () => {
// Simulate a blocked navigation (the hook sets up history.block internally)
act(() => {
- history.push(pathname, locationState);
+ history.push({ pathname, search }, locationState);
});
// Modal should now be visible
@@ -158,8 +159,8 @@ test('should preserve pathname and state when confirming navigation', () => {
// Modal should close
expect(result.current.showModal).toBe(false);
- // Verify correct call with pathname and state preserved
- expect(pushSpy).toHaveBeenCalledWith(pathname, locationState);
+ // Verify correct call with pathname, search, and state preserved
+ expect(pushSpy).toHaveBeenCalledWith({ pathname, search }, locationState);
pushSpy.mockRestore();
});
diff --git a/superset-frontend/src/pages/Chart/Chart.test.tsx b/superset-frontend/src/pages/Chart/Chart.test.tsx
index de3787ebde5..58106375f0e 100644
--- a/superset-frontend/src/pages/Chart/Chart.test.tsx
+++ b/superset-frontend/src/pages/Chart/Chart.test.tsx
@@ -260,7 +260,11 @@ describe('ChartPage', () => {
const { getByTestId } = render(
<>
<Link
- to={`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}&${URL_PARAMS.saveAction.name}=overwrite`}
+ to={{
+ pathname: '/',
+ search: `?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
+ state: { saveAction: 'overwrite' },
+ }}
>
Change route
</Link>
@@ -298,5 +302,60 @@ describe('ChartPage', () => {
}).slice(1, -1),
);
});
+
+ test('re-fetches explore data on back-button navigation (POP)', async () => {
+ const exploreApiRoute = 'glob:*/api/v1/explore/*';
+ const initialFormData = getExploreFormData({
+ viz_type: VizType.Table,
+ show_cell_bars: true,
+ });
+ const updatedFormData = getExploreFormData({
+ viz_type: VizType.Table,
+ show_cell_bars: false,
+ });
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: initialFormData },
+ });
+ render(
+ <>
+ <Link to="/?slice_id=99">Navigate away</Link>
+ <ChartPage />
+ </>,
+ {
+ useRouter: true,
+ useRedux: true,
+ useDnd: true,
+ },
+ );
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+ expect(screen.getByTestId('mock-explore-chart-panel')).toHaveTextContent(
+ JSON.stringify({ show_cell_bars: true }).slice(1, -1),
+ );
+
+ // Navigate forward (PUSH) then simulate back-button (POP)
+ fetchMock.clearHistory().removeRoutes();
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: updatedFormData },
+ });
+ fireEvent.click(screen.getByText('Navigate away'));
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+
+ fetchMock.clearHistory().removeRoutes();
+ fetchMock.get(exploreApiRoute, {
+ result: { dataset: { id: 1 }, form_data: initialFormData },
+ });
+ // Simulate back button
+ window.history.back();
+ await waitFor(() =>
+ expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
+ );
+ expect(screen.getByTestId('mock-explore-chart-panel')).toHaveTextContent(
+ JSON.stringify({ show_cell_bars: true }).slice(1, -1),
+ );
+ });
});
});
diff --git a/superset-frontend/src/pages/Chart/index.tsx b/superset-frontend/src/pages/Chart/index.tsx
index 948abdc47f9..3f8d28ce2dc 100644
--- a/superset-frontend/src/pages/Chart/index.tsx
+++ b/superset-frontend/src/pages/Chart/index.tsx
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
-import { useLocation } from 'react-router-dom';
+import { useHistory } from 'react-router-dom';
+import type { Location, Action } from 'history';
import { t } from '@apache-superset/core/translation';
import {
getLabelsColorMap,
@@ -128,20 +129,28 @@ const getDashboardContextFormData = () => {
export default function ExplorePage() {
const [isLoaded, setIsLoaded] = useState(false);
- const isExploreInitialized = useRef(false);
+ const fetchGeneration = useRef(0);
const dispatch = useDispatch();
- const location = useLocation();
+ const history = useHistory();
- useEffect(() => {
- const exploreUrlParams = getParsedExploreURLParams(location);
- const saveAction = getUrlParam(
- URL_PARAMS.saveAction,
- ) as SaveActionType | null;
- const dashboardContextFormData = getDashboardContextFormData();
+ const loadExploreData = useCallback(
+ (
+ loc: { search: string; pathname: string },
+ saveAction?: SaveActionType | null,
+ ) => {
+ fetchGeneration.current += 1;
+ const generation = fetchGeneration.current;
+ const exploreUrlParams = getParsedExploreURLParams(loc);
+ const dashboardContextFormData = getDashboardContextFormData();
+
+ const isStale = () => generation !== fetchGeneration.current;
- if (!isExploreInitialized.current || !!saveAction) {
fetchExploreData(exploreUrlParams)
.then(({ result }) => {
+ if (isStale()) {
+ return;
+ }
+
const formData = dashboardContextFormData
? getFormDataWithDashboardContext(
result.form_data,
@@ -176,6 +185,10 @@ export default function ExplorePage() {
})
.catch(err => Promise.all([getClientErrorObject(err), err]))
.then(resolved => {
+ if (isStale()) {
+ return;
+ }
+
const [clientError, err] = resolved || [];
if (!err) {
return Promise.resolve();
@@ -209,6 +222,9 @@ export default function ExplorePage() {
)
.then(
({ result: { id, url, owners, form_data: _, ...data } }) => {
+ if (isStale()) {
+ return;
+ }
const slice = {
...data,
datasource: err.extra?.datasource_name,
@@ -225,6 +241,9 @@ export default function ExplorePage() {
},
)
.catch(() => {
+ if (isStale()) {
+ return;
+ }
dispatch(hydrateExplore(exploreData));
});
}
@@ -232,12 +251,39 @@ export default function ExplorePage() {
return Promise.resolve();
})
.finally(() => {
- setIsLoaded(true);
- isExploreInitialized.current = true;
+ if (!isStale()) {
+ setIsLoaded(true);
+ }
});
- }
+ },
+ [dispatch],
+ );
+
+ // Initial fetch on mount
+ useEffect(() => {
+ loadExploreData(history.location);
getLabelsColorMap().source = LabelsColorMapSource.Explore;
- }, [dispatch, location]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Re-fetch on navigation or post-save.
+ // PUSH/POP: full reload (unmount + re-fetch).
+ // REPLACE with saveAction state: re-fetch without unmount (keeps chart visible).
+ // Other REPLACE: ignored (URL sync from updateHistory).
+ useEffect(() => {
+ const unlisten = history.listen((loc: Location, action: Action) => {
+ const saveAction = (loc.state as Record<string, unknown>)?.saveAction as
+ | SaveActionType
+ | undefined;
+ if (action === 'PUSH' || action === 'POP') {
+ setIsLoaded(false);
+ loadExploreData(loc, saveAction);
+ } else if (saveAction) {
+ loadExploreData(loc, saveAction);
+ }
+ });
+ return unlisten;
+ }, [history, loadExploreData]);
if (!isLoaded) {
return <Loading />;