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..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 @@ -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. +- settlement of 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. +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 f1f9da01ed..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 @@ -11,13 +11,19 @@ const navigation = vi.hoisted(() => ({ replaceState: vi.fn() })); 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); vi.mock('$app/state', () => ({ page: { state: pageState, - url: new URL('http://localhost/') + get url() { + return new URL(window.location.href); + } } })); @@ -25,32 +31,167 @@ describe('createQueryParameters', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); - 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)); + delete (pageState as Record)[queryHistoryEntryIdKey]; + sessionStorage.clear(); + 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(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); - it('writes only the latest debounced update with shallow routing', async () => { + it('creates a durable history entry immediately while throttling rapid replacements', 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', queryHistoryState()); + 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', queryHistoryState()); + 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.pushState).toHaveBeenCalledWith('/?filter=first', queryHistoryState()); expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=first'); }); - it('flushes a pending update before full navigation', async () => { + 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' })); + await vi.advanceTimersByTimeAsync(200); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(2); + expect(navigation.pushState).toHaveBeenNthCalledWith(1, '/?filter=first', queryHistoryState()); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second', queryHistoryState()); + expect(navigation.replaceState).not.toHaveBeenCalled(); + }); + + 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('keeps a meaningful Back target when a burst returns to its starting URL', 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' })); + await vi.advanceTimersByTimeAsync(200); + + // Assert + expect(navigation.pushState).toHaveBeenCalledTimes(2); + 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'); + 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', queryHistoryState()); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/', pageState); + expect(navigation.pushState).toHaveBeenNthCalledWith(3, '/?filter=second', queryHistoryState()); + 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', queryHistoryState()); + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=a+b', pageState); + 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', queryHistoryState()); + 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); await fireEvent.click(screen.getByRole('button', { name: 'First' })); @@ -63,18 +204,35 @@ 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'); }); - it('discards a pending update when popstate has already changed the location', async () => { + 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', queryHistoryState()); + expect(window.location.search).toBe('?filter=second'); + }); + + it('restores popstate without scheduling another history write', async () => { // Arrange 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 + window.history.replaceState(pageState, '', '?filter=previous'); beforeNavigation?.({ type: 'popstate' }); window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); await vi.advanceTimersByTimeAsync(200); @@ -82,11 +240,166 @@ 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'); }); + 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 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({}, '', '/'); + 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(sourceEntryState, '', '/?filter=first'); + window.dispatchEvent(new PopStateEvent('popstate', { state: sourceEntryState })); + await tick(); + + // Assert: the source entry and reactive state restore the latest value. + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); + expect(window.location.search).toBe('?filter=second'); + 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); + 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', queryHistoryState()); + expect(navigation.replaceState).not.toHaveBeenCalled(); + expect(window.location.search).toBe('?filter=a'); + 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 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' }); + window.dispatchEvent(new PopStateEvent('popstate', { state: pageState })); + await tick(); + window.dispatchEvent(new Event('beforeunload')); + expect(sessionStorage).toHaveLength(1); + + // Act: reload/leave the route, then recreate it by traversing Forward to the source entry. + view.unmount(); + window.history.replaceState(sourceEntryState, '', '/?filter=first'); + render(QueryParametersTestHarness); + await tick(); + + // Assert + expect(navigation.replaceState).toHaveBeenCalledOnce(); + expect(navigation.replaceState).toHaveBeenCalledWith('/?filter=second', queryHistoryState()); + 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); @@ -102,6 +415,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..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 @@ -2,12 +2,20 @@ 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'; import { createQueryParameterProxy } from './proxy.js'; import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; +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, defaults, @@ -16,29 +24,168 @@ export function createQueryParameters({ }: CreateQueryParametersOptions) { let searchParams = createSearchParams(building ? '' : page.url.search); const current = $state>(parseQueryParameters(searchParams, schema, defaults)); + // 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; + let coalescingStartUrl: string | undefined; + let coalescingEntryId: string | undefined; + let pendingReplacementUrl: string | undefined; + 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 getCurrentHistoryEntryId = () => (window.history.state as null | SvelteKitHistoryState)?.[svelteKitPageStateKey]?.[queryHistoryEntryIdKey]; + + 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 ? getStoredPendingReplacement(currentHistoryEntryId) : undefined; + if (currentHistoryEntryId && retainedReplacementUrl) { + removeStoredPendingReplacement(currentHistoryEntryId); + replaceState(retainedReplacementUrl, createHistoryState(currentHistoryEntryId)); + searchParams = createSearchParams(window.location.search); + Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); + } + } + + const settlePushHistoryEntry = () => { + isCoalescingPushHistoryEntry = false; + coalescingStartUrl = undefined; + coalescingEntryId = undefined; + }; + + const discardPendingReplacement = () => { + if (browser && coalescingEntryId) { + removeStoredPendingReplacement(coalescingEntryId); + } + + pendingReplacementUrl = undefined; + }; + + const flushPendingReplacement = () => { + if (pendingReplacementUrl) { + replaceState(pendingReplacementUrl, createHistoryState(coalescingEntryId)); + discardPendingReplacement(); + } + }; + + const finalizePushHistoryEntry = () => { + if (!coalescingEntryId || getCurrentHistoryEntryId() === coalescingEntryId) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } + }; + + const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); const synchronizeURL = () => { if (searchParamsEqual(searchParams, window.location.search)) { + discardPendingReplacement(); return; } + 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. + schedulePushHistoryEntryFinalization.cancel(); + discardPendingReplacement(); + settlePushHistoryEntry(); + } + const query = searchParams.toString(); - const url = `?${query}${window.location.hash}`; + const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; if (history === 'replace') { replaceState(url, page.state); - } else { + } else if (!isCoalescingPushHistoryEntry) { + coalescingStartUrl = getCurrentUrl(); + coalescingEntryId = crypto.randomUUID(); + pushState(url, createHistoryState(coalescingEntryId)); + 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(); + discardPendingReplacement(); pushState(url, page.state); + settlePushHistoryEntry(); + } else { + // Avoid exhausting browser History API mutation quotas during sustained input. + pendingReplacementUrl = url; + if (browser && coalescingEntryId) { + storePendingReplacement(coalescingEntryId, url); + } + } + + if (history === 'push' && isCoalescingPushHistoryEntry) { + schedulePushHistoryEntryFinalization(); } }; - const scheduleSynchronization = createDebouncedFunction(synchronizeURL, debounceMilliseconds); - beforeNavigate(({ type }) => { - scheduleSynchronization.cancel(); - if (type !== 'popstate') { - synchronizeURL(); + const handleBeforeNavigate = ({ type }: { type: string }) => { + schedulePushHistoryEntryFinalization.cancel(); + if (type === 'popstate') { + if (coalescingEntryId && getCurrentHistoryEntryId() === coalescingEntryId) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } + + return; } - }); - onDestroy(scheduleSynchronization.cancel); + + if (getCurrentHistoryEntryId() === coalescingEntryId) { + flushPendingReplacement(); + } else { + discardPendingReplacement(); + } + + settlePushHistoryEntry(); + }; + + const handleBeforeUnload = () => { + schedulePushHistoryEntryFinalization.cancel(); + finalizePushHistoryEntry(); + }; + + beforeNavigate(handleBeforeNavigate); + if (browser) { + window.addEventListener('beforeunload', handleBeforeUnload); + } + + onDestroy(finalizePushHistoryEntry); const commit = (result: ReturnType>) => { searchParams = result.searchParams; @@ -47,7 +194,7 @@ export function createQueryParameters({ } if (result.urlChanged) { - scheduleSynchronization(); + synchronizeURL(); } }; @@ -65,7 +212,16 @@ export function createQueryParameters({ Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); }; - const synchronizeStateFromLocation = () => synchronizeState(window.location.search); + const synchronizeStateFromLocation = () => { + schedulePushHistoryEntryFinalization.cancel(); + if (coalescingEntryId && getCurrentHistoryEntryId() === coalescingEntryId) { + flushPendingReplacement(); + settlePushHistoryEntry(); + } + + synchronizeState(window.location.search); + }; + if (browser) { window.addEventListener('popstate', synchronizeStateFromLocation); } @@ -73,12 +229,13 @@ export function createQueryParameters({ onDestroy(() => { if (browser) { window.removeEventListener('popstate', synchronizeStateFromLocation); + 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 8fa9418fcd..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 @@ -1,13 +1,21 @@ + + + {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; });