From b9e195e3691c7b0505960d6d2b88ee7ac2f83c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Thu, 11 Jun 2026 16:18:32 +0200 Subject: [PATCH] Shift `selection` and `detailIndi` to URL as source of truth --- docs/APP_REFACTORING_DESIGN.md | 2 +- src/app.tsx | 73 +++++++++++++--------------------- 2 files changed, 29 insertions(+), 46 deletions(-) diff --git a/docs/APP_REFACTORING_DESIGN.md b/docs/APP_REFACTORING_DESIGN.md index 8c2be98..4646299 100644 --- a/docs/APP_REFACTORING_DESIGN.md +++ b/docs/APP_REFACTORING_DESIGN.md @@ -95,7 +95,7 @@ We will gradually eliminate React state variables in `src/app.tsx` and derive th * [x] **Step 3.1b:** Shift `standalone` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. * [x] **Step 3.1c:** Shift `showWikiTreeMenus` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. * [x] **Step 3.1d:** Shift `freezeAnimation` to URL as SSOT. Derive it directly from search parameters on render and remove its React state variable. -* [ ] **Step 3.2:** Extract `selection` and `detailIndi` state. Parse them directly from URL params (`indi`, `gen`, and `detail`); update display selectors. Remove React states. Ensure that chart selection changes (`onSelection` callback) explicitly clear or update the `detail` query parameter to match the new selection to avoid getting stuck on the old details viewport. Also update the detail-only selection handler (`onDetailSelection`) to update the `detail` query parameter in the URL. +* [x] **Step 3.2:** Extract `selection` and `detailIndi` state. Parse them directly from URL params (`indi`, `gen`, and `detail`); update display selectors. Remove React states. Ensure that chart selection changes (`onSelection` callback) explicitly clear or update the `detail` query parameter to match the new selection to avoid getting stuck on the old details viewport. Also update the detail-only selection handler (`onDetailSelection`) to update the `detail` query parameter in the URL. * [ ] **Step 3.3:** Extract `showSidePanel` state. Derive state directly from `?sidePanel=` parameter. Remove React state. Enhance URL args helpers to allow generating path/query target objects with replaced values, and ensure layout settings (`sidePanel`) and configuration changes use `replace: true` to prevent polluting the browser history stack. * [ ] **Step 3.4:** Extract `config` state. Parse display settings on render using `argsToConfig` helper. Remove state. In `ViewPage`, run `updateChartWithConfig(config, data)` synchronously during the render pass (e.g. in the `useMemo` that derives query parameters/config) to update the in-memory chart data before it is rendered by the `` child component. Run E2E and visual tests to verify no rendering regressions occurred. diff --git a/src/app.tsx b/src/app.tsx index db6657f..023ed31 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -83,10 +83,7 @@ export function App() { const [loadingStatus, setLoadingStatus] = useState('Loading…'); /** Loaded data. */ const [data, setData] = useState(); - /** Selected individual. */ - const [selection, setSelection] = useState(); - /** Selected individual which should be displayed in the details pane. */ - const [detailIndi, setDetailIndi] = useState(); + /** Error to display. */ const [error, setError] = useState(); /** Whether the side panel is shown. */ @@ -125,6 +122,12 @@ export function App() { const showWikiTreeMenus = args.showWikiTreeMenus; /** Freeze animations after initial chart render. */ const freezeAnimation = args.freezeAnimation; + /** The currently selected individual. Fallback to default individual from loaded data if not specified. */ + const updatedSelection = useMemo(() => { + return data ? getSelection(data.chartData, args.selection) : undefined; + }, [data, args.selection]); + /** The individual displayed in the details pane. */ + const detailIndi = args.detail || updatedSelection?.id; /** Prevents the Google Drive "Open with" state from being processed more than once. */ const stateProcessed = useRef(false); @@ -132,6 +135,8 @@ export function App() { const isMountedRef = useRef(true); /** Incremented with each load request to ensure only the latest asynchronous load result is applied. */ const fetchIdRef = useRef(0); + /** Tracks the currently loaded selection to check if new data needs to be fetched. */ + const loadedSelectionRef = useRef(); /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ useEffect(() => { @@ -141,20 +146,6 @@ export function App() { }; }, []); - const updateDisplay = useCallback( - (newSelection: IndiInfo) => { - if ( - !selection || - selection.id !== newSelection.id || - selection.generation !== newSelection.generation - ) { - setSelection(newSelection); - setDetailIndi(newSelection.id); - } - }, - [selection], - ); - function updateChartWithConfig(config: Config, data: TopolaData | undefined) { if (data === undefined) { return; @@ -196,7 +187,7 @@ export function App() { const newSource = {spec: newSourceSpec, selection: newSelection}; const oldSouce = { spec: sourceSpec, - selection: selection, + selection: loadedSelectionRef.current, }; switch (newSource.spec.source) { case DataSourceEnum.UPLOADED: @@ -231,7 +222,7 @@ export function App() { ); } }, - [sourceSpec, selection, data, wikiTreeDataSource], + [sourceSpec, data, wikiTreeDataSource], ); const loadData = useCallback( @@ -321,8 +312,7 @@ export function App() { await googleDriveService.signOut(); setHasGoogleToken(false); setData(undefined); - setSelection(undefined); - setDetailIndi(undefined); + loadedSelectionRef.current = undefined; // Purge sessionStorage keys starting with "google-drive:" clearGoogleDriveCache(); navigate({pathname: '/'}, {replace: true}); @@ -353,8 +343,6 @@ export function App() { setState(AppState.LOADING); // Set state from URL parameters. setSourceSpec(args.sourceSpec); - setSelection(args.selection); - setDetailIndi(args.selection?.id); setConfig(args.config); const currentFetchId = ++fetchIdRef.current; setLoadingStatus('Loading…'); @@ -375,6 +363,10 @@ export function App() { `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, ); setData(data); + loadedSelectionRef.current = getSelection( + data.chartData, + args.selection, + ); updateChartWithConfig(args.config, data); setShowSidePanel(args.showSidePanel); setState(AppState.SHOWING_CHART); @@ -398,12 +390,11 @@ export function App() { // Update selection if it has changed in the URL. const loadMoreFromWikitree = args.sourceSpec.source === DataSourceEnum.WIKITREE && - (!selection || selection.id !== args.selection?.id); + (!loadedSelectionRef.current || + loadedSelectionRef.current.id !== args.selection?.id); setState( loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - updateDisplay(getSelection(data!.chartData, args.selection)); if (loadMoreFromWikitree) { const currentFetchId = ++fetchIdRef.current; try { @@ -417,8 +408,7 @@ export function App() { } const newSelection = getSelection(data.chartData, args.selection); setData(data); - setSelection(newSelection); - setDetailIndi(newSelection.id); + loadedSelectionRef.current = newSelection; setState(AppState.SHOWING_CHART); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -442,17 +432,7 @@ export function App() { } } })(); - }, [ - location, - state, - selection, - data, - navigate, - intl, - isNewData, - loadData, - updateDisplay, - ]); + }, [location, state, data, navigate, intl, isNewData, loadData]); // Clean up object URLs created for uploaded images/files when the dataset // changes or the app unmounts to prevent memory leaks. @@ -483,6 +463,7 @@ export function App() { updateUrl({ indi: selection.id, gen: String(selection.generation), + detail: null, }); } /** @@ -490,7 +471,9 @@ export function App() { * Shows the individual in the details pane. */ function onDetailSelection(selection: IndiInfo) { - setDetailIndi(selection.id); + updateUrl({ + detail: selection.id, + }); } function onPrint() { @@ -580,10 +563,10 @@ export function App() { switch (state) { case AppState.SHOWING_CHART: case AppState.LOADING_MORE: { - if (!data) { + if (!data || !updatedSelection) { return null; } - const updatedSelection = getSelection(data.chartData, selection); + const selection = updatedSelection; return (
- {renderChart(updatedSelection)} + {renderChart(selection)}
);