Extract url utils from app.tsx

This commit is contained in:
Przemek Więch
2026-06-10 19:24:04 +02:00
parent 8f0b800d53
commit fa6c7f9ff1
5 changed files with 559 additions and 149 deletions

View File

@@ -82,7 +82,7 @@ To refactor `app.tsx` safely and maintain continuous code correctness, we will m
#### Phase 1: Pure Component and Utility Extraction #### Phase 1: Pure Component and Utility Extraction
* [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`. * [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`.
* [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts. * [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts.
* [ ] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify. * [x] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify.
* [ ] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties. * [ ] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties.
#### Phase 2: WebMCP Bridge Extraction #### Phase 2: WebMCP Bridge Extraction

View File

@@ -16,6 +16,8 @@ const config: Config.InitialOptions = {
"d3-selection": "<rootDir>/node_modules/d3-selection/dist/d3-selection.js", "d3-selection": "<rootDir>/node_modules/d3-selection/dist/d3-selection.js",
"d3-timer": "<rootDir>/node_modules/d3-timer/dist/d3-timer.js", "d3-timer": "<rootDir>/node_modules/d3-timer/dist/d3-timer.js",
"d3-transition": "<rootDir>/node_modules/d3-transition/dist/d3-transition.js", "d3-transition": "<rootDir>/node_modules/d3-transition/dist/d3-transition.js",
"d3-drag": "<rootDir>/node_modules/d3-drag/dist/d3-drag.js",
"d3-zoom": "<rootDir>/node_modules/d3-zoom/dist/d3-zoom.js",
}, },
}; };

View File

