From fa6c7f9ff159bf1b71da70562951b7497302cf92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Wed, 10 Jun 2026 19:24:04 +0200 Subject: [PATCH] Extract url utils from app.tsx --- docs/APP_REFACTORING_DESIGN.md | 2 +- jest.config.ts | 2 + src/app.tsx | 158 ++--------------- src/util/url_args.spec.ts | 315 +++++++++++++++++++++++++++++++++ src/util/url_args.ts | 231 ++++++++++++++++++++++++ 5 files changed, 559 insertions(+), 149 deletions(-) create mode 100644 src/util/url_args.spec.ts create mode 100644 src/util/url_args.ts diff --git a/docs/APP_REFACTORING_DESIGN.md b/docs/APP_REFACTORING_DESIGN.md index f8d6a26..4e7301b 100644 --- a/docs/APP_REFACTORING_DESIGN.md +++ b/docs/APP_REFACTORING_DESIGN.md @@ -82,7 +82,7 @@ To refactor `app.tsx` safely and maintain continuous code correctness, we will m #### 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.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. #### Phase 2: WebMCP Bridge Extraction diff --git a/jest.config.ts b/jest.config.ts index fcfff84..c085b55 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,6 +16,8 @@ const config: Config.InitialOptions = { "d3-selection": "/node_modules/d3-selection/dist/d3-selection.js", "d3-timer": "/node_modules/d3-timer/dist/d3-timer.js", "d3-transition": "/node_modules/d3-transition/dist/d3-transition.js", + "d3-drag": "/node_modules/d3-drag/dist/d3-drag.js", + "d3-zoom": "/node_modules/d3-zoom/dist/d3-zoom.js", }, }; diff --git a/src/app.tsx b/src/app.tsx index 2b993f7..40b097c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,4 +1,3 @@ -import * as H from 'history'; import queryString from 'query-string'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; @@ -48,7 +47,6 @@ import {Intro} from './intro'; import {GoogleAuthModal} from './menu/google_auth_modal'; import {TopBar} from './menu/top_bar'; import { - argsToConfig, Config, configToArgs, DEFALUT_CONFIG, @@ -60,29 +58,14 @@ import {analyticsEvent} from './util/analytics'; import {TopolaError} from './util/error'; import {getI18nMessage} from './util/error_i18n'; import {idToIndiMap, TopolaData} from './util/gedcom_util'; +import { + DataSourceSpec, + getArguments, + getStaticUrl, + getUrlForArgs, +} from './util/url_args'; 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(); enum AppState { @@ -93,124 +76,6 @@ enum AppState { 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([ - ['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() { /** State of the application. */ const [state, setState] = useState(AppState.INITIAL); @@ -620,13 +485,10 @@ export function App() { }); }, [mcpBridge, location]); - function updateUrl(args: queryString.ParsedQuery) { - const search = queryString.parse(location.search); - for (const key in args) { - search[key] = args[key]; - } - location.search = queryString.stringify(search); - navigate(location); + function updateUrl( + args: Record, + ) { + navigate(getUrlForArgs(location, args)); } /** diff --git a/src/util/url_args.spec.ts b/src/util/url_args.spec.ts new file mode 100644 index 0000000..6e20cf7 --- /dev/null +++ b/src/util/url_args.spec.ts @@ -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¶m2=keep'); + const updated = getUrlForArgs(loc, {param1: 'new', param3: 'added'}); + expect(updated).toEqual({ + pathname: '/view', + search: '?param1=new¶m2=keep¶m3=added', + hash: '#hash-val', + }); + }); + + it('deletes query parameters set to null or undefined', () => { + const loc = createLocation('?param1=val1¶m2=val2'); + const updated = getUrlForArgs(loc, {param1: null, param2: undefined}); + expect(updated).toEqual({ + pathname: '/view', + search: '', + hash: '#hash-val', + }); + }); + }); +}); diff --git a/src/util/url_args.ts b/src/util/url_args.ts new file mode 100644 index 0000000..328a43e --- /dev/null +++ b/src/util/url_args.ts @@ -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([ + ['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, +): {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, + }; +}