Shift selection and detailIndi to URL as source of truth

This commit is contained in:
Przemek Więch
2026-06-11 16:18:32 +02:00
parent 4dd84a7375
commit b9e195e369
2 changed files with 29 additions and 46 deletions

View File

@@ -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.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.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. * [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.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 `<Chart>` child component. Run E2E and visual tests to verify no rendering regressions occurred. * [ ] **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 `<Chart>` child component. Run E2E and visual tests to verify no rendering regressions occurred.

View File

@@ -83,10 +83,7 @@ export function App() {
const [loadingStatus, setLoadingStatus] = useState('Loading…'); const [loadingStatus, setLoadingStatus] = useState('Loading…');
/** Loaded data. */ /** Loaded data. */
const [data, setData] = useState<TopolaData>(); const [data, setData] = useState<TopolaData>();
/** Selected individual. */
const [selection, setSelection] = useState<IndiInfo>();
/** Selected individual which should be displayed in the details pane. */
const [detailIndi, setDetailIndi] = useState<string>();
/** Error to display. */ /** Error to display. */
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
/** Whether the side panel is shown. */ /** Whether the side panel is shown. */
@@ -125,6 +122,12 @@ export function App() {
const showWikiTreeMenus = args.showWikiTreeMenus; const showWikiTreeMenus = args.showWikiTreeMenus;
/** Freeze animations after initial chart render. */ /** Freeze animations after initial chart render. */
const freezeAnimation = args.freezeAnimation; 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. */ /** Prevents the Google Drive "Open with" state from being processed more than once. */
const stateProcessed = useRef(false); const stateProcessed = useRef(false);
@@ -132,6 +135,8 @@ export function App() {
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
/** Incremented with each load request to ensure only the latest asynchronous load result is applied. */ /** Incremented with each load request to ensure only the latest asynchronous load result is applied. */
const fetchIdRef = useRef(0); const fetchIdRef = useRef(0);
/** Tracks the currently loaded selection to check if new data needs to be fetched. */
const loadedSelectionRef = useRef<IndiInfo>();
/** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */
useEffect(() => { 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) { function updateChartWithConfig(config: Config, data: TopolaData | undefined) {
if (data === undefined) { if (data === undefined) {
return; return;
@@ -196,7 +187,7 @@ export function App() {
const newSource = {spec: newSourceSpec, selection: newSelection}; const newSource = {spec: newSourceSpec, selection: newSelection};
const oldSouce = { const oldSouce = {
spec: sourceSpec, spec: sourceSpec,
selection: selection, selection: loadedSelectionRef.current,
}; };
switch (newSource.spec.source) { switch (newSource.spec.source) {
case DataSourceEnum.UPLOADED: case DataSourceEnum.UPLOADED:
@@ -231,7 +222,7 @@ export function App() {
); );
} }
}, },
[sourceSpec, selection, data, wikiTreeDataSource], [sourceSpec, data, wikiTreeDataSource],
); );
const loadData = useCallback( const loadData = useCallback(
@@ -321,8 +312,7 @@ export function App() {
await googleDriveService.signOut(); await googleDriveService.signOut();
setHasGoogleToken(false); setHasGoogleToken(false);
setData(undefined); setData(undefined);
setSelection(undefined); loadedSelectionRef.current = undefined;
setDetailIndi(undefined);
// Purge sessionStorage keys starting with "google-drive:" // Purge sessionStorage keys starting with "google-drive:"
clearGoogleDriveCache(); clearGoogleDriveCache();
navigate({pathname: '/'}, {replace: true}); navigate({pathname: '/'}, {replace: true});
@@ -353,8 +343,6 @@ export function App() {
setState(AppState.LOADING); setState(AppState.LOADING);
// Set state from URL parameters. // Set state from URL parameters.
setSourceSpec(args.sourceSpec); setSourceSpec(args.sourceSpec);
setSelection(args.selection);
setDetailIndi(args.selection?.id);
setConfig(args.config); setConfig(args.config);
const currentFetchId = ++fetchIdRef.current; const currentFetchId = ++fetchIdRef.current;
setLoadingStatus('Loading…'); setLoadingStatus('Loading…');
@@ -375,6 +363,10 @@ export function App() {
`Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`,
); );
setData(data); setData(data);
loadedSelectionRef.current = getSelection(
data.chartData,
args.selection,
);
updateChartWithConfig(args.config, data); updateChartWithConfig(args.config, data);
setShowSidePanel(args.showSidePanel); setShowSidePanel(args.showSidePanel);
setState(AppState.SHOWING_CHART); setState(AppState.SHOWING_CHART);
@@ -398,12 +390,11 @@ export function App() {
// Update selection if it has changed in the URL. // Update selection if it has changed in the URL.
const loadMoreFromWikitree = const loadMoreFromWikitree =
args.sourceSpec.source === DataSourceEnum.WIKITREE && args.sourceSpec.source === DataSourceEnum.WIKITREE &&
(!selection || selection.id !== args.selection?.id); (!loadedSelectionRef.current ||
loadedSelectionRef.current.id !== args.selection?.id);
setState( setState(
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, 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) { if (loadMoreFromWikitree) {
const currentFetchId = ++fetchIdRef.current; const currentFetchId = ++fetchIdRef.current;
try { try {
@@ -417,8 +408,7 @@ export function App() {
} }
const newSelection = getSelection(data.chartData, args.selection); const newSelection = getSelection(data.chartData, args.selection);
setData(data); setData(data);
setSelection(newSelection); loadedSelectionRef.current = newSelection;
setDetailIndi(newSelection.id);
setState(AppState.SHOWING_CHART); setState(AppState.SHOWING_CHART);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
@@ -442,17 +432,7 @@ export function App() {
} }
} }
})(); })();
}, [ }, [location, state, data, navigate, intl, isNewData, loadData]);
location,
state,
selection,
data,
navigate,
intl,
isNewData,
loadData,
updateDisplay,
]);
// Clean up object URLs created for uploaded images/files when the dataset // Clean up object URLs created for uploaded images/files when the dataset
// changes or the app unmounts to prevent memory leaks. // changes or the app unmounts to prevent memory leaks.
@@ -483,6 +463,7 @@ export function App() {
updateUrl({ updateUrl({
indi: selection.id, indi: selection.id,
gen: String(selection.generation), gen: String(selection.generation),
detail: null,
}); });
} }
/** /**
@@ -490,7 +471,9 @@ export function App() {
* Shows the individual in the details pane. * Shows the individual in the details pane.
*/ */
function onDetailSelection(selection: IndiInfo) { function onDetailSelection(selection: IndiInfo) {
setDetailIndi(selection.id); updateUrl({
detail: selection.id,
});
} }
function onPrint() { function onPrint() {
@@ -580,10 +563,10 @@ export function App() {
switch (state) { switch (state) {
case AppState.SHOWING_CHART: case AppState.SHOWING_CHART:
case AppState.LOADING_MORE: { case AppState.LOADING_MORE: {
if (!data) { if (!data || !updatedSelection) {
return null; return null;
} }
const updatedSelection = getSelection(data.chartData, selection); const selection = updatedSelection;
return ( return (
<div id="content"> <div id="content">
<ErrorPopup <ErrorPopup
@@ -597,7 +580,7 @@ export function App() {
<SidebarPushable> <SidebarPushable>
<SidePanel <SidePanel
data={data} data={data}
selectedIndiId={detailIndi || updatedSelection.id} selectedIndiId={detailIndi || selection.id}
config={config} config={config}
expanded={showSidePanel} expanded={showSidePanel}
onToggle={onToggleSidePanel} onToggle={onToggleSidePanel}
@@ -607,7 +590,7 @@ export function App() {
updateUrl(configToArgs(config)); updateUrl(configToArgs(config));
}} }}
/> />
<SidebarPusher>{renderChart(updatedSelection)}</SidebarPusher> <SidebarPusher>{renderChart(selection)}</SidebarPusher>
</SidebarPushable> </SidebarPushable>
</div> </div>
); );