@@ -1,4 +1,3 @@
import * as H from 'history';
import queryString from 'query-string'; import queryString from 'query-string';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
@@ -48,7 +47,6 @@ import {Intro} from './intro';
import {GoogleAuthModal} from './menu/google_auth_modal'; import {GoogleAuthModal} from './menu/google_auth_modal';
import {TopBar} from './menu/top_bar'; import {TopBar} from './menu/top_bar';
import { import {
argsToConfig,
Config, Config,
configToArgs, configToArgs,
DEFALUT_CONFIG, DEFALUT_CONFIG,
@@ -60,29 +58,14 @@ import {analyticsEvent} from './util/analytics';
import {TopolaError} from './util/error'; import {TopolaError} from './util/error';
import {getI18nMessage} from './util/error_i18n'; import {getI18nMessage} from './util/error_i18n';
import {idToIndiMap, TopolaData} from './util/gedcom_util'; import {idToIndiMap, TopolaData} from './util/gedcom_util';
import {
DataSourceSpec,
getArguments,
getStaticUrl,
getUrlForArgs,
} from './util/url_args';
import {WebMcpBridge} from './webmcp'; import {WebMcpBridge} from './webmcp';
/**
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
* injected via a meta tag from Caddy server).
*
* If this static URL is provided, the viewer is switched to
* single-tree mode without the option to load other data.
*/
function getStaticUrl(): string | undefined {
const envUrl = import.meta.env.VITE_STATIC_URL;
if (envUrl) return envUrl;
const metaTag = document.querySelector('meta[name="topola-static-url"]');
const metaUrl = metaTag?.getAttribute('content');
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
return metaUrl;
}
return undefined;
}
const staticUrl = getStaticUrl(); const staticUrl = getStaticUrl();
enum AppState { enum AppState {
@@ -93,124 +76,6 @@ enum AppState {
LOADING_MORE, LOADING_MORE,
} }
type DataSourceSpec =
| UrlSourceSpec
| UploadSourceSpec
| WikiTreeSourceSpec
| EmbeddedSourceSpec
| GoogleDriveSourceSpec;
/**
* Arguments passed to the application, primarily through URL parameters.
* Non-optional arguments get populated with default values.
*/
interface Arguments {
sourceSpec?: DataSourceSpec;
selection?: IndiInfo;
chartType: ChartType;
standalone: boolean;
showWikiTreeMenus: boolean;
freezeAnimation: boolean;
showSidePanel: boolean;
config: Config;
}
function getParamFromSearch(name: string, search: queryString.ParsedQuery) {
const value = search[name];
return typeof value === 'string' ? value : undefined;
}
/**
* Retrieve arguments passed into the application through the URL and uploaded
* data.
*/
function getArguments(location: H.Location): Arguments {
const search = queryString.parse(location.search);
const getParam = (name: string) => getParamFromSearch(name, search);
const view = getParam('view');
const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy],
['donatso', ChartType.Donatso],
]);
const hash = getParam('file');
const url = getParam('url');
const embedded = getParam('embedded') === 'true'; // False by default.
let sourceSpec: DataSourceSpec | undefined = undefined;
if (staticUrl) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url: staticUrl,
handleCors: false,
};
} else if (getParam('source') === 'wikitree') {
const windowSearch = queryString.parse(window.location.search);
sourceSpec = {
source: DataSourceEnum.WIKITREE,
authcode:
getParam('authcode') || getParamFromSearch('authcode', windowSearch),
};
} else if (getParam('source') === 'google-drive') {
const fileId = getParam('fileId');
if (fileId) {
sourceSpec = {
source: DataSourceEnum.GOOGLE_DRIVE,
fileId,
};
}
} else if (hash) {
sourceSpec = {
source: DataSourceEnum.UPLOADED,
hash,
};
} else if (url) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url,
handleCors: getParam('handleCors') !== 'false', // True by default.
};
} else if (embedded) {
sourceSpec = {source: DataSourceEnum.EMBEDDED};
}
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const selection = indi
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
: undefined;
/**
* Determines whether the side panel should be shown taking into account the
* URL parameter and the viewport size.
*
* On mobile devices (max-width: 767px), the side panel is hidden by default.
* On tablet and desktop, the side panel is shown by default.
*/
function getShowSidePanel() {
if (window.matchMedia('(max-width: 767px)').matches) {
// On mobile, hide the side panel by default.
return getParam('sidePanel') === 'true';
}
// On tablet and desktop, show the side panel by default.
return getParam('sidePanel') !== 'false';
}
return {
sourceSpec,
selection,
// Hourglass is the default view.
chartType: chartTypes.get(view) || ChartType.Hourglass,
showSidePanel: getShowSidePanel(),
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
freezeAnimation: getParam('freeze') === 'true', // False by default
config: argsToConfig(search),
};
}
export function App() { export function App() {
/** State of the application. */ /** State of the application. */
const [state, setState] = useState<AppState>(AppState.INITIAL); const [state, setState] = useState<AppState>(AppState.INITIAL);
@@ -620,13 +485,10 @@ export function App() {
}); });
}, [mcpBridge, location]); }, [mcpBridge, location]);
function updateUrl(args: queryString.ParsedQuery<string>) { function updateUrl(
const search = queryString.parse(location.search); args: Record<string, string | (string | null)[] | null | undefined>,
for (const key in args) { ) {
search[key] = args[key]; navigate(getUrlForArgs(location, args));
}
location.search = queryString.stringify(search);
navigate(location);
} }
/** /**

315
src/util/url_args.spec.ts Normal file
View File

@@ -0,0 +1,315 @@
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals';
import * as H from 'history';
import {ChartType} from '../chart';
import {DataSourceEnum} from '../datasource/data_source';
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
import {
getArguments,
getParamFromSearch,
getStaticUrl,
getUrlForArgs,
} from './url_args';
describe('url_args', () => {
const originalEnv = process.env;
let documentMock: {
querySelector: jest.Mock;
};
beforeEach(() => {
process.env = {...originalEnv};
// Mock document globally
documentMock = {
querySelector: jest.fn().mockReturnValue(null),
};
Object.defineProperty(global, 'document', {
value: documentMock,
writable: true,
configurable: true,
});
});
afterEach(() => {
process.env = originalEnv;
// Clean up document mock
Object.defineProperty(global, 'document', {
value: undefined,
writable: true,
configurable: true,
});
});
describe('getStaticUrl', () => {
it('returns VITE_STATIC_URL if set', () => {
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
expect(getStaticUrl()).toBe('http://example.com/static.ged');
});
it('returns meta tag url if meta tag is present and valid', () => {
const mockMeta = {
getAttribute: jest.fn().mockReturnValue('http://example.com/meta.ged'),
};
documentMock.querySelector.mockReturnValue(mockMeta);
expect(getStaticUrl()).toBe('http://example.com/meta.ged');
expect(documentMock.querySelector).toHaveBeenCalledWith(
'meta[name="topola-static-url"]',
);
expect(mockMeta.getAttribute).toHaveBeenCalledWith('content');
});
it('ignores template placeholder in meta tag', () => {
const mockMeta = {
getAttribute: jest.fn().mockReturnValue('{{ env "STATIC_URL" }}'),
};
documentMock.querySelector.mockReturnValue(mockMeta);
expect(getStaticUrl()).toBeUndefined();
});
it('ignores __ placeholder in meta tag', () => {
const mockMeta = {
getAttribute: jest.fn().mockReturnValue('__STATIC_URL_PLACEHOLDER__'),
};
documentMock.querySelector.mockReturnValue(mockMeta);
expect(getStaticUrl()).toBeUndefined();
});
it('returns undefined if neither VITE_STATIC_URL nor meta tag is present', () => {
expect(getStaticUrl()).toBeUndefined();
});
});
describe('getParamFromSearch', () => {
it('extracts query param value', () => {
expect(getParamFromSearch('test', {test: 'val'})).toBe('val');
});
it('returns undefined if param is array or missing', () => {
expect(
getParamFromSearch('test', {test: ['val1', 'val2']}),
).toBeUndefined();
expect(getParamFromSearch('missing', {test: 'val'})).toBeUndefined();
});
});
describe('getArguments', () => {
const createLocation = (search: string): H.Location => ({
pathname: '/view',
search,
hash: '',
state: null,
key: '',
});
it('returns defaults for empty search', () => {
const args = getArguments(createLocation(''));
expect(args.sourceSpec).toBeUndefined();
expect(args.selection).toBeUndefined();
expect(args.detail).toBeUndefined();
expect(args.chartType).toBe(ChartType.Hourglass);
expect(args.standalone).toBe(true);
expect(args.showWikiTreeMenus).toBe(true);
expect(args.freezeAnimation).toBe(false);
expect(args.showSidePanel).toBe(true); // default on desktop
expect(args.config).toEqual({
color: ChartColors.COLOR_BY_GENERATION,
id: Ids.SHOW,
sex: Sex.SHOW,
place: PlaceDisplay.FULL,
placeCount: 2,
});
});
it('parses chart view types correctly', () => {
expect(getArguments(createLocation('?view=relatives')).chartType).toBe(
ChartType.Relatives,
);
expect(getArguments(createLocation('?view=fancy')).chartType).toBe(
ChartType.Fancy,
);
expect(getArguments(createLocation('?view=donatso')).chartType).toBe(
ChartType.Donatso,
);
expect(getArguments(createLocation('?view=unknown')).chartType).toBe(
ChartType.Hourglass,
);
});
it('parses WikiTree source spec', () => {
const args = getArguments(
createLocation('?source=wikitree&authcode=123'),
);
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.WIKITREE,
authcode: '123',
});
});
it('parses Google Drive source spec', () => {
const args = getArguments(
createLocation('?source=google-drive&fileId=abc'),
);
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.GOOGLE_DRIVE,
fileId: 'abc',
});
});
it('parses Uploaded source spec', () => {
const args = getArguments(createLocation('?file=hash123'));
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.UPLOADED,
hash: 'hash123',
});
});
it('parses GEDCOM URL source spec', () => {
const args = getArguments(
createLocation('?url=http://example.com/tree.ged'),
);
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.GEDCOM_URL,
url: 'http://example.com/tree.ged',
handleCors: true,
});
});
it('parses GEDCOM URL source spec with handleCors false', () => {
const args = getArguments(
createLocation('?url=http://example.com/tree.ged&handleCors=false'),
);
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.GEDCOM_URL,
url: 'http://example.com/tree.ged',
handleCors: false,
});
});
it('parses Embedded source spec', () => {
const args = getArguments(createLocation('?embedded=true'));
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.EMBEDDED,
});
});
it('prefers staticUrl over other source specs', () => {
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
const args = getArguments(
createLocation('?embedded=true&url=http://other.com'),
);
expect(args.sourceSpec).toEqual({
source: DataSourceEnum.GEDCOM_URL,
url: 'http://example.com/static.ged',
handleCors: false,
});
expect(args.standalone).toBe(false);
});
it('parses selection correctly', () => {
const args = getArguments(createLocation('?indi=I123&gen=4'));
expect(args.selection).toEqual({
id: 'I123',
generation: 4,
});
});
it('defaults selection generation to 0 if missing or invalid', () => {
const args1 = getArguments(createLocation('?indi=I123'));
expect(args1.selection).toEqual({
id: 'I123',
generation: 0,
});
const args2 = getArguments(createLocation('?indi=I123&gen=abc'));
expect(args2.selection).toEqual({
id: 'I123',
generation: 0,
});
});
it('parses detail parameter', () => {
const args = getArguments(createLocation('?detail=I456'));
expect(args.detail).toBe('I456');
});
it('parses showSidePanel setting', () => {
// Mock window.matchMedia for desktop
Object.defineProperty(global, 'window', {
value: {
matchMedia: jest.fn().mockReturnValue({matches: false}),
},
writable: true,
configurable: true,
});
expect(getArguments(createLocation('')).showSidePanel).toBe(true);
expect(
getArguments(createLocation('?sidePanel=false')).showSidePanel,
).toBe(false);
// Mock window.matchMedia for mobile
Object.defineProperty(global, 'window', {
value: {
matchMedia: jest.fn().mockReturnValue({matches: true}),
},
writable: true,
configurable: true,
});
expect(getArguments(createLocation('')).showSidePanel).toBe(false);
expect(
getArguments(createLocation('?sidePanel=true')).showSidePanel,
).toBe(true);
});
it('parses boolean settings (standalone, showWikiTreeMenus, freeze)', () => {
const args = getArguments(
createLocation('?standalone=false&showWikiTreeMenus=false&freeze=true'),
);
expect(args.standalone).toBe(false);
expect(args.showWikiTreeMenus).toBe(false);
expect(args.freezeAnimation).toBe(true);
});
it('parses config object from query parameters', () => {
const args = getArguments(createLocation('?c=s&i=h&s=h&p=s&pn=5'));
expect(args.config).toEqual({
color: ChartColors.COLOR_BY_SEX,
id: Ids.HIDE,
sex: Sex.HIDE,
place: PlaceDisplay.SHORT,
placeCount: 5,
});
});
});
describe('getUrlForArgs', () => {
const createLocation = (search: string): H.Location => ({
pathname: '/view',
search,
hash: '#hash-val',
state: null,
key: '',
});
it('updates query parameter value and preserves pathname/hash', () => {
const loc = createLocation('?param1=old&param2=keep');
const updated = getUrlForArgs(loc, {param1: 'new', param3: 'added'});
expect(updated).toEqual({
pathname: '/view',
search: '?param1=new&param2=keep&param3=added',
hash: '#hash-val',
});
});
it('deletes query parameters set to null or undefined', () => {
const loc = createLocation('?param1=val1&param2=val2');
const updated = getUrlForArgs(loc, {param1: null, param2: undefined});
expect(updated).toEqual({
pathname: '/view',
search: '',
hash: '#hash-val',
});
});
});
});

231
src/util/url_args.ts Normal file
View File

@@ -0,0 +1,231 @@
import * as H from 'history';
import type {ParsedQuery} from 'query-string';
import {IndiInfo} from 'topola';
import {ChartType} from '../chart';
import {DataSourceEnum} from '../datasource/data_source';
import {EmbeddedSourceSpec} from '../datasource/embedded';
import {GoogleDriveSourceSpec} from '../datasource/google_drive';
import {UploadSourceSpec, UrlSourceSpec} from '../datasource/load_data';
import {WikiTreeSourceSpec} from '../datasource/wikitree';
import {argsToConfig, Config} from '../sidepanel/config/config';
export type DataSourceSpec =
| UrlSourceSpec
| UploadSourceSpec
| WikiTreeSourceSpec
| EmbeddedSourceSpec
| GoogleDriveSourceSpec;
/**
* Arguments passed to the application, primarily through URL parameters.
* Non-optional arguments get populated with default values.
*/
export interface Arguments {
sourceSpec?: DataSourceSpec;
selection?: IndiInfo;
detail?: string;
chartType: ChartType;
standalone: boolean;
showWikiTreeMenus: boolean;
freezeAnimation: boolean;
showSidePanel: boolean;
config: Config;
}
/**
* Parses a query string using URLSearchParams.
*/
function parseQuery(search: string): ParsedQuery {
const params = new URLSearchParams(search);
const result: ParsedQuery = {};
params.forEach((value, key) => {
const existing = result[key];
if (existing !== undefined) {
if (Array.isArray(existing)) {
existing.push(value);
} else {
result[key] = [existing as string, value];
}
} else {
result[key] = value;
}
});
return result;
}
/**
* Stringifies a query object using URLSearchParams.
*/
function stringifyQuery(query: ParsedQuery): string {
const params = new URLSearchParams();
for (const key in query) {
const val = query[key];
if (val !== undefined && val !== null) {
if (Array.isArray(val)) {
val.forEach((v) => {
if (v !== null && v !== undefined) {
params.append(key, v);
}
});
} else {
params.set(key, val);
}
}
}
const str = params.toString();
return str ? `?${str}` : '';
}
/**
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
* injected via a meta tag from Caddy server).
*
* If this static URL is provided, the viewer is switched to
* single-tree mode without the option to load other data.
*/
export function getStaticUrl(): string | undefined {
const envUrl = import.meta.env.VITE_STATIC_URL;
if (envUrl) return envUrl;
const metaTag = document.querySelector('meta[name="topola-static-url"]');
const metaUrl = metaTag?.getAttribute('content');
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
return metaUrl;
}
return undefined;
}
export function getParamFromSearch(name: string, search: ParsedQuery) {
const value = search[name];
return typeof value === 'string' ? value : undefined;
}
/**
* Retrieve arguments passed into the application through the URL and uploaded
* data.
*/
export function getArguments(location: H.Location): Arguments {
const search = parseQuery(location.search);
const getParam = (name: string) => getParamFromSearch(name, search);
const view = getParam('view');
const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy],
['donatso', ChartType.Donatso],
]);
const hash = getParam('file');
const url = getParam('url');
const embedded = getParam('embedded') === 'true'; // False by default.
const staticUrl = getStaticUrl();
let sourceSpec: DataSourceSpec | undefined = undefined;
if (staticUrl) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url: staticUrl,
handleCors: false,
};
} else if (getParam('source') === 'wikitree') {
const windowSearch =
typeof window !== 'undefined' ? parseQuery(window.location.search) : {};
sourceSpec = {
source: DataSourceEnum.WIKITREE,
authcode:
getParam('authcode') || getParamFromSearch('authcode', windowSearch),
};
} else if (getParam('source') === 'google-drive') {
const fileId = getParam('fileId');
if (fileId) {
sourceSpec = {
source: DataSourceEnum.GOOGLE_DRIVE,
fileId,
};
}
} else if (hash) {
sourceSpec = {
source: DataSourceEnum.UPLOADED,
hash,
};
} else if (url) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url,
handleCors: getParam('handleCors') !== 'false', // True by default.
};
} else if (embedded) {
sourceSpec = {source: DataSourceEnum.EMBEDDED};
}
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const selection = indi
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
: undefined;
const detail = getParam('detail');
/**
* Determines whether the side panel should be shown taking into account the
* URL parameter and the viewport size.
*
* On mobile devices (max-width: 767px), the side panel is hidden by default.
* On tablet and desktop, the side panel is shown by default.
*/
function getShowSidePanel() {
if (
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(max-width: 767px)').matches
) {
// On mobile, hide the side panel by default.
return getParam('sidePanel') === 'true';
}
// On tablet and desktop, show the side panel by default.
return getParam('sidePanel') !== 'false';
}
return {
sourceSpec,
selection,
detail,
// Hourglass is the default view.
chartType: chartTypes.get(view) || ChartType.Hourglass,
showSidePanel: getShowSidePanel(),
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
freezeAnimation: getParam('freeze') === 'true', // False by default
config: argsToConfig(search),
};
}
/**
* Returns a path/query object suitable for passing to React Router's `navigate` function
* with new values added or removed from the current URL.
*/
export function getUrlForArgs(
location: H.Location,
newArgs: Record<string, string | (string | null)[] | null | undefined>,
): {pathname: string; search: string; hash: string} {
const search = parseQuery(location.search);
for (const key in newArgs) {
const val = newArgs[key];
if (val === undefined || val === null) {
delete search[key];
} else if (Array.isArray(val)) {
search[key] = val.filter(
(v): v is string => v !== null && v !== undefined,
);
} else {
search[key] = val;
}
}
return {
pathname: location.pathname,
search: stringifyQuery(search),
hash: location.hash,
};
}