From 1d5960a7d2b409c9e246f803a727f90524e5e5c5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 11:00:05 -0500 Subject: [PATCH 01/13] Preserve query filters across reload and history --- .../e2e/tests/project-scoping.e2e.ts | 53 +++++++++++++++ .../features/shared/query-params/README.md | 7 +- .../query-params/query-params.svelte.test.ts | 64 +++++++++++++++++-- .../query-params/query-params.svelte.ts | 31 +++++---- .../query-params.test-harness.svelte | 8 ++- .../src/routes/(app)/event/+page.svelte | 8 +-- .../(app)/redirect-to-events.svelte.test.ts | 19 ++++++ .../routes/(app)/redirect-to-events.svelte.ts | 19 +++++- .../src/routes/(app)/stack/+page.svelte | 8 +-- 9 files changed, 187 insertions(+), 30 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts index cd9a6e7de4..4ded8edb8e 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts @@ -50,5 +50,58 @@ test('operator can scope Events to a project and clear the project filter', asyn await expect(page).not.toHaveURL(/[?&]project=/); await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeVisible({ timeout: 30_000 }); + + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`[?&]project=${e2eScenario.projectId}(?:&|$)`)); + await expect(page.getByRole('button', { name: new RegExp(`^Project\\s+${escapeRegExp(e2eScenario.projectName)}`) })).toBeVisible(); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeHidden({ timeout: 30_000 }); + + await page.goForward(); + await expect(page).not.toHaveURL(/[?&]project=/); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeVisible({ timeout: 30_000 }); + }); +}); + +test('project scope on Most Frequent Errors survives immediate reload and history traversal', async ({ e2eApi, e2eScenario, page }) => { + await test.step('seed an error and scope the stack list from its details', async () => { + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + + await page.goto('/next/stack/most-frequent-errors'); + const stackRow = getVisibleRow(page, e2eScenario.message); + await expect(stackRow).toBeVisible({ timeout: 30_000 }); + await stackRow.click(); + + const stackSheet = page.getByRole('dialog', { name: 'Stack' }); + await expect(stackSheet).toBeVisible(); + await stackSheet.getByTitle(`Filter project:${e2eScenario.projectId}`).click(); + }); + + await test.step('retain scope through an immediate reload', async () => { + await page.reload(); + + await expect(page).toHaveURL(new RegExp(`[?&]project=${e2eScenario.projectId}(?:&|$)`)); + await expect(page.getByRole('button', { name: new RegExp(`^Project\\s+${escapeRegExp(e2eScenario.projectName)}`) })).toBeVisible(); + await expect(page.getByTitle('Refresh results').locator('svg')).not.toHaveClass(/animate-spin/); + }); + + await test.step('restore the scoped and unscoped states through Back and Forward', async () => { + const projectFilter = page.getByRole('button', { name: new RegExp(`^Project\\s+${escapeRegExp(e2eScenario.projectName)}`) }); + await projectFilter.click(); + await expect(projectFilter).toHaveAttribute('aria-expanded', 'true'); + await page.getByRole('button', { name: 'Remove filter' }).click(); + await expect(page).not.toHaveURL(/[?&]project=/); + + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`[?&]project=${e2eScenario.projectId}(?:&|$)`)); + await expect(projectFilter).toBeVisible(); + + await page.goForward(); + await expect(page).not.toHaveURL(/[?&]project=/); + await expect(projectFilter).toBeHidden(); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 1ae4e4c17f..3518ed1eb2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -5,10 +5,11 @@ Exceptionless's shared Svelte query-parameter state module. It is intentionally - top-level string, number, boolean, date, and enum parameters; - typed property access and atomic multi-parameter updates; - preservation of unrelated URL parameters; -- debounced push or replace navigation; +- immediate, reload-safe URL synchronization; +- coalescing of rapid push-history updates into one Back-button entry; - synchronization with browser navigation; - no state, URL, or history writes for unchanged values after coercion; -- cancellation of pending synchronization during navigation and component teardown. +- cancellation of pending history-entry coalescing during navigation and component teardown. ```ts import { createQueryParameters } from '$shared/query-params'; @@ -27,4 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. +URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately creates a history entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. This keeps the URL durable without producing a Back-button entry for every keystroke. With `history: 'replace'`, every update immediately replaces the current entry. + The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index f1f9da01ed..52649e5304 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -34,23 +34,72 @@ describe('createQueryParameters', () => { vi.useRealTimers(); }); - it('writes only the latest debounced update with shallow routing', async () => { + it('writes updates immediately while coalescing rapid push history entries', async () => { // Arrange render(QueryParametersTestHarness); // Act await fireEvent.click(screen.getByRole('button', { name: 'First' })); await fireEvent.click(screen.getByRole('button', { name: 'Second' })); - await vi.advanceTimersByTimeAsync(200); // Assert expect(screen.getByText('second').textContent).toBe('second'); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('?filter=second', pageState); + expect(navigation.pushState).toHaveBeenCalledWith('?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('?filter=second', pageState); + expect(window.location.search).toBe('?filter=second'); + }); + + it('starts a new push history entry after the coalescing window settles', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await vi.advanceTimersByTimeAsync(200); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '?filter=second', pageState); expect(navigation.replaceState).not.toHaveBeenCalled(); }); - it('flushes a pending update before full navigation', async () => { + it('writes replace-history updates immediately without adding entries', async () => { + // Arrange + render(QueryParametersTestHarness, { history: 'replace' }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + // Assert + expect(navigation.pushState).not.toHaveBeenCalled(); + expect(navigation.replaceState).toHaveBeenCalledTimes(2); + expect(navigation.replaceState).toHaveBeenNthCalledWith(1, '?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '?filter=second', pageState); + expect(window.location.search).toBe('?filter=second'); + }); + + it('does not leave a trailing question mark when the last parameter is cleared', async () => { + // Arrange + window.history.replaceState({}, '', '/events#details'); + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + // Assert + expect(navigation.replaceState).toHaveBeenCalledWith('/events#details', pageState); + expect(window.location.pathname).toBe('/events'); + expect(window.location.search).toBe(''); + expect(window.location.hash).toBe('#details'); + }); + + it('does not add a delayed history write after full navigation', async () => { // Arrange render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); @@ -64,10 +113,11 @@ describe('createQueryParameters', () => { expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.pushState).toHaveBeenCalledWith('?filter=first', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=first'); }); - it('discards a pending update when popstate has already changed the location', async () => { + it('restores popstate without scheduling another history write', async () => { // Arrange render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); @@ -82,7 +132,8 @@ describe('createQueryParameters', () => { // Assert expect(beforeNavigation).toBeDefined(); - expect(navigation.pushState).not.toHaveBeenCalled(); + expect(navigation.pushState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=previous'); expect(screen.getByText('previous').textContent).toBe('previous'); }); @@ -102,6 +153,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(screen.getByText('first').textContent).toBe('first'); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 377c780225..35f22975eb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -16,6 +16,11 @@ export function createQueryParameters({ }: CreateQueryParametersOptions) { let searchParams = createSearchParams(building ? '' : page.url.search); const current = $state>(parseQueryParameters(searchParams, schema, defaults)); + // Create a durable entry immediately, then replace it while rapid updates still belong to the same user action. + let isCoalescingPushHistoryEntry = false; + const schedulePushHistoryEntrySettlement = createDebouncedFunction(() => { + isCoalescingPushHistoryEntry = false; + }, debounceMilliseconds); const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { @@ -23,22 +28,26 @@ export function createQueryParameters({ } const query = searchParams.toString(); - const url = `?${query}${window.location.hash}`; - if (history === 'replace') { + const url = `${query ? `?${query}` : window.location.pathname}${window.location.hash}`; + if (history === 'replace' || isCoalescingPushHistoryEntry) { replaceState(url, page.state); } else { pushState(url, page.state); + isCoalescingPushHistoryEntry = true; } - }; - const scheduleSynchronization = createDebouncedFunction(synchronizeURL, debounceMilliseconds); - beforeNavigate(({ type }) => { - scheduleSynchronization.cancel(); - if (type !== 'popstate') { - synchronizeURL(); + if (history === 'push') { + schedulePushHistoryEntrySettlement(); } - }); - onDestroy(scheduleSynchronization.cancel); + }; + + const settlePushHistoryEntry = () => { + schedulePushHistoryEntrySettlement.cancel(); + isCoalescingPushHistoryEntry = false; + }; + + beforeNavigate(settlePushHistoryEntry); + onDestroy(settlePushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; @@ -47,7 +56,7 @@ export function createQueryParameters({ } if (result.urlChanged) { - scheduleSynchronization(); + synchronizeURL(); } }; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte index 8fa9418fcd..b6b7071186 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte @@ -1,13 +1,19 @@ + {queryParameters.filter} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 3a06936aac..22cdf15b51 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -410,8 +410,8 @@ let filters = $state(getCurrentFilters()); let isInternalFilterUpdate = false; watch( - [() => page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView], - ([pathname, , activeSavedView], [previousPathname, , previousSavedView]) => { + [() => page.url.pathname, () => getListFilterQueryParams(queryParams), () => savedViewsState.activeSavedView], + ([pathname, currentQueryParams, activeSavedView], [previousPathname, , previousSavedView]) => { const savedViewChanged = pathname !== previousPathname || activeSavedView?.id !== previousSavedView?.id; if (isInternalFilterUpdate && !savedViewChanged) { isInternalFilterUpdate = false; @@ -419,7 +419,7 @@ } isInternalFilterUpdate = false; - filters = getCurrentFilters(getListFilterQueryParams(page.url.searchParams)); + filters = getCurrentFilters(currentQueryParams); }, { lazy: true } ); @@ -524,7 +524,7 @@ } untrack(() => { - updateFilters(getCurrentFilters(getListFilterQueryParams(page.url.searchParams)), { clearPagination: false }); + updateFilters(getCurrentFilters(getListFilterQueryParams(queryParams)), { clearPagination: false }); }); normalizedSavedViewId = activeSavedViewId; }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts index 1bc1e4aae5..7d4148e1c9 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.test.ts @@ -10,6 +10,25 @@ vi.mock('$app/paths', () => ({ })); describe('redirect-to-events', () => { + it('snapshots list filter query parameters from shared reactive state', async () => { + // Arrange + const { getListFilterQueryParams } = await import('./redirect-to-events.svelte'); + const queryParams = { + filter: 'message:test', + page: 2, + project: 'project-1' + }; + + // Act + const result = getListFilterQueryParams(queryParams); + + // Assert + expect(result.filter).toBe('message:test'); + expect(result.project).toBe('project-1'); + expect(result.status).toBeNull(); + expect(result).not.toHaveProperty('page'); + }); + it('defines every list filter query parameter reset without unrelated state', async () => { // Arrange const { LIST_FILTER_QUERY_PARAM_RESET } = await import('./redirect-to-events.svelte'); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts index 859648ac9f..d07c690ad1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/redirect-to-events.svelte.ts @@ -31,6 +31,7 @@ const LIST_FILTER_QUERY_PARAM_NAMES = [ export type ListFilterQueryParams = Partial>; type ListFilterQueryParamName = (typeof LIST_FILTER_QUERY_PARAM_NAMES)[number]; +type ListFilterQueryParamSnapshot = Record; const DATE_RANGE_PATTERN = /^\[?(?.+?)\s+TO\s+(?.+?)\]?$/i; const RELATIVE_TO_NOW_PATTERN = /^now-(?\d+[Mdhmswy])$/; @@ -95,8 +96,22 @@ export function getEventsNavigationOptionsForFilter(filter: IFilter): ListNaviga return undefined; } -export function getListFilterQueryParams(searchParams: URLSearchParams): ListFilterQueryParams { - return Object.fromEntries(LIST_FILTER_QUERY_PARAM_NAMES.map((name) => [name, searchParams.get(name)])) as ListFilterQueryParams; +export function getListFilterQueryParams(source: ListFilterQueryParams): ListFilterQueryParamSnapshot { + return { + bot: source.bot ?? null, + filter: source.filter ?? null, + first: source.first ?? null, + level: source.level ?? null, + project: source.project ?? null, + reference: source.reference ?? null, + session: source.session ?? null, + stack: source.stack ?? null, + status: source.status ?? null, + tag: source.tag ?? null, + time: source.time ?? null, + type: source.type ?? null, + version: source.version ?? null + }; } export async function navigateToListPage(page: ListPage, organizationId: string | undefined, filters: IFilter[], options: ListNavigationOptions = {}) { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 7fb2d7ec29..48f863f10c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -399,8 +399,8 @@ let filters = $state(getCurrentFilters()); let isInternalFilterUpdate = false; watch( - [() => page.url.pathname, () => page.url.search, () => savedViewsState.activeSavedView], - ([pathname, , activeSavedView], [previousPathname, , previousSavedView]) => { + [() => page.url.pathname, () => getListFilterQueryParams(queryParams), () => savedViewsState.activeSavedView], + ([pathname, currentQueryParams, activeSavedView], [previousPathname, , previousSavedView]) => { const savedViewChanged = pathname !== previousPathname || activeSavedView?.id !== previousSavedView?.id; if (isInternalFilterUpdate && !savedViewChanged) { isInternalFilterUpdate = false; @@ -408,7 +408,7 @@ } isInternalFilterUpdate = false; - const updatedFilters = getCurrentFilters(getListFilterQueryParams(page.url.searchParams)); + const updatedFilters = getCurrentFilters(currentQueryParams); if (serializeFilters(filters ?? []) !== serializeFilters(updatedFilters)) { filters = updatedFilters; } @@ -516,7 +516,7 @@ } untrack(() => { - updateFilters(getCurrentFilters(getListFilterQueryParams(page.url.searchParams)), { clearPagination: false }); + updateFilters(getCurrentFilters(getListFilterQueryParams(queryParams)), { clearPagination: false }); }); normalizedSavedViewId = activeSavedViewId; }); From 237acbffbf87b2d630260dba5efe0d16df8c99c6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 11:17:43 -0500 Subject: [PATCH 02/13] Avoid duplicate coalesced history entries --- .../features/shared/query-params/README.md | 4 +- .../query-params/query-params.svelte.test.ts | 46 +++++++++------ .../query-params/query-params.svelte.ts | 56 +++++++++++++------ 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 3518ed1eb2..0c86eb43d8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -9,7 +9,7 @@ Exceptionless's shared Svelte query-parameter state module. It is intentionally - coalescing of rapid push-history updates into one Back-button entry; - synchronization with browser navigation; - no state, URL, or history writes for unchanged values after coercion; -- cancellation of pending history-entry coalescing during navigation and component teardown. +- finalization of pending history-entry coalescing during navigation and component teardown. ```ts import { createQueryParameters } from '$shared/query-params'; @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately creates a history entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. This keeps the URL durable without producing a Back-button entry for every keystroke. With `history: 'replace'`, every update immediately replaces the current entry. +URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, rapid updates immediately replace the visible URL. After `debounceMilliseconds` elapses, the module restores the URL from before the burst and pushes the final URL as one history entry. If the burst returns to its starting URL, no entry is added. Pending entries are finalized before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 52649e5304..e953d8c65c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -34,7 +34,7 @@ describe('createQueryParameters', () => { vi.useRealTimers(); }); - it('writes updates immediately while coalescing rapid push history entries', async () => { + it('writes updates immediately and commits one settled push history entry', async () => { // Arrange render(QueryParametersTestHarness); @@ -44,11 +44,17 @@ describe('createQueryParameters', () => { // Assert expect(screen.getByText('second').textContent).toBe('second'); - expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('?filter=first', pageState); - expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('?filter=second', pageState); + expect(navigation.pushState).not.toHaveBeenCalled(); + expect(navigation.replaceState).toHaveBeenCalledTimes(2); + expect(navigation.replaceState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); expect(window.location.search).toBe('?filter=second'); + + await vi.advanceTimersByTimeAsync(200); + + expect(navigation.replaceState).toHaveBeenNthCalledWith(3, '/', pageState); + expect(navigation.pushState).toHaveBeenCalledOnce(); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=second', pageState); }); it('starts a new push history entry after the coalescing window settles', async () => { @@ -59,12 +65,14 @@ describe('createQueryParameters', () => { // Act await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + await vi.advanceTimersByTimeAsync(200); // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.pushState).toHaveBeenNthCalledWith(1, '?filter=first', pageState); - expect(navigation.pushState).toHaveBeenNthCalledWith(2, '?filter=second', pageState); - expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledTimes(4); + expect(navigation.replaceState).toHaveBeenNthCalledWith(4, '/?filter=first', pageState); }); it('writes replace-history updates immediately without adding entries', async () => { @@ -78,12 +86,12 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).not.toHaveBeenCalled(); expect(navigation.replaceState).toHaveBeenCalledTimes(2); - expect(navigation.replaceState).toHaveBeenNthCalledWith(1, '?filter=first', pageState); - expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); expect(window.location.search).toBe('?filter=second'); }); - it('does not leave a trailing question mark when the last parameter is cleared', async () => { + it('does not push a duplicate entry or trailing question mark when a burst returns to its starting URL', async () => { // Arrange window.history.replaceState({}, '', '/events#details'); render(QueryParametersTestHarness); @@ -91,15 +99,18 @@ describe('createQueryParameters', () => { // Act await fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + await vi.advanceTimersByTimeAsync(200); // Assert + expect(navigation.pushState).not.toHaveBeenCalled(); + expect(navigation.replaceState).toHaveBeenCalledTimes(2); expect(navigation.replaceState).toHaveBeenCalledWith('/events#details', pageState); expect(window.location.pathname).toBe('/events'); expect(window.location.search).toBe(''); expect(window.location.hash).toBe('#details'); }); - it('does not add a delayed history write after full navigation', async () => { + it('commits a pending push history entry before full navigation', async () => { // Arrange render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); @@ -112,8 +123,9 @@ describe('createQueryParameters', () => { // Assert expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('?filter=first', pageState); - expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenCalledTimes(2); + expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '/', pageState); expect(window.location.search).toBe('?filter=first'); }); @@ -122,10 +134,10 @@ describe('createQueryParameters', () => { render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; - window.history.replaceState(pageState, '', '?filter=previous'); // Act beforeNavigation?.({ type: 'popstate' }); + window.history.replaceState(pageState, '', '?filter=previous'); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await vi.advanceTimersByTimeAsync(200); await tick(); @@ -133,7 +145,7 @@ describe('createQueryParameters', () => { // Assert expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(navigation.replaceState).toHaveBeenCalledTimes(2); expect(window.location.search).toBe('?filter=previous'); expect(screen.getByText('previous').textContent).toBe('previous'); }); @@ -153,7 +165,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(navigation.replaceState).toHaveBeenCalledTimes(4); expect(screen.getByText('first').textContent).toBe('first'); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 35f22975eb..3705f5bc5b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -8,6 +8,11 @@ import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameter import { createQueryParameterProxy } from './proxy.js'; import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; +interface HistoryEntrySnapshot { + state: App.PageState; + url: string; +} + export function createQueryParameters({ debounceMilliseconds = 200, defaults, @@ -16,11 +21,30 @@ export function createQueryParameters({ }: CreateQueryParametersOptions) { let searchParams = createSearchParams(building ? '' : page.url.search); const current = $state>(parseQueryParameters(searchParams, schema, defaults)); - // Create a durable entry immediately, then replace it while rapid updates still belong to the same user action. - let isCoalescingPushHistoryEntry = false; - const schedulePushHistoryEntrySettlement = createDebouncedFunction(() => { - isCoalescingPushHistoryEntry = false; - }, debounceMilliseconds); + let pendingPushHistoryEntry: HistoryEntrySnapshot | undefined; + const getCurrentUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; + + const finalizePushHistoryEntry = () => { + const previousEntry = pendingPushHistoryEntry; + pendingPushHistoryEntry = undefined; + if (!previousEntry) { + return; + } + + const currentEntry = { state: page.state, url: getCurrentUrl() }; + if (currentEntry.url === previousEntry.url) { + return; + } + + replaceState(previousEntry.url, previousEntry.state); + pushState(currentEntry.url, currentEntry.state); + }; + + const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); + const flushPendingPushHistoryEntry = () => { + schedulePushHistoryEntryFinalization.cancel(); + finalizePushHistoryEntry(); + }; const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { @@ -28,26 +52,22 @@ export function createQueryParameters({ } const query = searchParams.toString(); - const url = `${query ? `?${query}` : window.location.pathname}${window.location.hash}`; - if (history === 'replace' || isCoalescingPushHistoryEntry) { + const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + if (history === 'replace') { replaceState(url, page.state); - } else { - pushState(url, page.state); - isCoalescingPushHistoryEntry = true; + return; } - if (history === 'push') { - schedulePushHistoryEntrySettlement(); + if (!pendingPushHistoryEntry) { + pendingPushHistoryEntry = { state: page.state, url: getCurrentUrl() }; } - }; - const settlePushHistoryEntry = () => { - schedulePushHistoryEntrySettlement.cancel(); - isCoalescingPushHistoryEntry = false; + replaceState(url, page.state); + schedulePushHistoryEntryFinalization(); }; - beforeNavigate(settlePushHistoryEntry); - onDestroy(settlePushHistoryEntry); + beforeNavigate(flushPendingPushHistoryEntry); + onDestroy(flushPendingPushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; From 740f922cfbe10b76b4e9e8db8a0003b4288b0f4c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 11:33:10 -0500 Subject: [PATCH 03/13] Keep immediate back navigation durable --- .../features/shared/query-params/README.md | 4 +- .../query-params/query-params.svelte.test.ts | 31 +++++----- .../query-params/query-params.svelte.ts | 56 +++++++------------ 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 0c86eb43d8..50d98e76fe 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -9,7 +9,7 @@ Exceptionless's shared Svelte query-parameter state module. It is intentionally - coalescing of rapid push-history updates into one Back-button entry; - synchronization with browser navigation; - no state, URL, or history writes for unchanged values after coercion; -- finalization of pending history-entry coalescing during navigation and component teardown. +- settlement of history-entry coalescing during navigation and component teardown. ```ts import { createQueryParameters } from '$shared/query-params'; @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, rapid updates immediately replace the visible URL. After `debounceMilliseconds` elapses, the module restores the URL from before the burst and pushes the final URL as one history entry. If the burst returns to its starting URL, no entry is added. Pending entries are finalized before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. +URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately pushes a Back-button entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. Coalescing settles before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index e953d8c65c..6cf0047927 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -34,7 +34,7 @@ describe('createQueryParameters', () => { vi.useRealTimers(); }); - it('writes updates immediately and commits one settled push history entry', async () => { + it('creates a durable history entry immediately while coalescing rapid updates', async () => { // Arrange render(QueryParametersTestHarness); @@ -44,17 +44,16 @@ describe('createQueryParameters', () => { // Assert expect(screen.getByText('second').textContent).toBe('second'); - expect(navigation.pushState).not.toHaveBeenCalled(); - expect(navigation.replaceState).toHaveBeenCalledTimes(2); - expect(navigation.replaceState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); - expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); + expect(navigation.pushState).toHaveBeenCalledOnce(); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); expect(window.location.search).toBe('?filter=second'); await vi.advanceTimersByTimeAsync(200); - expect(navigation.replaceState).toHaveBeenNthCalledWith(3, '/', pageState); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledOnce(); }); it('starts a new push history entry after the coalescing window settles', async () => { @@ -71,8 +70,7 @@ describe('createQueryParameters', () => { expect(navigation.pushState).toHaveBeenCalledTimes(2); expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); - expect(navigation.replaceState).toHaveBeenCalledTimes(4); - expect(navigation.replaceState).toHaveBeenNthCalledWith(4, '/?filter=first', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); }); it('writes replace-history updates immediately without adding entries', async () => { @@ -91,7 +89,7 @@ describe('createQueryParameters', () => { expect(window.location.search).toBe('?filter=second'); }); - it('does not push a duplicate entry or trailing question mark when a burst returns to its starting URL', async () => { + it('does not leave a trailing question mark when a burst returns to its starting URL', async () => { // Arrange window.history.replaceState({}, '', '/events#details'); render(QueryParametersTestHarness); @@ -102,15 +100,15 @@ describe('createQueryParameters', () => { await vi.advanceTimersByTimeAsync(200); // Assert - expect(navigation.pushState).not.toHaveBeenCalled(); - expect(navigation.replaceState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenCalledWith('/events?filter=first#details', pageState); + expect(navigation.replaceState).toHaveBeenCalledOnce(); expect(navigation.replaceState).toHaveBeenCalledWith('/events#details', pageState); expect(window.location.pathname).toBe('/events'); expect(window.location.search).toBe(''); expect(window.location.hash).toBe('#details'); }); - it('commits a pending push history entry before full navigation', async () => { + it('does not add a delayed history write after full navigation', async () => { // Arrange render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); @@ -124,8 +122,7 @@ describe('createQueryParameters', () => { expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); - expect(navigation.replaceState).toHaveBeenCalledTimes(2); - expect(navigation.replaceState).toHaveBeenNthCalledWith(2, '/', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=first'); }); @@ -145,7 +142,7 @@ describe('createQueryParameters', () => { // Assert expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledTimes(2); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=previous'); expect(screen.getByText('previous').textContent).toBe('previous'); }); @@ -165,7 +162,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.replaceState).toHaveBeenCalledTimes(4); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(screen.getByText('first').textContent).toBe('first'); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 3705f5bc5b..eee815b751 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -8,11 +8,6 @@ import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameter import { createQueryParameterProxy } from './proxy.js'; import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; -interface HistoryEntrySnapshot { - state: App.PageState; - url: string; -} - export function createQueryParameters({ debounceMilliseconds = 200, defaults, @@ -21,30 +16,13 @@ export function createQueryParameters({ }: CreateQueryParametersOptions) { let searchParams = createSearchParams(building ? '' : page.url.search); const current = $state>(parseQueryParameters(searchParams, schema, defaults)); - let pendingPushHistoryEntry: HistoryEntrySnapshot | undefined; - const getCurrentUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; - - const finalizePushHistoryEntry = () => { - const previousEntry = pendingPushHistoryEntry; - pendingPushHistoryEntry = undefined; - if (!previousEntry) { - return; - } - - const currentEntry = { state: page.state, url: getCurrentUrl() }; - if (currentEntry.url === previousEntry.url) { - return; - } - - replaceState(previousEntry.url, previousEntry.state); - pushState(currentEntry.url, currentEntry.state); - }; - - const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); - const flushPendingPushHistoryEntry = () => { - schedulePushHistoryEntryFinalization.cancel(); - finalizePushHistoryEntry(); - }; + // Create the Back target synchronously, then replace it while rapid updates still + // belong to the same user interaction. Deferring the first push leaves no target + // for an immediate Back action and can navigate a newly opened tab to about:blank. + let isCoalescingPushHistoryEntry = false; + const schedulePushHistoryEntrySettlement = createDebouncedFunction(() => { + isCoalescingPushHistoryEntry = false; + }, debounceMilliseconds); const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { @@ -53,21 +31,25 @@ export function createQueryParameters({ const query = searchParams.toString(); const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; - if (history === 'replace') { + if (history === 'replace' || isCoalescingPushHistoryEntry) { replaceState(url, page.state); - return; + } else { + pushState(url, page.state); + isCoalescingPushHistoryEntry = true; } - if (!pendingPushHistoryEntry) { - pendingPushHistoryEntry = { state: page.state, url: getCurrentUrl() }; + if (history === 'push') { + schedulePushHistoryEntrySettlement(); } + }; - replaceState(url, page.state); - schedulePushHistoryEntryFinalization(); + const settlePushHistoryEntry = () => { + schedulePushHistoryEntrySettlement.cancel(); + isCoalescingPushHistoryEntry = false; }; - beforeNavigate(flushPendingPushHistoryEntry); - onDestroy(flushPendingPushHistoryEntry); + beforeNavigate(settlePushHistoryEntry); + onDestroy(settlePushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; From 5bdd3c71e6ce0e1c2e9193d959fbe27bd979785b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 11:48:05 -0500 Subject: [PATCH 04/13] Keep coalesced history traversal meaningful --- .../features/shared/query-params/README.md | 2 +- .../query-params/query-params.svelte.test.ts | 26 ++++++++++++++++--- .../query-params/query-params.svelte.ts | 17 ++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 50d98e76fe..2ce8ecc421 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately pushes a Back-button entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. Coalescing settles before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. +URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately pushes a Back-button entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. If a burst returns to its starting URL, the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Coalescing settles before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 6cf0047927..160d1cc797 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -89,7 +89,7 @@ describe('createQueryParameters', () => { expect(window.location.search).toBe('?filter=second'); }); - it('does not leave a trailing question mark when a burst returns to its starting URL', async () => { + it('keeps a meaningful Back target when a burst returns to its starting URL', async () => { // Arrange window.history.replaceState({}, '', '/events#details'); render(QueryParametersTestHarness); @@ -100,14 +100,32 @@ describe('createQueryParameters', () => { await vi.advanceTimersByTimeAsync(200); // Assert - expect(navigation.pushState).toHaveBeenCalledWith('/events?filter=first#details', pageState); - expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/events#details', pageState); + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/events?filter=first#details', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/events#details', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.pathname).toBe('/events'); expect(window.location.search).toBe(''); expect(window.location.hash).toBe('#details'); }); + it('starts a new coalescing burst after returning to the starting URL', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(3); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?filter=second', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + }); + it('does not add a delayed history write after full navigation', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index eee815b751..fd6372dddf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -20,9 +20,12 @@ export function createQueryParameters({ // belong to the same user interaction. Deferring the first push leaves no target // for an immediate Back action and can navigate a newly opened tab to about:blank. let isCoalescingPushHistoryEntry = false; + let coalescingStartUrl: string | undefined; const schedulePushHistoryEntrySettlement = createDebouncedFunction(() => { isCoalescingPushHistoryEntry = false; + coalescingStartUrl = undefined; }, debounceMilliseconds); + const getCurrentUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { @@ -31,11 +34,20 @@ export function createQueryParameters({ const query = searchParams.toString(); const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; - if (history === 'replace' || isCoalescingPushHistoryEntry) { + if (history === 'replace') { replaceState(url, page.state); - } else { + } else if (!isCoalescingPushHistoryEntry) { + coalescingStartUrl = getCurrentUrl(); pushState(url, page.state); isCoalescingPushHistoryEntry = true; + } else if (url === coalescingStartUrl) { + // Keep the transient state as a meaningful Back target instead of + // replacing it with a duplicate of the entry behind it. + pushState(url, page.state); + isCoalescingPushHistoryEntry = false; + coalescingStartUrl = undefined; + } else { + replaceState(url, page.state); } if (history === 'push') { @@ -46,6 +58,7 @@ export function createQueryParameters({ const settlePushHistoryEntry = () => { schedulePushHistoryEntrySettlement.cancel(); isCoalescingPushHistoryEntry = false; + coalescingStartUrl = undefined; }; beforeNavigate(settlePushHistoryEntry); From 6ae1cdb3f751feef647267a20e5d77f5718aaaf3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:01:16 -0500 Subject: [PATCH 05/13] Normalize coalesced history comparisons --- .../query-params/query-params.svelte.test.ts | 16 ++++++++++++++++ .../shared/query-params/query-params.svelte.ts | 5 ++++- .../query-params.test-harness.svelte | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 160d1cc797..00492c2792 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -126,6 +126,22 @@ describe('createQueryParameters', () => { expect(navigation.replaceState).not.toHaveBeenCalled(); }); + it('recognizes an encoded equivalent of the starting URL', async () => { + // Arrange + window.history.replaceState({}, '', '/?filter=a%20b'); + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Spaced' })); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a+b', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + }); + it('does not add a delayed history write after full navigation', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index fd6372dddf..96102d0d5f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -25,7 +25,10 @@ export function createQueryParameters({ isCoalescingPushHistoryEntry = false; coalescingStartUrl = undefined; }, debounceMilliseconds); - const getCurrentUrl = () => `${window.location.pathname}${window.location.search}${window.location.hash}`; + const getCurrentUrl = () => { + const query = createSearchParams(window.location.search).toString(); + return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + }; const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte index b6b7071186..d97601a0d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte @@ -15,5 +15,6 @@ + {queryParameters.filter} From db824db0c1acb2f6ae292caadf707c55b96ee443 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:14:44 -0500 Subject: [PATCH 06/13] Throttle coalesced history writes --- .../features/shared/query-params/README.md | 2 +- .../query-params/query-params.svelte.test.ts | 40 ++++++++++++- .../query-params/query-params.svelte.ts | 59 ++++++++++++++----- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 2ce8ecc421..998745b0ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -URL synchronization is synchronous: once query-parameter state changes, a reload observes the same state. With `history: 'push'`, the first update immediately pushes a Back-button entry and rapid follow-up updates replace that entry until `debounceMilliseconds` elapses. If a burst returns to its starting URL, the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Coalescing settles before navigation or teardown. With `history: 'replace'`, every update immediately replaces the current entry. +With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Popstate traversal discards pending writes so it never rewrites the destination entry. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 00492c2792..74f3a67040 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -34,7 +34,7 @@ describe('createQueryParameters', () => { vi.useRealTimers(); }); - it('creates a durable history entry immediately while coalescing rapid updates', async () => { + it('creates a durable history entry immediately while throttling rapid replacements', async () => { // Arrange render(QueryParametersTestHarness); @@ -46,14 +46,32 @@ describe('createQueryParameters', () => { expect(screen.getByText('second').textContent).toBe('second'); expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=first'); + + await vi.advanceTimersByTimeAsync(200); + + expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.replaceState).toHaveBeenCalledOnce(); expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); expect(window.location.search).toBe('?filter=second'); + }); + + it('cancels a pending replacement when state returns to the visible URL', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + // Act + await fireEvent.click(screen.getByRole('button', { name: 'First' })); await vi.advanceTimersByTimeAsync(200); + // Assert expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=first'); }); it('starts a new push history entry after the coalescing window settles', async () => { @@ -160,6 +178,22 @@ describe('createQueryParameters', () => { expect(window.location.search).toBe('?filter=first'); }); + it('flushes a throttled replacement before reload', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + // Act + window.dispatchEvent(new Event('beforeunload')); + + // Assert + expect(navigation.pushState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(window.location.search).toBe('?filter=second'); + }); + it('restores popstate without scheduling another history write', async () => { // Arrange render(QueryParametersTestHarness); @@ -167,8 +201,8 @@ describe('createQueryParameters', () => { const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; // Act - beforeNavigation?.({ type: 'popstate' }); window.history.replaceState(pageState, '', '?filter=previous'); + beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await vi.advanceTimersByTimeAsync(200); await tick(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 96102d0d5f..381ed2de9b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -21,17 +21,34 @@ export function createQueryParameters({ // for an immediate Back action and can navigate a newly opened tab to about:blank. let isCoalescingPushHistoryEntry = false; let coalescingStartUrl: string | undefined; - const schedulePushHistoryEntrySettlement = createDebouncedFunction(() => { - isCoalescingPushHistoryEntry = false; - coalescingStartUrl = undefined; - }, debounceMilliseconds); + let pendingReplacementUrl: string | undefined; const getCurrentUrl = () => { const query = createSearchParams(window.location.search).toString(); return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; }; + const settlePushHistoryEntry = () => { + isCoalescingPushHistoryEntry = false; + coalescingStartUrl = undefined; + }; + + const flushPendingReplacement = () => { + if (pendingReplacementUrl) { + replaceState(pendingReplacementUrl, page.state); + pendingReplacementUrl = undefined; + } + }; + + const finalizePushHistoryEntry = () => { + flushPendingReplacement(); + settlePushHistoryEntry(); + }; + + const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); + const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { + pendingReplacementUrl = undefined; return; } @@ -46,26 +63,37 @@ export function createQueryParameters({ } else if (url === coalescingStartUrl) { // Keep the transient state as a meaningful Back target instead of // replacing it with a duplicate of the entry behind it. + schedulePushHistoryEntryFinalization.cancel(); + pendingReplacementUrl = undefined; pushState(url, page.state); - isCoalescingPushHistoryEntry = false; - coalescingStartUrl = undefined; + settlePushHistoryEntry(); } else { - replaceState(url, page.state); + // Avoid exhausting browser History API mutation quotas during sustained input. + pendingReplacementUrl = url; } - if (history === 'push') { - schedulePushHistoryEntrySettlement(); + if (history === 'push' && isCoalescingPushHistoryEntry) { + schedulePushHistoryEntryFinalization(); } }; - const settlePushHistoryEntry = () => { - schedulePushHistoryEntrySettlement.cancel(); - isCoalescingPushHistoryEntry = false; - coalescingStartUrl = undefined; + const handleBeforeNavigate = ({ type }: { type: string }) => { + schedulePushHistoryEntryFinalization.cancel(); + if (type !== 'popstate') { + flushPendingReplacement(); + } else { + pendingReplacementUrl = undefined; + } + + settlePushHistoryEntry(); }; - beforeNavigate(settlePushHistoryEntry); - onDestroy(settlePushHistoryEntry); + beforeNavigate(handleBeforeNavigate); + if (browser) { + window.addEventListener('beforeunload', flushPendingReplacement); + } + + onDestroy(finalizePushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; @@ -100,6 +128,7 @@ export function createQueryParameters({ onDestroy(() => { if (browser) { window.removeEventListener('popstate', synchronizeStateFromLocation); + window.removeEventListener('beforeunload', flushPendingReplacement); } }); From 88d04c1a7b17654eb33997ff1a45970a94053e2d Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:29:31 -0500 Subject: [PATCH 07/13] Preserve pending history across traversal --- .../features/shared/query-params/README.md | 2 +- .../query-params/query-params.svelte.test.ts | 53 ++++++++++++++++++- .../query-params/query-params.svelte.ts | 49 ++++++++++++----- .../query-params.test-harness.svelte | 1 + 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 998745b0ea..40ef6e9784 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Popstate traversal discards pending writes so it never rewrites the destination entry. With `history: 'replace'`, every update immediately replaces the current entry. +With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, comparison is independent of query encoding and parameter order, and the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Immediate Back keeps a pending replacement associated with its source entry; Forward restores the latest value without rewriting the Back destination. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 74f3a67040..9b8a5388ff 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -17,7 +17,9 @@ vi.mock('$app/navigation', () => navigation); vi.mock('$app/state', () => ({ page: { state: pageState, - url: new URL('http://localhost/') + get url() { + return new URL(window.location.href); + } } })); @@ -160,6 +162,24 @@ describe('createQueryParameters', () => { expect(navigation.replaceState).not.toHaveBeenCalled(); }); + it('recognizes an equivalent starting URL with reordered parameters', async () => { + // Arrange + window.history.replaceState({}, '', '/?project=p'); + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + await vi.advanceTimersByTimeAsync(200); + await fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(3); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?project=p', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?project=p&filter=a', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + }); + it('does not add a delayed history write after full navigation', async () => { // Arrange render(QueryParametersTestHarness); @@ -215,6 +235,37 @@ describe('createQueryParameters', () => { expect(screen.getByText('previous').textContent).toBe('previous'); }); + it('preserves a throttled replacement across immediate Back and Forward', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; + + // Act: traverse Back before the replacement settles. + window.history.replaceState(pageState, '', '/'); + beforeNavigation?.({ type: 'popstate' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + + // Assert: the destination is untouched and the pending source value is retained. + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe(''); + expect(document.querySelector('output')?.textContent).toBe(''); + + // Act: traverse Forward to the source entry. + window.history.replaceState(pageState, '', '/?filter=first'); + beforeNavigation?.({ type: 'popstate' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + + // Assert: the source entry and reactive state restore the latest value. + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(window.location.search).toBe('?filter=second'); + expect(screen.getByText('second').textContent).toBe('second'); + }); + it('restores reactive state when shallow history is traversed', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 381ed2de9b..35bdb894f0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -2,6 +2,7 @@ import { browser, building } from '$app/environment'; import { afterNavigate, beforeNavigate, pushState, replaceState } from '$app/navigation'; import { page } from '$app/state'; import { onDestroy } from 'svelte'; +import { SvelteURL } from 'svelte/reactivity'; import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameterState } from './types.js'; @@ -21,15 +22,22 @@ export function createQueryParameters({ // for an immediate Back action and can navigate a newly opened tab to about:blank. let isCoalescingPushHistoryEntry = false; let coalescingStartUrl: string | undefined; + let coalescingEntryUrl: string | undefined; let pendingReplacementUrl: string | undefined; - const getCurrentUrl = () => { - const query = createSearchParams(window.location.search).toString(); - return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + const normalizeUrl = (url: string) => { + const value = new SvelteURL(url, window.location.origin); + value.searchParams.sort(); + const query = value.searchParams.toString(); + + return `${value.pathname}${query ? `?${query}` : ''}${value.hash}`; }; + const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); + const settlePushHistoryEntry = () => { isCoalescingPushHistoryEntry = false; coalescingStartUrl = undefined; + coalescingEntryUrl = undefined; }; const flushPendingReplacement = () => { @@ -40,8 +48,10 @@ export function createQueryParameters({ }; const finalizePushHistoryEntry = () => { - flushPendingReplacement(); - settlePushHistoryEntry(); + if (!coalescingEntryUrl || getCurrentUrl() === coalescingEntryUrl) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } }; const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); @@ -59,8 +69,9 @@ export function createQueryParameters({ } else if (!isCoalescingPushHistoryEntry) { coalescingStartUrl = getCurrentUrl(); pushState(url, page.state); + coalescingEntryUrl = normalizeUrl(url); isCoalescingPushHistoryEntry = true; - } else if (url === coalescingStartUrl) { + } else if (normalizeUrl(url) === coalescingStartUrl) { // Keep the transient state as a meaningful Back target instead of // replacing it with a duplicate of the entry behind it. schedulePushHistoryEntryFinalization.cancel(); @@ -79,7 +90,16 @@ export function createQueryParameters({ const handleBeforeNavigate = ({ type }: { type: string }) => { schedulePushHistoryEntryFinalization.cancel(); - if (type !== 'popstate') { + if (type === 'popstate') { + if (pendingReplacementUrl && getCurrentUrl() === coalescingEntryUrl) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } + + return; + } + + if (getCurrentUrl() === coalescingEntryUrl) { flushPendingReplacement(); } else { pendingReplacementUrl = undefined; @@ -88,9 +108,14 @@ export function createQueryParameters({ settlePushHistoryEntry(); }; + const handleBeforeUnload = () => { + schedulePushHistoryEntryFinalization.cancel(); + finalizePushHistoryEntry(); + }; + beforeNavigate(handleBeforeNavigate); if (browser) { - window.addEventListener('beforeunload', flushPendingReplacement); + window.addEventListener('beforeunload', handleBeforeUnload); } onDestroy(finalizePushHistoryEntry); @@ -128,13 +153,13 @@ export function createQueryParameters({ onDestroy(() => { if (browser) { window.removeEventListener('popstate', synchronizeStateFromLocation); - window.removeEventListener('beforeunload', flushPendingReplacement); + window.removeEventListener('beforeunload', handleBeforeUnload); } }); - afterNavigate(({ to }) => { - if (to) { - synchronizeState(to.url.search); + afterNavigate(() => { + if (browser) { + synchronizeStateFromLocation(); } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte index d97601a0d2..b0609654fb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test-harness.svelte @@ -15,6 +15,7 @@ + {queryParameters.filter} From 667d5e3bdf39b1b792db460172d04fe4701e0a11 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:40:42 -0500 Subject: [PATCH 08/13] Rebase filter history after back navigation --- .../query-params/query-params.svelte.test.ts | 23 +++++++++++++++++++ .../query-params/query-params.svelte.ts | 9 ++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 9b8a5388ff..0ca337323b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -266,6 +266,29 @@ describe('createQueryParameters', () => { expect(screen.getByText('second').textContent).toBe('second'); }); + it('starts a new burst when the Back destination is edited', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; + window.history.replaceState(pageState, '', '/'); + beforeNavigation?.({ type: 'popstate' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Alpha' })); + await vi.advanceTimersByTimeAsync(200); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a', pageState); + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=a'); + expect(screen.getByText('a').textContent).toBe('a'); + }); + it('restores reactive state when shallow history is traversed', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 35bdb894f0..530507d66f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -62,6 +62,15 @@ export function createQueryParameters({ return; } + if (history === 'push' && isCoalescingPushHistoryEntry && getCurrentUrl() !== coalescingEntryUrl) { + // A popstate traversal may retain a pending replacement for the entry + // we left. Editing this destination discards that Forward entry, so + // start a fresh burst here instead of mutating the retained source. + schedulePushHistoryEntryFinalization.cancel(); + pendingReplacementUrl = undefined; + settlePushHistoryEntry(); + } + const query = searchParams.toString(); const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; if (history === 'replace') { From c0ef85a9c21dc074997375b54af373954e7f80a1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:47:56 -0500 Subject: [PATCH 09/13] Preserve pending history across route teardown --- .../query-params/query-params.svelte.test.ts | 24 +++++++++++++++++ .../query-params/query-params.svelte.ts | 26 +++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 0ca337323b..a28995c309 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -289,6 +289,30 @@ describe('createQueryParameters', () => { expect(screen.getByText('a').textContent).toBe('a'); }); + it('preserves a throttled replacement across route teardown', async () => { + // Arrange + const view = render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; + window.history.replaceState(pageState, '', '/'); + beforeNavigation?.({ type: 'popstate' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + + // Act: leave the route, then recreate it by traversing Forward to the source entry. + view.unmount(); + window.history.replaceState(pageState, '', '/?filter=first'); + render(QueryParametersTestHarness); + await tick(); + + // Assert + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(window.location.search).toBe('?filter=second'); + expect(screen.getByText('second').textContent).toBe('second'); + }); + it('restores reactive state when shallow history is traversed', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 530507d66f..a6cf1b0f84 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -2,13 +2,15 @@ import { browser, building } from '$app/environment'; import { afterNavigate, beforeNavigate, pushState, replaceState } from '$app/navigation'; import { page } from '$app/state'; import { onDestroy } from 'svelte'; -import { SvelteURL } from 'svelte/reactivity'; +import { SvelteMap, SvelteURL } from 'svelte/reactivity'; import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameterState } from './types.js'; import { createQueryParameterProxy } from './proxy.js'; import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; +const pendingPushHistoryReplacements = new SvelteMap(); + export function createQueryParameters({ debounceMilliseconds = 200, defaults, @@ -34,6 +36,17 @@ export function createQueryParameters({ const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); + if (browser) { + const currentUrl = getCurrentUrl(); + const retainedReplacementUrl = pendingPushHistoryReplacements.get(currentUrl); + if (retainedReplacementUrl) { + pendingPushHistoryReplacements.delete(currentUrl); + replaceState(retainedReplacementUrl, page.state); + searchParams = createSearchParams(window.location.search); + Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); + } + } + const settlePushHistoryEntry = () => { isCoalescingPushHistoryEntry = false; coalescingStartUrl = undefined; @@ -127,7 +140,16 @@ export function createQueryParameters({ window.addEventListener('beforeunload', handleBeforeUnload); } - onDestroy(finalizePushHistoryEntry); + onDestroy(() => { + if (pendingReplacementUrl && coalescingEntryUrl && getCurrentUrl() !== coalescingEntryUrl) { + pendingPushHistoryReplacements.set(coalescingEntryUrl, pendingReplacementUrl); + pendingReplacementUrl = undefined; + settlePushHistoryEntry(); + return; + } + + finalizePushHistoryEntry(); + }); const commit = (result: ReturnType>) => { searchParams = result.searchParams; From a86a2bd0c2443d77d85ada645abc8ac9a2348beb Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 12:57:11 -0500 Subject: [PATCH 10/13] Key pending filters by history entry --- .../features/shared/query-params/README.md | 2 +- .../query-params/query-params.svelte.test.ts | 64 ++++++++++++----- .../query-params/query-params.svelte.ts | 69 ++++++++++++------- 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index 40ef6e9784..e00ffb7902 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, comparison is independent of query encoding and parameter order, and the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Immediate Back keeps a pending replacement associated with its source entry; Forward restores the latest value without rewriting the Back destination. With `history: 'replace'`, every update immediately replaces the current entry. +With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, comparison is independent of query encoding and parameter order, and the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Immediate Back keeps a pending replacement associated with its unique source entry; Forward restores the latest value after route teardown or reload without rewriting the Back destination or replaying it onto a later visit to the same URL. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index a28995c309..cfa43e5db3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -11,6 +11,7 @@ const navigation = vi.hoisted(() => ({ replaceState: vi.fn() })); const pageState = vi.hoisted(() => ({})); +const queryHistoryState = () => expect.objectContaining({ __exceptionlessQueryHistoryEntryId: expect.any(String) }); vi.mock('$app/environment', () => ({ browser: true, building: false })); vi.mock('$app/navigation', () => navigation); @@ -27,6 +28,7 @@ describe('createQueryParameters', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + sessionStorage.clear(); window.history.replaceState({}, '', '/'); navigation.pushState.mockImplementation((url: string | URL, state: App.PageState) => window.history.pushState(state, '', url)); navigation.replaceState.mockImplementation((url: string | URL, state: App.PageState) => window.history.replaceState(state, '', url)); @@ -47,7 +49,7 @@ describe('createQueryParameters', () => { // Assert expect(screen.getByText('second').textContent).toBe('second'); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=first'); @@ -55,7 +57,7 @@ describe('createQueryParameters', () => { expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); expect(window.location.search).toBe('?filter=second'); }); @@ -71,7 +73,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=first'); }); @@ -88,8 +90,8 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); - expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', queryHistoryState()); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); }); @@ -121,7 +123,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/events?filter=first#details', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/events?filter=first#details', queryHistoryState()); expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/events#details', pageState); expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.pathname).toBe('/events'); @@ -140,9 +142,9 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(3); - expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', queryHistoryState()); expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/', pageState); - expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?filter=second', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?filter=second', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); }); @@ -157,7 +159,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', queryHistoryState()); expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a+b', pageState); expect(navigation.replaceState).not.toHaveBeenCalled(); }); @@ -175,7 +177,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(3); - expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?project=p', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?project=p', queryHistoryState()); expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?project=p&filter=a', pageState); expect(navigation.replaceState).not.toHaveBeenCalled(); }); @@ -193,7 +195,7 @@ describe('createQueryParameters', () => { // Assert expect(beforeNavigation).toBeDefined(); expect(navigation.pushState).toHaveBeenCalledOnce(); - expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', pageState); + expect(navigation.pushState).toHaveBeenCalledWith('/?filter=first', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=first'); }); @@ -210,7 +212,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledOnce(); expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); expect(window.location.search).toBe('?filter=second'); }); @@ -261,7 +263,7 @@ describe('createQueryParameters', () => { // Assert: the source entry and reactive state restore the latest value. expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); expect(window.location.search).toBe('?filter=second'); expect(screen.getByText('second').textContent).toBe('second'); }); @@ -283,7 +285,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.pushState).toHaveBeenCalledTimes(2); - expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); expect(window.location.search).toBe('?filter=a'); expect(screen.getByText('a').textContent).toBe('a'); @@ -294,25 +296,53 @@ describe('createQueryParameters', () => { const view = render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const sourceEntryState = navigation.pushState.mock.calls[0]?.[1] as App.PageState; const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; window.history.replaceState(pageState, '', '/'); beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await tick(); + window.dispatchEvent(new Event('beforeunload')); + expect(sessionStorage).toHaveLength(1); - // Act: leave the route, then recreate it by traversing Forward to the source entry. + // Act: reload/leave the route, then recreate it by traversing Forward to the source entry. view.unmount(); - window.history.replaceState(pageState, '', '/?filter=first'); + window.history.replaceState(sourceEntryState, '', '/?filter=first'); render(QueryParametersTestHarness); await tick(); // Assert expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', pageState); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', sourceEntryState); expect(window.location.search).toBe('?filter=second'); expect(screen.getByText('second').textContent).toBe('second'); }); + it('does not restore a retained replacement after its Forward branch is discarded', async () => { + // Arrange + const view = render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; + window.history.replaceState(pageState, '', '/'); + beforeNavigation?.({ type: 'popstate' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + + // Act: link navigation discards Forward, then a later visit reuses the same URL. + beforeNavigation?.({ type: 'link' }); + view.unmount(); + window.history.replaceState(pageState, '', '/?filter=first'); + render(QueryParametersTestHarness); + await tick(); + + // Assert + expect(sessionStorage).toHaveLength(0); + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=first'); + expect(screen.getByText('first').textContent).toBe('first'); + }); + it('restores reactive state when shallow history is traversed', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index a6cf1b0f84..b91cc59a73 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -2,14 +2,17 @@ import { browser, building } from '$app/environment'; import { afterNavigate, beforeNavigate, pushState, replaceState } from '$app/navigation'; import { page } from '$app/state'; import { onDestroy } from 'svelte'; -import { SvelteMap, SvelteURL } from 'svelte/reactivity'; +import { SvelteURL } from 'svelte/reactivity'; import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameterState } from './types.js'; import { createQueryParameterProxy } from './proxy.js'; import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; -const pendingPushHistoryReplacements = new SvelteMap(); +const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; +const pendingReplacementStoragePrefix = 'exceptionless:query-history:'; + +type QueryHistoryPageState = App.PageState & { [queryHistoryEntryIdKey]?: string }; export function createQueryParameters({ debounceMilliseconds = 200, @@ -25,6 +28,7 @@ export function createQueryParameters({ let isCoalescingPushHistoryEntry = false; let coalescingStartUrl: string | undefined; let coalescingEntryUrl: string | undefined; + let coalescingEntryId: string | undefined; let pendingReplacementUrl: string | undefined; const normalizeUrl = (url: string) => { const value = new SvelteURL(url, window.location.origin); @@ -36,12 +40,25 @@ export function createQueryParameters({ const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); + const getCurrentHistoryEntryId = () => { + const pageStateEntryId = (page.state as QueryHistoryPageState)[queryHistoryEntryIdKey]; + if (pageStateEntryId) { + return pageStateEntryId; + } + + return (window.history.state as null | QueryHistoryPageState)?.[queryHistoryEntryIdKey]; + }; + + const createHistoryState = (entryId: string | undefined) => ({ ...page.state, ...(entryId ? { [queryHistoryEntryIdKey]: entryId } : {}) }) as App.PageState; + + const getPendingReplacementStorageKey = (entryId: string) => `${pendingReplacementStoragePrefix}${entryId}`; + if (browser) { - const currentUrl = getCurrentUrl(); - const retainedReplacementUrl = pendingPushHistoryReplacements.get(currentUrl); - if (retainedReplacementUrl) { - pendingPushHistoryReplacements.delete(currentUrl); - replaceState(retainedReplacementUrl, page.state); + const currentHistoryEntryId = getCurrentHistoryEntryId(); + const retainedReplacementUrl = currentHistoryEntryId ? sessionStorage.getItem(getPendingReplacementStorageKey(currentHistoryEntryId)) : undefined; + if (currentHistoryEntryId && retainedReplacementUrl) { + sessionStorage.removeItem(getPendingReplacementStorageKey(currentHistoryEntryId)); + replaceState(retainedReplacementUrl, createHistoryState(currentHistoryEntryId)); searchParams = createSearchParams(window.location.search); Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); } @@ -51,12 +68,21 @@ export function createQueryParameters({ isCoalescingPushHistoryEntry = false; coalescingStartUrl = undefined; coalescingEntryUrl = undefined; + coalescingEntryId = undefined; + }; + + const discardPendingReplacement = () => { + if (browser && coalescingEntryId) { + sessionStorage.removeItem(getPendingReplacementStorageKey(coalescingEntryId)); + } + + pendingReplacementUrl = undefined; }; const flushPendingReplacement = () => { if (pendingReplacementUrl) { - replaceState(pendingReplacementUrl, page.state); - pendingReplacementUrl = undefined; + replaceState(pendingReplacementUrl, createHistoryState(coalescingEntryId)); + discardPendingReplacement(); } }; @@ -71,7 +97,7 @@ export function createQueryParameters({ const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { - pendingReplacementUrl = undefined; + discardPendingReplacement(); return; } @@ -80,7 +106,7 @@ export function createQueryParameters({ // we left. Editing this destination discards that Forward entry, so // start a fresh burst here instead of mutating the retained source. schedulePushHistoryEntryFinalization.cancel(); - pendingReplacementUrl = undefined; + discardPendingReplacement(); settlePushHistoryEntry(); } @@ -90,19 +116,23 @@ export function createQueryParameters({ replaceState(url, page.state); } else if (!isCoalescingPushHistoryEntry) { coalescingStartUrl = getCurrentUrl(); - pushState(url, page.state); + coalescingEntryId = crypto.randomUUID(); + pushState(url, createHistoryState(coalescingEntryId)); coalescingEntryUrl = normalizeUrl(url); isCoalescingPushHistoryEntry = true; } else if (normalizeUrl(url) === coalescingStartUrl) { // Keep the transient state as a meaningful Back target instead of // replacing it with a duplicate of the entry behind it. schedulePushHistoryEntryFinalization.cancel(); - pendingReplacementUrl = undefined; + discardPendingReplacement(); pushState(url, page.state); settlePushHistoryEntry(); } else { // Avoid exhausting browser History API mutation quotas during sustained input. pendingReplacementUrl = url; + if (browser && coalescingEntryId) { + sessionStorage.setItem(getPendingReplacementStorageKey(coalescingEntryId), url); + } } if (history === 'push' && isCoalescingPushHistoryEntry) { @@ -124,7 +154,7 @@ export function createQueryParameters({ if (getCurrentUrl() === coalescingEntryUrl) { flushPendingReplacement(); } else { - pendingReplacementUrl = undefined; + discardPendingReplacement(); } settlePushHistoryEntry(); @@ -140,16 +170,7 @@ export function createQueryParameters({ window.addEventListener('beforeunload', handleBeforeUnload); } - onDestroy(() => { - if (pendingReplacementUrl && coalescingEntryUrl && getCurrentUrl() !== coalescingEntryUrl) { - pendingPushHistoryReplacements.set(coalescingEntryUrl, pendingReplacementUrl); - pendingReplacementUrl = undefined; - settlePushHistoryEntry(); - return; - } - - finalizePushHistoryEntry(); - }); + onDestroy(finalizePushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; From 6fe028ef04febd4358d6e98c68a1131df9097c74 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 13:00:47 -0500 Subject: [PATCH 11/13] Use current history state during traversal --- .../query-params/query-params.svelte.test.ts | 14 ++++++++----- .../query-params/query-params.svelte.ts | 20 +++++-------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index cfa43e5db3..e7138679ed 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -11,7 +11,8 @@ const navigation = vi.hoisted(() => ({ replaceState: vi.fn() })); const pageState = vi.hoisted(() => ({})); -const queryHistoryState = () => expect.objectContaining({ __exceptionlessQueryHistoryEntryId: expect.any(String) }); +const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; +const queryHistoryState = () => expect.objectContaining({ [queryHistoryEntryIdKey]: expect.any(String) }); vi.mock('$app/environment', () => ({ browser: true, building: false })); vi.mock('$app/navigation', () => navigation); @@ -28,6 +29,7 @@ describe('createQueryParameters', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + delete (pageState as Record)[queryHistoryEntryIdKey]; sessionStorage.clear(); window.history.replaceState({}, '', '/'); navigation.pushState.mockImplementation((url: string | URL, state: App.PageState) => window.history.pushState(state, '', url)); @@ -242,10 +244,12 @@ describe('createQueryParameters', () => { render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + const sourceEntryState = window.history.state as Record; + (pageState as Record)[queryHistoryEntryIdKey] = sourceEntryState[queryHistoryEntryIdKey]; const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; - // Act: traverse Back before the replacement settles. - window.history.replaceState(pageState, '', '/'); + // Act: traverse Back before the replacement settles while page.state is still stale. + window.history.replaceState({}, '', '/'); beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await tick(); @@ -256,9 +260,9 @@ describe('createQueryParameters', () => { expect(document.querySelector('output')?.textContent).toBe(''); // Act: traverse Forward to the source entry. - window.history.replaceState(pageState, '', '/?filter=first'); + window.history.replaceState(sourceEntryState, '', '/?filter=first'); beforeNavigation?.({ type: 'popstate' }); - window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + window.dispatchEvent(new PopStateEvent('popstate', { state: sourceEntryState })); await tick(); // Assert: the source entry and reactive state restore the latest value. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index b91cc59a73..6693d7a170 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -27,7 +27,6 @@ export function createQueryParameters({ // for an immediate Back action and can navigate a newly opened tab to about:blank. let isCoalescingPushHistoryEntry = false; let coalescingStartUrl: string | undefined; - let coalescingEntryUrl: string | undefined; let coalescingEntryId: string | undefined; let pendingReplacementUrl: string | undefined; const normalizeUrl = (url: string) => { @@ -40,14 +39,7 @@ export function createQueryParameters({ const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); - const getCurrentHistoryEntryId = () => { - const pageStateEntryId = (page.state as QueryHistoryPageState)[queryHistoryEntryIdKey]; - if (pageStateEntryId) { - return pageStateEntryId; - } - - return (window.history.state as null | QueryHistoryPageState)?.[queryHistoryEntryIdKey]; - }; + const getCurrentHistoryEntryId = () => (window.history.state as null | QueryHistoryPageState)?.[queryHistoryEntryIdKey]; const createHistoryState = (entryId: string | undefined) => ({ ...page.state, ...(entryId ? { [queryHistoryEntryIdKey]: entryId } : {}) }) as App.PageState; @@ -67,7 +59,6 @@ export function createQueryParameters({ const settlePushHistoryEntry = () => { isCoalescingPushHistoryEntry = false; coalescingStartUrl = undefined; - coalescingEntryUrl = undefined; coalescingEntryId = undefined; }; @@ -87,7 +78,7 @@ export function createQueryParameters({ }; const finalizePushHistoryEntry = () => { - if (!coalescingEntryUrl || getCurrentUrl() === coalescingEntryUrl) { + if (!coalescingEntryId || getCurrentHistoryEntryId() === coalescingEntryId) { flushPendingReplacement(); settlePushHistoryEntry(); } @@ -101,7 +92,7 @@ export function createQueryParameters({ return; } - if (history === 'push' && isCoalescingPushHistoryEntry && getCurrentUrl() !== coalescingEntryUrl) { + if (history === 'push' && isCoalescingPushHistoryEntry && getCurrentHistoryEntryId() !== coalescingEntryId) { // A popstate traversal may retain a pending replacement for the entry // we left. Editing this destination discards that Forward entry, so // start a fresh burst here instead of mutating the retained source. @@ -118,7 +109,6 @@ export function createQueryParameters({ coalescingStartUrl = getCurrentUrl(); coalescingEntryId = crypto.randomUUID(); pushState(url, createHistoryState(coalescingEntryId)); - coalescingEntryUrl = normalizeUrl(url); isCoalescingPushHistoryEntry = true; } else if (normalizeUrl(url) === coalescingStartUrl) { // Keep the transient state as a meaningful Back target instead of @@ -143,7 +133,7 @@ export function createQueryParameters({ const handleBeforeNavigate = ({ type }: { type: string }) => { schedulePushHistoryEntryFinalization.cancel(); if (type === 'popstate') { - if (pendingReplacementUrl && getCurrentUrl() === coalescingEntryUrl) { + if (pendingReplacementUrl && getCurrentHistoryEntryId() === coalescingEntryId) { flushPendingReplacement(); settlePushHistoryEntry(); } @@ -151,7 +141,7 @@ export function createQueryParameters({ return; } - if (getCurrentUrl() === coalescingEntryUrl) { + if (getCurrentHistoryEntryId() === coalescingEntryId) { flushPendingReplacement(); } else { discardPendingReplacement(); From c6c87ef430f59c902824f612f882ed7c8d85c407 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 13:09:58 -0500 Subject: [PATCH 12/13] Read wrapped SvelteKit history state --- .../query-params/query-params.svelte.test.ts | 24 +++++++++++-------- .../query-params/query-params.svelte.ts | 15 ++++++++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index e7138679ed..1132a9af22 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -12,7 +12,9 @@ const navigation = vi.hoisted(() => ({ })); const pageState = vi.hoisted(() => ({})); const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; +const svelteKitPageStateKey = 'sveltekit:states'; const queryHistoryState = () => expect.objectContaining({ [queryHistoryEntryIdKey]: expect.any(String) }); +const createSvelteKitHistoryState = (state: App.PageState) => ({ [svelteKitPageStateKey]: state }); vi.mock('$app/environment', () => ({ browser: true, building: false })); vi.mock('$app/navigation', () => navigation); @@ -31,9 +33,13 @@ describe('createQueryParameters', () => { vi.clearAllMocks(); delete (pageState as Record)[queryHistoryEntryIdKey]; sessionStorage.clear(); - window.history.replaceState({}, '', '/'); - navigation.pushState.mockImplementation((url: string | URL, state: App.PageState) => window.history.pushState(state, '', url)); - navigation.replaceState.mockImplementation((url: string | URL, state: App.PageState) => window.history.replaceState(state, '', url)); + window.history.replaceState(createSvelteKitHistoryState(pageState), '', '/'); + navigation.pushState.mockImplementation((url: string | URL, state: App.PageState) => + window.history.pushState(createSvelteKitHistoryState(state), '', url) + ); + navigation.replaceState.mockImplementation((url: string | URL, state: App.PageState) => + window.history.replaceState(createSvelteKitHistoryState(state), '', url) + ); }); afterEach(() => { @@ -244,13 +250,12 @@ describe('createQueryParameters', () => { render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); await fireEvent.click(screen.getByRole('button', { name: 'Second' })); - const sourceEntryState = window.history.state as Record; - (pageState as Record)[queryHistoryEntryIdKey] = sourceEntryState[queryHistoryEntryIdKey]; - const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; + const sourceEntryState = window.history.state; + const sourcePageState = navigation.pushState.mock.calls[0]?.[1] as Record; + (pageState as Record)[queryHistoryEntryIdKey] = sourcePageState[queryHistoryEntryIdKey]; // Act: traverse Back before the replacement settles while page.state is still stale. window.history.replaceState({}, '', '/'); - beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await tick(); @@ -261,7 +266,6 @@ describe('createQueryParameters', () => { // Act: traverse Forward to the source entry. window.history.replaceState(sourceEntryState, '', '/?filter=first'); - beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: sourceEntryState })); await tick(); @@ -300,7 +304,7 @@ describe('createQueryParameters', () => { const view = render(QueryParametersTestHarness); await fireEvent.click(screen.getByRole('button', { name: 'First' })); await fireEvent.click(screen.getByRole('button', { name: 'Second' })); - const sourceEntryState = navigation.pushState.mock.calls[0]?.[1] as App.PageState; + const sourceEntryState = window.history.state; const beforeNavigation = navigation.beforeNavigate.mock.calls[0]?.[0] as ((navigation: { type: string }) => void) | undefined; window.history.replaceState(pageState, '', '/'); beforeNavigation?.({ type: 'popstate' }); @@ -317,7 +321,7 @@ describe('createQueryParameters', () => { // Assert expect(navigation.replaceState).toHaveBeenCalledOnce(); - expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', sourceEntryState); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); expect(window.location.search).toBe('?filter=second'); expect(screen.getByText('second').textContent).toBe('second'); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 6693d7a170..0ba344b1d9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -11,8 +11,10 @@ import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; const pendingReplacementStoragePrefix = 'exceptionless:query-history:'; +const svelteKitPageStateKey = 'sveltekit:states'; type QueryHistoryPageState = App.PageState & { [queryHistoryEntryIdKey]?: string }; +type SvelteKitHistoryState = { [svelteKitPageStateKey]?: QueryHistoryPageState }; export function createQueryParameters({ debounceMilliseconds = 200, @@ -39,7 +41,7 @@ export function createQueryParameters({ const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); - const getCurrentHistoryEntryId = () => (window.history.state as null | QueryHistoryPageState)?.[queryHistoryEntryIdKey]; + const getCurrentHistoryEntryId = () => (window.history.state as null | SvelteKitHistoryState)?.[svelteKitPageStateKey]?.[queryHistoryEntryIdKey]; const createHistoryState = (entryId: string | undefined) => ({ ...page.state, ...(entryId ? { [queryHistoryEntryIdKey]: entryId } : {}) }) as App.PageState; @@ -187,7 +189,16 @@ export function createQueryParameters({ Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); }; - const synchronizeStateFromLocation = () => synchronizeState(window.location.search); + const synchronizeStateFromLocation = () => { + schedulePushHistoryEntryFinalization.cancel(); + if (pendingReplacementUrl && getCurrentHistoryEntryId() === coalescingEntryId) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } + + synchronizeState(window.location.search); + }; + if (browser) { window.addEventListener('popstate', synchronizeStateFromLocation); } From c7131d6483924edeccda7147b8ba9398f201d853 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 12 Aug 2026 13:18:02 -0500 Subject: [PATCH 13/13] Settle filter history after traversal --- .../features/shared/query-params/README.md | 2 +- .../query-params/query-params.svelte.test.ts | 49 +++++++++++++++++++ .../query-params/query-params.svelte.ts | 35 ++++++++++--- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md index e00ffb7902..afbc8d5e80 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/README.md @@ -28,6 +28,6 @@ queryParams.update({ filter: 'status:open', page: 1 }); Updates may also assign a single schema property directly. Use `update()` when several parameters form one logical state change so they produce one reactive update and one URL synchronization. -With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, comparison is independent of query encoding and parameter order, and the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Immediate Back keeps a pending replacement associated with its unique source entry; Forward restores the latest value after route teardown or reload without rewriting the Back destination or replaying it onto a later visit to the same URL. With `history: 'replace'`, every update immediately replaces the current entry. +With `history: 'push'`, the first update immediately pushes a durable Back-button entry. Rapid follow-up updates are coalesced into one replacement after `debounceMilliseconds`, avoiding browser History API mutation limits, and a pending replacement is flushed before reload, link navigation, or teardown. If a burst returns to its starting URL, comparison is independent of query encoding and parameter order, and the return is pushed so the transient state remains a meaningful Back target rather than creating adjacent duplicate URLs. Immediate Back keeps a pending replacement associated with its unique source entry; Forward restores the latest value after route teardown or reload without rewriting the Back destination or replaying it onto a later visit to the same URL. Session-storage persistence is best-effort; if browser policy denies storage, current-page coalescing continues without blocking hydration. With `history: 'replace'`, every update immediately replaces the current entry. The implementation was originally derived from [beynar/kit-query-params](https://github.com/beynar/kit-query-params) version 0.0.26 at commit `7c90edf7`. The original copyright and MIT license are retained in [LICENSE](./LICENSE). This module is maintained as first-party Exceptionless code and does not track the upstream package API. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts index 1132a9af22..1e13003332 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.test.ts @@ -43,6 +43,7 @@ describe('createQueryParameters', () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -276,6 +277,54 @@ describe('createQueryParameters', () => { expect(screen.getByText('second').textContent).toBe('second'); }); + it('settles the source entry after Back and Forward without a pending replacement', async () => { + // Arrange + render(QueryParametersTestHarness); + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + const sourceEntryState = window.history.state; + + // Act: traverse away from and back to the source before its timer settles. + window.history.replaceState({}, '', '/'); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + window.history.replaceState(sourceEntryState, '', '/?filter=first'); + window.dispatchEvent(new PopStateEvent('popstate', { state: sourceEntryState })); + await tick(); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + // Assert: the later edit starts a new burst instead of replacing the old entry. + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', queryHistoryState()); + expect(navigation.replaceState).not.toHaveBeenCalled(); + }); + + it('falls back to in-memory coalescing when session storage is unavailable', async () => { + // Arrange + window.history.replaceState(createSvelteKitHistoryState({ [queryHistoryEntryIdKey]: 'source-entry' }), '', '/'); + const storageError = new DOMException('Storage is unavailable', 'SecurityError'); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw storageError; + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw storageError; + }); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw storageError; + }); + render(QueryParametersTestHarness); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'First' })); + await fireEvent.click(screen.getByRole('button', { name: 'Second' })); + await vi.advanceTimersByTimeAsync(200); + + // Assert + expect(navigation.pushState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); + expect(screen.getByText('second').textContent).toBe('second'); + }); + it('starts a new burst when the Back destination is edited', async () => { // Arrange render(QueryParametersTestHarness); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts index 0ba344b1d9..91e0d4928a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.svelte.ts @@ -46,12 +46,35 @@ export function createQueryParameters({ const createHistoryState = (entryId: string | undefined) => ({ ...page.state, ...(entryId ? { [queryHistoryEntryIdKey]: entryId } : {}) }) as App.PageState; const getPendingReplacementStorageKey = (entryId: string) => `${pendingReplacementStoragePrefix}${entryId}`; + const getStoredPendingReplacement = (entryId: string) => { + try { + return sessionStorage.getItem(getPendingReplacementStorageKey(entryId)) ?? undefined; + } catch { + return undefined; + } + }; + + const removeStoredPendingReplacement = (entryId: string) => { + try { + sessionStorage.removeItem(getPendingReplacementStorageKey(entryId)); + } catch { + // Storage can be denied by browser policy; in-memory coalescing still works. + } + }; + + const storePendingReplacement = (entryId: string, url: string) => { + try { + sessionStorage.setItem(getPendingReplacementStorageKey(entryId), url); + } catch { + // Storage can be denied by browser policy; in-memory coalescing still works. + } + }; if (browser) { const currentHistoryEntryId = getCurrentHistoryEntryId(); - const retainedReplacementUrl = currentHistoryEntryId ? sessionStorage.getItem(getPendingReplacementStorageKey(currentHistoryEntryId)) : undefined; + const retainedReplacementUrl = currentHistoryEntryId ? getStoredPendingReplacement(currentHistoryEntryId) : undefined; if (currentHistoryEntryId && retainedReplacementUrl) { - sessionStorage.removeItem(getPendingReplacementStorageKey(currentHistoryEntryId)); + removeStoredPendingReplacement(currentHistoryEntryId); replaceState(retainedReplacementUrl, createHistoryState(currentHistoryEntryId)); searchParams = createSearchParams(window.location.search); Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); @@ -66,7 +89,7 @@ export function createQueryParameters({ const discardPendingReplacement = () => { if (browser && coalescingEntryId) { - sessionStorage.removeItem(getPendingReplacementStorageKey(coalescingEntryId)); + removeStoredPendingReplacement(coalescingEntryId); } pendingReplacementUrl = undefined; @@ -123,7 +146,7 @@ export function createQueryParameters({ // Avoid exhausting browser History API mutation quotas during sustained input. pendingReplacementUrl = url; if (browser && coalescingEntryId) { - sessionStorage.setItem(getPendingReplacementStorageKey(coalescingEntryId), url); + storePendingReplacement(coalescingEntryId, url); } } @@ -135,7 +158,7 @@ export function createQueryParameters({ const handleBeforeNavigate = ({ type }: { type: string }) => { schedulePushHistoryEntryFinalization.cancel(); if (type === 'popstate') { - if (pendingReplacementUrl && getCurrentHistoryEntryId() === coalescingEntryId) { + if (coalescingEntryId && getCurrentHistoryEntryId() === coalescingEntryId) { flushPendingReplacement(); settlePushHistoryEntry(); } @@ -191,7 +214,7 @@ export function createQueryParameters({ const synchronizeStateFromLocation = () => { schedulePushHistoryEntryFinalization.cancel(); - if (pendingReplacementUrl && getCurrentHistoryEntryId() === coalescingEntryId) { + if (coalescingEntryId && getCurrentHistoryEntryId() === coalescingEntryId) { flushPendingReplacement(); settlePushHistoryEntry(); }