From c7030e39b2bd14d2856eee709e9712f4a540cc47 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 14 Oct 2026 08:16:54 -0500 Subject: [PATCH 1/6] Add list query parameters and write onto the live URL Query parameter schemas accept list types (`'string[]'`, `'number[]'`, `'[]'`) encoded as repeated keys. Lists drop invalid and duplicate items, compare item by item, and are frozen so an in-place mutation cannot change state without updating the URL. An emptied list with a non-empty default writes an empty marker so a reload keeps it empty. Each instance now writes only its own keys onto the live URL, so several instances on one page no longer overwrite each other, and a new instance reads the visible URL rather than `page.url`, which shallow history writes do not update. Co-Authored-By: Claude Opus 5.5 --- .../features/shared/query-params/README.md | 8 +- .../features/shared/query-params/coerce.ts | 58 ++++++++++--- .../query-params/query-params.svelte.test.ts | 37 ++++++++- .../query-params/query-params.svelte.ts | 71 +++++++++++++--- .../query-params.test-harness.svelte | 24 +++--- .../shared/query-params/query-params.test.ts | 71 +++++++++++++++- .../shared/query-params/query-params.ts | 81 +++++++++++++++---- .../lib/features/shared/query-params/types.ts | 36 ++++++--- 8 files changed, 323 insertions(+), 63 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 ec31b69c64..67a00b16ea 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 @@ -2,9 +2,9 @@ Exceptionless's shared Svelte query-parameter state module. It is intentionally tailored to the application's current needs: -- top-level string, number, boolean, date, and enum parameters; +- top-level string, number, boolean, date, and enum parameters, and lists of each; - typed property access and atomic multi-parameter updates; -- preservation of unrelated URL parameters; +- preservation of unrelated URL parameters, including those written by other instances on the same page; - immediate, reload-safe URL synchronization; - coalescing of rapid push-history updates into one Back-button entry; - synchronization with browser navigation; @@ -26,6 +26,10 @@ const queryParams = createQueryParameters({ queryParams.update({ filter: 'status:open', page: 1 }); ``` +Append `[]` to a type for a list parameter, such as `'string[]'`, `'number[]'`, or `'[]'`. Lists use repeated keys (`status=open&status=fixed`), drop invalid and duplicate items when parsed, and are never null. List values are frozen; assign a new array instead of mutating one, because only assignment updates the URL. An emptied list removes its key, unless the list has a non-empty default; then it writes an empty value (`status=`) so a reload keeps the list empty instead of restoring the default. + +Each instance owns only its schema's keys. Writes apply those keys onto the live URL, so several instances on one page do not overwrite each other's parameters. + 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. When the current entry is a transient detail-sheet entry, the update replaces that entry instead so the prior list remains the direct Back target. 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. diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/coerce.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/coerce.ts index 6845b7ed6d..2a961df0ab 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/coerce.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/coerce.ts @@ -1,10 +1,50 @@ -import type { QueryParameterType, QueryParameterTypeOutput } from './types.js'; +import type { QueryParameterScalarType, QueryParameterScalarValue, QueryParameterType, QueryParameterTypeOutput } from './types.js'; + +const emptyList: readonly never[] = Object.freeze([]); export function coerceQueryParameter( parameterType: T, value: unknown, - fallback: QueryParameterTypeOutput = null as QueryParameterTypeOutput + fallback?: QueryParameterTypeOutput ): QueryParameterTypeOutput { + const itemType = getListItemType(parameterType); + if (itemType) { + return coerceQueryParameterList(itemType, value, fallback as readonly QueryParameterScalarValue[] | undefined) as QueryParameterTypeOutput; + } + + return coerceScalar(parameterType as QueryParameterScalarType, value, (fallback ?? null) as QueryParameterScalarValue) as QueryParameterTypeOutput; +} + +/** Returns the item type of a list parameter such as `'number[]'`, or `undefined` for scalar parameters. */ +export function getListItemType(parameterType: QueryParameterType): QueryParameterScalarType | undefined { + return parameterType.endsWith('[]') ? (parameterType.slice(0, -2) as QueryParameterScalarType) : undefined; +} + +function coerceQueryParameterList( + itemType: QueryParameterScalarType, + value: unknown, + fallback: readonly QueryParameterScalarValue[] = emptyList +): readonly QueryParameterScalarValue[] { + if (value === undefined || value === null) { + return fallback; + } + + const items: QueryParameterScalarValue[] = []; + const seen = new Set(); + for (const item of Array.isArray(value) ? value : [value]) { + // Empty strings mark an explicitly empty list in the URL; they are never list items. + const coerced = item === '' ? null : coerceScalar(itemType, item, null); + const identity = coerced instanceof Date ? coerced.getTime() : coerced; + if (coerced !== null && !seen.has(identity)) { + seen.add(identity); + items.push(coerced); + } + } + + return items.length > 0 ? Object.freeze(items) : emptyList; +} + +function coerceScalar(parameterType: QueryParameterScalarType, value: unknown, fallback: QueryParameterScalarValue): QueryParameterScalarValue { if (value === undefined || value === null || value === 'null' || (value === '' && parameterType !== 'string')) { return fallback; } @@ -12,15 +52,15 @@ export function coerceQueryParameter( switch (parameterType) { case 'boolean': { if (typeof value === 'boolean') { - return value as QueryParameterTypeOutput; + return value; } if (value === 1 || value === '1' || (typeof value === 'string' && value.toLowerCase() === 'true')) { - return true as QueryParameterTypeOutput; + return true; } if (value === 0 || value === '0' || (typeof value === 'string' && value.toLowerCase() === 'false')) { - return false as QueryParameterTypeOutput; + return false; } return fallback; @@ -28,20 +68,20 @@ export function coerceQueryParameter( case 'date': { const date = value instanceof Date ? value : new Date(value === '0' ? 0 : String(value)); - return (Number.isNaN(date.getTime()) ? fallback : date) as QueryParameterTypeOutput; + return Number.isNaN(date.getTime()) ? fallback : date; } case 'number': { const number = typeof value === 'number' ? value : Number(value); - return (Number.isFinite(number) ? number : fallback) as QueryParameterTypeOutput; + return Number.isFinite(number) ? number : fallback; } case 'string': { - return String(value) as QueryParameterTypeOutput; + return String(value); } default: { - return (validateEnum(parameterType, value) ? value : fallback) as QueryParameterTypeOutput; + return validateEnum(parameterType, value) ? value : fallback; } } } 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 9bdb94bb4e..04955643c9 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 @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/svelte'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; import { tick } from 'svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,6 +11,7 @@ const navigation = vi.hoisted(() => ({ replaceState: vi.fn() })); const pageState = vi.hoisted(() => ({})); +const pageUrl = vi.hoisted(() => ({ stale: undefined as undefined | URL })); const detailSheetHistoryStateKey = '__exceptionlessDetailSheet'; const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; const svelteKitPageStateKey = 'sveltekit:states'; @@ -23,7 +24,7 @@ vi.mock('$app/state', () => ({ page: { state: pageState, get url() { - return new URL(window.location.href); + return pageUrl.stale ?? new URL(window.location.href); } } })); @@ -34,6 +35,7 @@ describe('createQueryParameters', () => { vi.clearAllMocks(); delete (pageState as Record)[detailSheetHistoryStateKey]; delete (pageState as Record)[queryHistoryEntryIdKey]; + pageUrl.stale = undefined; sessionStorage.clear(); window.history.replaceState(createSvelteKitHistoryState(pageState), '', '/'); navigation.pushState.mockImplementation((url: string | URL, state: App.PageState) => @@ -444,6 +446,37 @@ describe('createQueryParameters', () => { expect(screen.getByText('first').textContent).toBe('first'); }); + it('reads the visible URL when created after a shallow write left page.url stale', () => { + // Arrange + window.history.replaceState(createSvelteKitHistoryState(pageState), '', '/?filter=visible'); + pageUrl.stale = new URL('/?filter=stale', window.location.origin); + + // Act + render(QueryParametersTestHarness); + + // Assert + expect(screen.getByText('visible').textContent).toBe('visible'); + }); + + it('applies each instance update onto the live URL without overwriting other instances', async () => { + // Arrange + const filters = within(render(QueryParametersTestHarness).container); + const projects = within(render(QueryParametersTestHarness, { history: 'replace', parameter: 'project' }).container); + await fireEvent.click(filters.getByRole('button', { name: 'First' })); + await vi.advanceTimersByTimeAsync(200); + + // Act + await fireEvent.click(projects.getByRole('button', { name: 'Alpha' })); + await fireEvent.click(filters.getByRole('button', { name: 'Second' })); + await fireEvent.click(projects.getByRole('button', { name: 'Clear' })); + + // Assert + expect(navigation.pushState).toHaveBeenNthCalledWith(2, '/?filter=second&project=a', queryHistoryState()); + expect(window.location.search).toBe('?filter=second'); + expect(filters.getByRole('status').textContent).toBe('second'); + expect(projects.getByRole('status').textContent).toBe(''); + }); + 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 4ba7642e57..3f44d22976 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,7 +8,15 @@ import type { CreateQueryParametersOptions, QueryParameterSchema, QueryParameter import { hasDetailSheetHistoryEntry, withoutDetailSheetHistoryEntry } from '../history-state.js'; import { createQueryParameterProxy } from './proxy.js'; -import { applyQueryParameterUpdates, createDebouncedFunction, createSearchParams, parseQueryParameters, searchParamsEqual } from './query-params.js'; +import { + applyQueryParameterUpdates, + createDebouncedFunction, + createSearchParams, + mergeQueryParameters, + parseQueryParameters, + queryParameterValuesEqual, + searchParamsEqual +} from './query-params.js'; const queryHistoryEntryIdKey = '__exceptionlessQueryHistoryEntryId'; const pendingReplacementStoragePrefix = 'exceptionless:query-history:'; @@ -23,8 +31,9 @@ export function createQueryParameters({ history = 'replace', schema }: CreateQueryParametersOptions) { - let searchParams = createSearchParams(building ? '' : page.url.search); - const current = $state>(parseQueryParameters(searchParams, schema, defaults)); + // `page.url` is not updated by shallow history writes, so it can be older than the visible URL. + let searchParams = createSearchParams(browser ? window.location.search : building ? '' : page.url.search); + const current = createQueryParameterState(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. @@ -42,6 +51,17 @@ export function createQueryParameters({ const getCurrentUrl = () => normalizeUrl(`${window.location.pathname}${window.location.search}${window.location.hash}`); + // Other query-parameter instances and unrelated code can write the URL between this + // instance's updates, so writes apply this instance's keys onto the live URL. + const createLocationSearchParams = (ownSearchParams: URLSearchParams) => mergeQueryParameters(window.location.search, ownSearchParams, schema); + + const createLocationUrl = (ownSearchParams: URLSearchParams) => { + const query = createLocationSearchParams(ownSearchParams).toString(); + return `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + }; + + const rebaseUrl = (url: string) => createLocationUrl(new SvelteURL(url, window.location.origin).searchParams); + const getCurrentHistoryEntryId = () => (window.history.state as null | SvelteKitHistoryState)?.[svelteKitPageStateKey]?.[queryHistoryEntryIdKey]; const createHistoryState = (entryId: string | undefined, state: App.PageState = page.state) => @@ -84,9 +104,9 @@ export function createQueryParameters({ const retainedReplacementUrl = currentHistoryEntryId ? getStoredPendingReplacement(currentHistoryEntryId) : undefined; if (currentHistoryEntryId && retainedReplacementUrl) { removeStoredPendingReplacement(currentHistoryEntryId); - replaceState(retainedReplacementUrl, createHistoryState(currentHistoryEntryId)); + replaceState(rebaseUrl(retainedReplacementUrl), createHistoryState(currentHistoryEntryId)); searchParams = createSearchParams(window.location.search); - Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); + assignChangedValues(parseQueryParameters(searchParams, schema, defaults)); } } @@ -106,7 +126,7 @@ export function createQueryParameters({ const flushPendingReplacement = () => { if (pendingReplacementUrl) { - replaceState(pendingReplacementUrl, createHistoryState(coalescingEntryId)); + replaceState(rebaseUrl(pendingReplacementUrl), createHistoryState(coalescingEntryId)); discardPendingReplacement(); } }; @@ -121,7 +141,7 @@ export function createQueryParameters({ const schedulePushHistoryEntryFinalization = createDebouncedFunction(finalizePushHistoryEntry, debounceMilliseconds); const synchronizeURL = (updateHistory = history) => { - if (searchParamsEqual(searchParams, window.location.search)) { + if (searchParamsEqual(createLocationSearchParams(searchParams), window.location.search)) { discardPendingReplacement(); return; } @@ -139,8 +159,7 @@ export function createQueryParameters({ settlePushHistoryEntry(); } - const query = searchParams.toString(); - const url = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + const url = createLocationUrl(searchParams); if (updateHistory === 'replace') { replaceState(url, page.state); } else if (!isCoalescingPushHistoryEntry) { @@ -211,7 +230,7 @@ export function createQueryParameters({ const commit = (result: ReturnType>, options?: QueryParameterUpdateOptions) => { searchParams = result.searchParams; if (result.stateChanged) { - Object.assign(current, result.state); + assignChangedValues(result.state); } if (result.urlChanged) { @@ -220,7 +239,7 @@ export function createQueryParameters({ }; const update = (values: Parameters>[2], options?: QueryParameterUpdateOptions) => { - commit(applyQueryParameterUpdates(current, searchParams, values, schema), options); + commit(applyQueryParameterUpdates(current, searchParams, values, schema, defaults), options); }; const synchronizeState = (search: string) => { @@ -230,7 +249,7 @@ export function createQueryParameters({ } searchParams = nextSearchParams; - Object.assign(current, parseQueryParameters(searchParams, schema, defaults)); + assignChangedValues(parseQueryParameters(searchParams, schema, defaults)); }; const synchronizeStateFromLocation = () => { @@ -263,4 +282,32 @@ export function createQueryParameters({ return createQueryParameterProxy(current, schema, { update }); + + function assignChangedValues(next: QueryParameterState) { + for (const key of Object.keys(schema) as (keyof T)[]) { + if (!queryParameterValuesEqual(current[key], next[key])) { + current[key] = next[key]; + } + } + } +} + +/** + * Backs each parameter with its own raw signal: readers stay fine-grained, and frozen + * list values are stored as-is instead of being wrapped in a deep state proxy. + */ +function createQueryParameterState(initial: QueryParameterState): QueryParameterState { + const state = {} as QueryParameterState; + for (const key of Object.keys(initial) as (keyof T)[]) { + let value = $state.raw(initial[key]); + Object.defineProperty(state, key, { + enumerable: true, + get: () => value, + set: (next: QueryParameterState[keyof T]) => { + value = next; + } + }); + } + + return state; } 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 2b9c7955cc..823d640a67 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 @@ -5,30 +5,34 @@ import { createQueryParameters } from './query-params.svelte'; - let { history = 'push' }: { history?: CreateQueryParametersOptions<{ filter: 'string' }>['history'] } = $props(); + type Schema = { filter: 'string' }; + + let { history = 'push', parameter = 'filter' }: { history?: CreateQueryParametersOptions['history']; parameter?: 'filter' | 'project' } = $props(); + // Each harness owns one key so several can share a page, as independent components do. + const key = untrack(() => parameter) as keyof Schema; const queryParameters = createQueryParameters({ debounceMilliseconds: 200, history: untrack(() => history), schema: { - filter: 'string' - } + [key]: 'string' + } as Schema }); - - - - - + + + + + -{queryParameters.filter} +{queryParameters[key]} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test.ts index 08f5cc5bb7..1e2774b6bb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.test.ts @@ -1,9 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { QueryParameterInput, QueryParameterSchema } from './types'; import { createQueryParameterProxy } from './proxy'; -import { applyQueryParameterUpdates, createDebouncedFunction, parseQueryParameters } from './query-params'; +import { applyQueryParameterUpdates, createDebouncedFunction, mergeQueryParameters, parseQueryParameters } from './query-params'; afterEach(() => { vi.useRealTimers(); @@ -91,6 +91,73 @@ describe('query parameter updates', () => { }); }); +describe('list query parameters', () => { + const schema = { dates: 'date[]', pages: 'number[]', status: '[]', tags: 'string[]' } satisfies QueryParameterSchema; + + it('parses repeated keys, dropping invalid and duplicate items, into frozen lists', () => { + // Arrange + const searchParams = new URLSearchParams( + 'status=open&status=bogus&status=fixed&status=open&pages=2&pages=x&pages=2&tags=a&tags=&tags=b&dates=2026-08-09T00:00:00Z&dates=2026-08-09T00:00:00.000Z' + ); + + // Act + const state = parseQueryParameters(searchParams, schema); + + // Assert + expect(state).toEqual({ dates: [new Date('2026-08-09T00:00:00Z')], pages: [2], status: ['open', 'fixed'], tags: ['a', 'b'] }); + expectTypeOf(state.status).toEqualTypeOf(); + expectTypeOf(state.dates).toEqualTypeOf(); + expect(Object.values(state).every((value) => Object.isFrozen(value))).toBe(true); + expect(() => (state.tags as string[]).push('c')).toThrow(TypeError); + }); + + it('writes lists as repeated keys and leaves the URL alone for equal lists', () => { + // Arrange + const searchParams = new URLSearchParams('dates=2026-08-09T00%3A00%3A00.000Z&unknown=preserved'); + const state = parseQueryParameters(searchParams, schema); + + // Act + const unchanged = applyQueryParameterUpdates(state, searchParams, { dates: [new Date('2026-08-09T00:00:00Z')] }, schema); + const changed = applyQueryParameterUpdates(state, searchParams, { pages: [1, 2], status: ['fixed', 'open'] }, schema); + + // Assert + expect(unchanged.stateChanged).toBe(false); + expect(unchanged.urlChanged).toBe(false); + expect(changed.searchParams.toString()).toBe('dates=2026-08-09T00%3A00%3A00.000Z&unknown=preserved&pages=1&pages=2&status=fixed&status=open'); + expect(changed.state.status).toEqual(['fixed', 'open']); + expect(Object.isFrozen(changed.state.status)).toBe(true); + }); + + it.each([ + { defaults: undefined, expectedUrl: 'unknown=preserved', label: 'removes the key without a default' }, + { defaults: { status: ['open'] as const }, expectedUrl: 'status=&unknown=preserved', label: 'writes an empty marker over a non-empty default' } + ])('$label when a list is emptied', ({ defaults, expectedUrl }) => { + // Arrange + const searchParams = new URLSearchParams('status=fixed&unknown=preserved'); + const state = parseQueryParameters(searchParams, schema, defaults); + + // Act + const result = applyQueryParameterUpdates(state, searchParams, { status: [] }, schema, defaults); + const reparsed = parseQueryParameters(result.searchParams, schema, defaults); + + // Assert + expect(result.searchParams.toString()).toBe(expectedUrl); + expect(reparsed.status).toEqual([]); + }); + + it('applies only owned keys onto the live URL, keeping parameter order', () => { + // Arrange + const live = 'other=b&filter=old&tags=x&page=4'; + const own = new URLSearchParams('filter=new&page=1&stale=ignored'); + + // Act + const merged = mergeQueryParameters(live, own, { filter: 'string', tags: 'string[]' }); + + // Assert + expect(merged.toString()).toBe('other=b&filter=new&page=4'); + }); +}); + describe('query parameter proxy', () => { it('delegates supported assignments and ignores unsupported properties', () => { // Arrange diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.ts index 23d3068b41..2be0f34ce1 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/query-params.ts @@ -1,6 +1,6 @@ import type { QueryParameterInput, QueryParameterSchema, QueryParameterState, QueryParameterType, QueryParameterValue } from './types.js'; -import { coerceQueryParameter } from './coerce.js'; +import { coerceQueryParameter, getListItemType } from './coerce.js'; export interface DebouncedFunction { (): void; @@ -18,7 +18,8 @@ export function applyQueryParameterUpdates( currentState: QueryParameterState, currentSearchParams: URLSearchParams, values: Partial>, - schema: T + schema: T, + defaults?: Partial> ): QueryParameterUpdate { const searchParams = createSearchParams(currentSearchParams); const state = { ...currentState }; @@ -42,7 +43,7 @@ export function applyQueryParameterUpdates( stateValues[key] = coerced; stateChanged = true; - setSearchParameter(searchParams, key, stringifyQueryParameter(parameterType, coerced)); + setSearchParameter(searchParams, key, stringifyQueryParameter(parameterType, coerced, hasDefaultItems(defaults, key))); } return { @@ -85,6 +86,24 @@ export function createSearchParams(value?: string | URLSearchParams): URLSearchP return new URLSearchParams(value); } +/** + * Applies the parameters an instance owns onto the live URL. Other instances and + * unrelated code may have written their own parameters since this instance last + * read the URL, so only the schema's keys are taken from `ownSearchParams`. + */ +export function mergeQueryParameters( + liveSearchParams: string | URLSearchParams, + ownSearchParams: URLSearchParams, + schema: QueryParameterSchema +): URLSearchParams { + const searchParams = createSearchParams(liveSearchParams); + for (const key of Object.keys(schema)) { + setSearchParameter(searchParams, key, ownSearchParams.getAll(key)); + } + + return searchParams; +} + export function parseQueryParameters( searchParams: URLSearchParams, schema: T, @@ -101,17 +120,31 @@ export function parseQueryParameters( } const fallback = coerceQueryParameter(parameterType, defaultValues?.[key]); - resultValues[key] = coerceQueryParameter(parameterType, searchParams.get(key), fallback); + const value = getListItemType(parameterType) ? (searchParams.has(key) ? searchParams.getAll(key) : undefined) : searchParams.get(key); + resultValues[key] = coerceQueryParameter(parameterType, value, fallback); } return result; } +export function queryParameterValuesEqual(current: unknown, next: unknown): boolean { + if (Array.isArray(current) && Array.isArray(next)) { + return current.length === next.length && current.every((item, index) => queryParameterValueEqual(item, next[index])); + } + + return queryParameterValueEqual(current, next); +} + export function searchParamsEqual(left: URLSearchParams, right: string | URLSearchParams): boolean { return left.toString() === createSearchParams(right).toString(); } -function queryParameterValuesEqual(current: unknown, next: unknown): boolean { +function hasDefaultItems(defaults: Partial> | undefined, key: string): boolean { + const value = (defaults as Partial> | undefined)?.[key]; + return Array.isArray(value) && value.length > 0; +} + +function queryParameterValueEqual(current: unknown, next: unknown): boolean { if (current instanceof Date && next instanceof Date) { return current.getTime() === next.getTime(); } @@ -119,34 +152,50 @@ function queryParameterValuesEqual(current: unknown, next: unknown): boolean { return Object.is(current, next); } -function setSearchParameter(searchParams: URLSearchParams, key: string, value: null | string): void { - if (value === null) { - searchParams.delete(key); - } else { - searchParams.set(key, value); +/** Replaces every value of `key`, keeping the parameter at its existing position so unchanged URLs compare equal. */ +function setSearchParameter(searchParams: URLSearchParams, key: string, values: readonly string[]): void { + const entries = [...searchParams]; + const position = entries.findIndex(([name]) => name === key); + const nextEntries = entries.filter(([name]) => name !== key); + nextEntries.splice(position < 0 ? nextEntries.length : position, 0, ...values.map((value): [string, string] => [key, value])); + + for (const name of new Set(entries.map(([name]) => name))) { + searchParams.delete(name); + } + + for (const [name, value] of nextEntries) { + searchParams.append(name, value); } } -function stringifyQueryParameter(parameterType: QueryParameterType, value: QueryParameterValue): null | string { +function stringifyQueryParameter(parameterType: QueryParameterType, value: QueryParameterValue, hasDefaultItems: boolean): string[] { + const itemType = getListItemType(parameterType); + if (itemType) { + const items = (value as readonly QueryParameterValue[]).flatMap((item) => stringifyQueryParameter(itemType, item, false)); + // An absent list parameter falls back to its default on reload, so an + // explicitly emptied list with a non-empty default needs a marker. + return items.length === 0 && hasDefaultItems ? [''] : items; + } + switch (parameterType) { case 'boolean': { - return value === null ? null : value ? 'true' : 'false'; + return value === null ? [] : [value ? 'true' : 'false']; } case 'date': { - return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : null; + return value instanceof Date && !Number.isNaN(value.getTime()) ? [value.toISOString()] : []; } case 'number': { - return typeof value === 'number' && Number.isFinite(value) ? String(value) : null; + return typeof value === 'number' && Number.isFinite(value) ? [String(value)] : []; } case 'string': { - return typeof value === 'string' ? value : null; + return typeof value === 'string' ? [value] : []; } default: { - return typeof value === 'string' && value ? value : null; + return typeof value === 'string' && value ? [value] : []; } } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts index 276b6ffa3b..b9a3b63712 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts @@ -9,19 +9,43 @@ export type QueryParameterInput = { [K in keyof T]: QueryParameterTypeOutput | undefined; }; +export type QueryParameterListType = `${QueryParameterScalarType}[]`; + export type QueryParameters = QueryParameterState & { update: (values: Partial>, options?: QueryParameterUpdateOptions) => void; }; +export type QueryParameterScalarType = 'boolean' | 'date' | 'number' | 'string' | `<${string}>`; + +export type QueryParameterScalarValue = boolean | Date | null | number | string; + export type QueryParameterSchema = Record; export type QueryParameterState = { [K in keyof T]: QueryParameterTypeOutput; }; -export type QueryParameterType = 'boolean' | 'date' | 'number' | 'string' | `<${string}>`; +export type QueryParameterType = QueryParameterListType | QueryParameterScalarType; + +/** + * List parameters are never null. They are frozen because the proxy only observes + * top-level assignment; an in-place mutation would change state without the URL. + */ +export type QueryParameterTypeOutput = T extends `${infer Item extends QueryParameterScalarType}[]` + ? readonly NonNullable>[] + : T extends QueryParameterScalarType + ? QueryParameterScalarOutput + : never; + +export interface QueryParameterUpdateOptions { + history?: 'push' | 'replace'; +} + +export type QueryParameterValue = QueryParameterScalarValue | readonly QueryParameterScalarValue[]; -export type QueryParameterTypeOutput = T extends 'string' +type InferEnum = T extends `<${infer U}>` ? (U extends `${infer First},${infer Rest}` ? First | InferEnum<`<${Rest}>`> : null | U) : never; + +type QueryParameterScalarOutput = T extends 'string' ? null | string : T extends 'number' ? null | number @@ -30,11 +54,3 @@ export type QueryParameterTypeOutput = T extends ' : T extends 'boolean' ? boolean | null : InferEnum; - -export interface QueryParameterUpdateOptions { - history?: 'push' | 'replace'; -} - -export type QueryParameterValue = boolean | Date | null | number | string; - -type InferEnum = T extends `<${infer U}>` ? (U extends `${infer First},${infer Rest}` ? First | InferEnum<`<${Rest}>`> : null | U) : never; From 07f2a70c2a33758bf6f91390b4bf8154cae05e15 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 14 Oct 2026 08:19:27 -0500 Subject: [PATCH 2/6] Upgrade to SvelteKit 3 and adapter-static 4 Move the SvelteKit configuration into the Vite plugin, extend `$app/tsconfig`, declare param matchers with `defineParams`, and declare public environment variables in `src/env.ts`. They stay dynamic, so the container entrypoint can still rewrite `_app/env.js` at startup. localStorage overrides now live in an app-owned module because `$app/env/public` exports immutable bindings. Shallow routing uses `goto` with `shallow: true`. A shallow `goto` starts a navigation that aborts one already in progress, and SvelteKit 3 writes it asynchronously, so all shallow writes go through one queue that waits for navigations, derives each write when it runs, and persists page state across reloads. Query parameters, detail sheets, and saved views read their entry markers from `page.state` instead of SvelteKit's internal history format. SvelteKit applies a traversed entry's page state before component popstate listeners run, so Back and Forward are counted from the client `init` hook. Navigation hooks ignore shallow navigations. Also: `goto` options and `data-sveltekit-reset` replace the removed `noScroll`/`keepFocus` options and attributes; same-origin redirects that are not app routes load as full pages because `goto` now rejects them; route IDs follow SvelteKit 3's `RouteId`; the client `handleError` hook uses the new `kind` input; and the organization query finishes after its observer unmounts, like other cacheable reads, now that render errors show the error page. Storybook 10 gets the payload define and public environment mock that Storybook 11 adds for SvelteKit 3. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/frontend-architecture/SKILL.md | 1 + .../ClientApp/.storybook/main.ts | 28 +- .../ClientApp/.storybook/mocks/env.js | 29 +- .../ClientApp/e2e/support/page-helpers.ts | 7 + .../ClientApp/e2e/tests/api-origin.e2e.ts | 5 +- .../ClientApp/e2e/tests/saved-views.e2e.ts | 16 +- .../ClientApp/package-lock.json | 158 ++++--- src/Exceptionless.Web/ClientApp/package.json | 9 +- src/Exceptionless.Web/ClientApp/src/app.d.ts | 5 +- src/Exceptionless.Web/ClientApp/src/env.ts | 25 + .../ClientApp/src/hooks.client.ts | 57 +-- src/Exceptionless.Web/ClientApp/src/hooks.ts | 2 +- .../assistant/assistant-links.test.ts | 3 +- .../lib/features/assistant/assistant-links.ts | 6 +- .../components/assistant-panel.svelte.test.ts | 6 +- .../src/lib/features/auth/api.svelte.ts | 4 +- .../auth/exceptionless-session.test.ts | 2 +- .../features/auth/exceptionless-session.ts | 2 +- .../src/lib/features/auth/index.svelte.ts | 32 +- .../src/lib/features/auth/navigation.test.ts | 9 +- .../change-plan-dialog-host.svelte.test.ts | 1 - .../src/lib/features/billing/stripe.svelte.ts | 8 +- .../filters/tag-faceted-filter.svelte.test.ts | 1 - .../events/tag-suggestions-api.svelte.test.ts | 1 - .../intercom/intercom-shell.svelte.test.ts | 2 - .../src/lib/features/navigation/redirect.ts | 16 + .../navigation/scoped-dashboard.svelte | 2 +- .../lib/features/navigation/visible-url.ts | 12 + .../components/notifications.svelte | 4 +- .../lib/features/organizations/api.svelte.ts | 7 +- .../impersonation-notification.svelte | 2 +- .../src/lib/features/product-tours/catalog.ts | 2 +- .../product-tours/user-cache.svelte.test.ts | 1 - .../lib/features/projects/api.svelte.test.ts | 1 - .../saved-views/use-saved-views.svelte.ts | 122 ++--- .../features/shared/api/api.svelte.test.ts | 2 +- .../shared/api/query-gating.svelte.test.ts | 1 - .../src/lib/features/shared/api/urls.test.ts | 6 +- .../src/lib/features/shared/api/urls.ts | 4 +- .../shared/components/detail-sheet.svelte | 203 ++++---- .../components/detail-sheet.svelte.test.ts | 205 ++++---- .../detail-sheet.test-harness.svelte | 2 +- .../components/navigation/nav-item.svelte | 2 +- .../ui/dropdown-menu/dropdown-menu.svelte | 2 +- .../components/ui/popover/popover.svelte | 2 +- .../components/ui/tooltip/tooltip.svelte | 2 +- .../src/lib/features/shared/environment.ts | 17 + .../shared/history-state.svelte.test.ts | 62 +++ .../src/lib/features/shared/history-state.ts | 53 +++ .../lib/features/shared/history-traversal.ts | 22 + .../features/shared/query-params/README.md | 4 +- .../query-params/query-params.svelte.test.ts | 438 +++++++++--------- .../query-params/query-params.svelte.ts | 151 ++++-- .../shallow-routing.test-fake.svelte.ts | 229 +++++++++ .../components/stack-action-page.svelte | 2 +- .../websockets/web-socket-client.test.ts | 6 +- .../src/lib/telemetry/Telemetry.svelte | 7 +- .../ClientApp/src/lib/telemetry/route.ts | 2 +- src/Exceptionless.Web/ClientApp/src/params.ts | 10 + .../ClientApp/src/params/objectid.ts | 3 - .../ClientApp/src/params/savedview.ts | 3 - .../ClientApp/src/params/stackaction.ts | 3 - .../(app)/(components)/layouts/navbar.svelte | 2 +- .../sidebar-organization-switcher.svelte | 6 +- .../(app)/(components)/layouts/sidebar.svelte | 3 +- .../(components)/navigation-command.svelte | 2 +- .../navigation-command.svelte.test.ts | 1 - .../ClientApp/src/routes/(app)/+layout.svelte | 33 +- .../ClientApp/src/routes/(app)/+page.svelte | 2 +- .../src/routes/(app)/account/+layout.svelte | 2 +- .../(app)/account/ai-tools/+page.svelte | 2 +- .../routes/(app)/account/manage/+page.svelte | 2 +- .../src/routes/(app)/event/+page.svelte | 2 +- .../event/[eventId=objectid]/+page.svelte | 2 +- .../event/by-ref/[referenceId]/+page.svelte | 2 +- .../[organizationId]/+layout.svelte | 2 +- .../[organizationId]/billing/+page.svelte | 4 +- .../[organizationId]/projects/+page.svelte | 2 +- .../[organizationId]/upgrade/+page.svelte | 4 +- .../[organizationId]/usage/+page.svelte | 4 +- .../routes/(app)/payment/[id]/+page@.svelte | 2 +- .../(app)/project/[projectId]/+layout.svelte | 2 +- .../[projectId]/configure/+page.svelte | 7 +- .../[projectId]/integrations/+page.svelte | 4 +- .../[projectId]/stacks/[stackId]/+page.svelte | 2 +- .../project/[projectId]/usage/+page.svelte | 4 +- .../stack/[stackId=objectid]/+page.svelte | 2 +- .../event/[eventId=objectid]/+page.svelte | 2 +- .../src/routes/(app)/system/+layout.svelte | 2 +- .../src/routes/(app)/system/+page.svelte | 2 +- .../system/actions/[[category]]/+page.svelte | 16 +- .../(app)/system/elasticsearch/+page.svelte | 2 +- .../src/routes/(app)/system/routes.svelte.ts | 2 +- .../src/routes/(auth)/login/+page.svelte | 8 +- .../(auth)/oauth/authorize/+page.svelte | 6 +- .../authorize/authorize-page.svelte.test.ts | 4 +- .../src/routes/(auth)/signup/+page.svelte | 4 +- .../ClientApp/src/routes/+layout.svelte | 13 +- .../ClientApp/src/routes/status/+page.svelte | 6 +- .../ClientApp/svelte.config.js | 26 -- src/Exceptionless.Web/ClientApp/tsconfig.json | 20 +- .../ClientApp/vite.config.ts | 24 +- .../ClientApp/vitest-setup-client.ts | 7 + .../ClientApp/vitest-setup-server.ts | 8 + 104 files changed, 1399 insertions(+), 884 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/env.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/navigation/redirect.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/navigation/visible-url.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/environment.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/history-state.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/history-traversal.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/shallow-routing.test-fake.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/params.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/params/objectid.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/params/savedview.ts delete mode 100644 src/Exceptionless.Web/ClientApp/src/params/stackaction.ts delete mode 100644 src/Exceptionless.Web/ClientApp/svelte.config.js diff --git a/.agents/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md index a2965c6298..a323a5fd47 100644 --- a/.agents/skills/frontend-architecture/SKILL.md +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -28,6 +28,7 @@ Keep feature behavior in `src/lib/features//`: API operations in `api.s - Use `options.ts` for shared option sets within a feature. Use `$features` for feature imports and `$generated` for generated contracts; extend generated types in feature-owned files. - Prefer named imports; use namespace imports for composite components such as `Dialog`, `DropdownMenu`, `Field`, and `Card`. +- For shallow routing, write history through `updateShallowHistory` in `$shared/history-state` and read the address-bar URL with `getVisibleUrl()` from `$features/navigation/visible-url`; `page.url` keeps the rendered page's URL. Navigation hooks that should ignore shallow routing start with `if (shallow && type === 'goto') return;`. - Keep files and directories kebab-case. Follow the app's ESLint rules for braces and statement formatting. - Avoid `any`; use generated types, explicit interfaces, `unknown`, and type guards. Spell out identifiers such as `organization` and `filter`. - Await asynchronous work and handle failures at the appropriate local boundary. Do not let rejected promises bypass error UI or mark an operation complete before its required work finishes. diff --git a/src/Exceptionless.Web/ClientApp/.storybook/main.ts b/src/Exceptionless.Web/ClientApp/.storybook/main.ts index 27de2c4725..ecf3f1694d 100644 --- a/src/Exceptionless.Web/ClientApp/.storybook/main.ts +++ b/src/Exceptionless.Web/ClientApp/.storybook/main.ts @@ -1,21 +1,35 @@ import type { StorybookConfig } from '@storybook/sveltekit'; +import type { Plugin } from 'vite'; import { mergeConfig } from 'vite'; +// SvelteKit resolves `$app/env/public` itself, ahead of any alias, so the mock replaces the +// module that the import resolves to. +function mockPublicEnvironment(): Plugin { + const mock = new URL('./mocks/env.js', import.meta.url).pathname; + return { + enforce: 'pre', + load: { + filter: { id: /\/@sveltejs\/kit\/src\/runtime\/app\/env\/public\/index\.js(\?|$)/ }, + handler: () => `export * from ${JSON.stringify(mock)};` + }, + name: 'exceptionless:storybook-public-env' + }; +} + const config: StorybookConfig = { addons: ['@storybook/addon-svelte-csf', '@chromatic-com/storybook', '@storybook/addon-a11y', '@storybook/addon-docs'], framework: '@storybook/sveltekit', stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'], - async viteFinal(config) { + async viteFinal(config, { configType }) { return mergeConfig(config, { + // Storybook 10 removes SvelteKit's compile plugin, which defines the payload that + // SvelteKit 3's `$app/env` and `$app/paths` read in builds. Storybook 11 defines it: + // https://github.com/storybookjs/storybook/blob/next/code/frameworks/sveltekit/src/plugins/config-overrides.ts + define: configType === 'PRODUCTION' ? { __SVELTEKIT_PAYLOAD__: JSON.stringify({}) } : {}, envPrefix: ['PUBLIC_'], - resolve: { - alias: { - // Mock SvelteKit's $env/dynamic/public for Storybook - '$env/dynamic/public': new URL('./mocks/env.js', import.meta.url).pathname - } - } + plugins: [mockPublicEnvironment()] }); } }; diff --git a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js index f5a391f3ea..35e721b8f3 100644 --- a/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js +++ b/src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js @@ -1,8 +1,21 @@ -// Mock for $env/dynamic/public in Storybook -export const env = { - // Filter to only include PUBLIC_ prefixed environment variables - ...Object.fromEntries(Object.entries(import.meta.env).filter(([key]) => key.startsWith('PUBLIC_'))), - // Provide a Stripe publishable key so isStripeEnabled() returns true in - // billing stories. The StripeProvider will fail to init (expected). - PUBLIC_STRIPE_PUBLISHABLE_KEY: import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_storybook_placeholder' -}; +// Mock for SvelteKit's $app/env/public in Storybook, which has no SvelteKit server to provide it. +const env = import.meta.env; + +export const PUBLIC_APP_VERSION = env.PUBLIC_APP_VERSION; +export const PUBLIC_BASE_URL = env.PUBLIC_BASE_URL; +export const PUBLIC_ENABLE_ACCOUNT_CREATION = env.PUBLIC_ENABLE_ACCOUNT_CREATION; +export const PUBLIC_ENABLE_SSL = env.PUBLIC_ENABLE_SSL; +export const PUBLIC_EXCEPTIONLESS_API_KEY = env.PUBLIC_EXCEPTIONLESS_API_KEY; +export const PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL; +export const PUBLIC_EXCEPTIONLESS_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_SERVER_URL; +export const PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL; +export const PUBLIC_FACEBOOK_APPID = env.PUBLIC_FACEBOOK_APPID; +export const PUBLIC_GITHUB_APPID = env.PUBLIC_GITHUB_APPID; +export const PUBLIC_GOOGLE_APPID = env.PUBLIC_GOOGLE_APPID; +export const PUBLIC_INTERCOM_APPID = env.PUBLIC_INTERCOM_APPID; +export const PUBLIC_MICROSOFT_APPID = env.PUBLIC_MICROSOFT_APPID; +export const PUBLIC_SLACK_APPID = env.PUBLIC_SLACK_APPID; +// Provide a Stripe publishable key so isStripeEnabled() returns true in +// billing stories. The StripeProvider will fail to init (expected). +export const PUBLIC_STRIPE_PUBLISHABLE_KEY = env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_storybook_placeholder'; +export const PUBLIC_SYSTEM_NOTIFICATION_MESSAGE = env.PUBLIC_SYSTEM_NOTIFICATION_MESSAGE; diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts b/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts index 0e319987da..acbe0c5111 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts @@ -1,9 +1,16 @@ import { expect, type Locator, type Page } from '@playwright/test'; +import { parse } from 'devalue'; export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +/** Reads the `page.state` that SvelteKit stored with the current history entry. */ +export async function getHistoryPageState(page: Page): Promise | undefined> { + const serialized = await page.evaluate(() => (window.history.state as null | { 'sveltekit:metadata'?: { state?: string } })?.['sveltekit:metadata']?.state); + return serialized ? (parse(serialized) as Record) : undefined; +} + export function getIdFromUrl(page: Page, pattern: RegExp): string { const match = pattern.exec(new URL(page.url()).pathname); if (!match?.[1]) { diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/api-origin.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/api-origin.e2e.ts index 385f5be688..901481f43b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/api-origin.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/api-origin.e2e.ts @@ -19,9 +19,10 @@ test('configured HTTPS preserves incoming links before starting HTTP API request await page.route(`${httpOrigin}/**`, async (route) => { const url = new URL(route.request().url()); const response = await route.fetch({ url: new URL(url.pathname + url.search, e2eApi.environment.appUrl).href }); - if (url.pathname.endsWith('virtual:env/dynamic/public')) { + // The dev server's `$app/env/public` module exports values read from `__sveltekit_dev.env`. + if (url.pathname.endsWith('/generated/dev/env/public/client.js')) { sslFlagInjected = true; - await route.fulfill({ body: `${await response.text()}\nenv.PUBLIC_ENABLE_SSL = 'true';`, response }); + await route.fulfill({ body: `globalThis.__sveltekit_dev.env.PUBLIC_ENABLE_SSL = 'true';\n${await response.text()}`, response }); } else { await route.fulfill({ response }); } diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts index ad77352d14..4069ba3d55 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -2,7 +2,7 @@ import type { Locator, Page, Request } from '@playwright/test'; import { expect, test } from '../fixtures/e2e-test'; import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; -import { getVisibleText } from '../support/page-helpers'; +import { getHistoryPageState, getVisibleText } from '../support/page-helpers'; test('home navigation honors personal and organization saved views and survives deletion', async ({ e2eApi, e2eScenario, page, request }) => { // Arrange @@ -857,12 +857,7 @@ test('stream switches to a session-local saved view draft without mixing in-flig await expect(getVisibleText(page, sourceMessage)).toBeHidden(); await expect(page.getByLabel('Unsaved view changes')).toBeVisible(); await expect - .poll(() => - page.evaluate(() => { - const state = window.history.state as { 'sveltekit:states'?: { __exceptionlessSavedViewDraftFilter?: unknown } }; - return state['sveltekit:states']?.__exceptionlessSavedViewDraftFilter; - }) - ) + .poll(async () => (await getHistoryPageState(page))?.__exceptionlessSavedViewDraftFilter) .toEqual({ filter: new URL(page.url()).searchParams.get('filter'), viewId: savedView.id }); const updatedFilterDefinitions = JSON.stringify([ @@ -892,12 +887,7 @@ test('stream switches to a session-local saved view draft without mixing in-flig streamRequestFilters.length = 0; await page.reload(); await expect - .poll(() => - page.evaluate(() => { - const state = window.history.state as { 'sveltekit:states'?: { __exceptionlessSavedViewDraftFilter?: unknown } }; - return state['sveltekit:states']?.__exceptionlessSavedViewDraftFilter; - }) - ) + .poll(async () => (await getHistoryPageState(page))?.__exceptionlessSavedViewDraftFilter) .toEqual({ filter: new URL(page.url()).searchParams.get('filter'), viewId: savedView.id }); await expect(page.getByRole('heading', { name: viewName })).toBeVisible(); await expect.poll(() => streamRequestFilters.join('\n')).toContain(remoteExpression); diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index 0d3b472790..5b49431aff 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -52,8 +52,8 @@ "@storybook/addon-svelte-csf": "^5.1.5", "@storybook/sveltekit": "^10.6.1", "@stylistic/eslint-plugin": "^5.10.0", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.70.3", + "@sveltejs/adapter-static": "^4.0.0", + "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "@tanstack/eslint-plugin-query": "^5.104.2", @@ -63,6 +63,7 @@ "@types/node": "^26.6.5", "@types/throttle-debounce": "^5.0.2", "cross-env": "^10.1.0", + "devalue": "^5.9.4", "eslint": "^10.12.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^5.12.1", @@ -580,6 +581,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -596,6 +598,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -612,6 +615,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -628,6 +632,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -644,6 +649,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -660,6 +666,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -676,6 +683,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -692,6 +700,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -708,6 +717,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -724,6 +734,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -740,6 +751,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -756,6 +768,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -772,6 +785,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -788,6 +802,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -804,6 +819,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -820,6 +836,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -836,6 +853,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -852,6 +870,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -868,6 +887,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -884,6 +904,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -900,6 +921,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -916,6 +938,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -932,6 +955,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -948,6 +972,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -964,6 +989,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -980,6 +1006,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2214,6 +2241,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2230,6 +2258,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2246,6 +2275,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2262,6 +2292,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2278,6 +2309,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2294,6 +2326,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2310,6 +2343,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2326,6 +2360,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2342,6 +2377,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2358,6 +2394,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2374,6 +2411,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2390,6 +2428,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2406,6 +2445,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2422,6 +2462,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2438,6 +2479,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3131,47 +3173,43 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", - "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-4.0.0.tgz", + "integrity": "sha512-aF1J23R1ebKqUxQ4/qLr/Lxu63NFmLAtg8UZl3HFLxaoA1D/nhTVGcD9lTqx65iUbjNR2iOL0TgOxSX82x6Paw==", "dev": true, "license": "MIT", "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^3.0.0-next.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.70.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", - "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.0.1.tgz", + "integrity": "sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q==", "devOptional": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", - "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", - "cookie": "^0.6.0", - "devalue": "^5.8.1", + "@rolldown/pluginutils": "^1.0.1", + "@standard-schema/spec": "^1.1.0", + "cookie": "^2.0.1", + "devalue": "^5.9.4", "esm-env": "^1.2.2", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "set-cookie-parser": "^3.0.0", - "sirv": "^3.0.0" + "magic-string": "^1.2.3", + "mrmime": "^2.0.1", + "sirv": "^3.0.2" }, "bin": { "svelte-kit": "svelte-kit.js" }, "engines": { - "node": ">=18.13" + "node": ">=22.17" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.57.1", + "typescript": "^6.0.0", + "vite": "^8.0.12" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -3182,16 +3220,6 @@ } } }, - "node_modules/@sveltejs/kit/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/@sveltejs/load-config": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", @@ -3943,13 +3971,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -4297,7 +4318,7 @@ "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -5242,13 +5263,17 @@ } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/copy-anything": { @@ -6201,7 +6226,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -7319,7 +7344,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7478,16 +7503,6 @@ "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/known-css-properties": { "version": "0.37.0", "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", @@ -10482,13 +10497,6 @@ "seroval": "^1.0" } }, - "node_modules/set-cookie-parser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", - "devOptional": true, - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11743,7 +11751,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11791,7 +11799,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -12099,6 +12107,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12146,6 +12155,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12166,6 +12176,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12186,6 +12197,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12206,6 +12218,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12226,6 +12239,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12246,6 +12260,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12266,6 +12281,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12286,6 +12302,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12306,6 +12323,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12326,6 +12344,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12346,6 +12365,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -12705,7 +12725,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index fcac4ba567..4221afe187 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -43,8 +43,8 @@ "@storybook/addon-svelte-csf": "^5.1.5", "@storybook/sveltekit": "^10.6.1", "@stylistic/eslint-plugin": "^5.10.0", - "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.70.3", + "@sveltejs/adapter-static": "^4.0.0", + "@sveltejs/kit": "^3.0.1", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@tailwindcss/vite": "^4.3.3", "@tanstack/eslint-plugin-query": "^5.104.2", @@ -54,6 +54,7 @@ "@types/node": "^26.6.5", "@types/throttle-debounce": "^5.0.2", "cross-env": "^10.1.0", + "devalue": "^5.9.4", "eslint": "^10.12.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^5.12.1", @@ -116,8 +117,8 @@ "storybook": "$storybook", "fast-uri": "3.1.8", "js-yaml": "4.3.2", - "@sveltejs/kit": { - "cookie": "0.7.2" + "runed": { + "@sveltejs/kit": "$@sveltejs/kit" } } } diff --git a/src/Exceptionless.Web/ClientApp/src/app.d.ts b/src/Exceptionless.Web/ClientApp/src/app.d.ts index f48c10f5eb..f7fcd31c60 100644 --- a/src/Exceptionless.Web/ClientApp/src/app.d.ts +++ b/src/Exceptionless.Web/ClientApp/src/app.d.ts @@ -3,7 +3,10 @@ declare global { namespace App { - // interface Error {} + interface Error { + /** Exceptionless reference ID for errors reported by the client error hook. */ + errorId?: string; + } // interface Locals {} // interface PageData {} // interface PageState {} diff --git a/src/Exceptionless.Web/ClientApp/src/env.ts b/src/Exceptionless.Web/ClientApp/src/env.ts new file mode 100644 index 0000000000..d6f55588bd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/env.ts @@ -0,0 +1,25 @@ +import { defineEnvVars } from '@sveltejs/kit/env'; + +// Public variables are dynamic: the container entrypoint (build/update-config.sh) rewrites +// _app/env.js with runtime settings, and the browser reads them without validation. +// Keep every value a plain string so build-time and container values have the same shape. +const optional = (value: string | undefined) => value; + +export const variables = defineEnvVars({ + PUBLIC_APP_VERSION: { public: true, schema: optional }, + PUBLIC_BASE_URL: { description: 'API origin; empty uses the UI origin.', public: true }, + PUBLIC_ENABLE_ACCOUNT_CREATION: { public: true }, + PUBLIC_ENABLE_SSL: { public: true, schema: optional }, + PUBLIC_EXCEPTIONLESS_API_KEY: { description: 'API key the UI uses to report its own errors.', public: true }, + PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL: { public: true }, + PUBLIC_EXCEPTIONLESS_SERVER_URL: { description: 'Server URL shown in client setup instructions and used for UI telemetry.', public: true }, + PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL: { public: true, schema: optional }, + PUBLIC_FACEBOOK_APPID: { public: true }, + PUBLIC_GITHUB_APPID: { public: true }, + PUBLIC_GOOGLE_APPID: { public: true }, + PUBLIC_INTERCOM_APPID: { public: true }, + PUBLIC_MICROSOFT_APPID: { public: true }, + PUBLIC_SLACK_APPID: { public: true }, + PUBLIC_STRIPE_PUBLISHABLE_KEY: { public: true }, + PUBLIC_SYSTEM_NOTIFICATION_MESSAGE: { public: true } +}); diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index ab9b6d7df2..7497abc967 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -1,11 +1,13 @@ -import type { ClientInit, HandleClientError } from '@sveltejs/kit'; +import type { ClientInit, HandleClientError } from '@sveltejs/kit/hooks'; -import { dev } from '$app/environment'; +import { dev } from '$app/env'; +import { PUBLIC_APP_VERSION, PUBLIC_ENABLE_SSL, PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL } from '$app/env/public'; import { page } from '$app/state'; -import { env } from '$env/dynamic/public'; import { configureSessions } from '$features/auth/exceptionless-session'; import { getHttpsRedirectUrl } from '$features/navigation/https-redirect'; import { canonicalAppUrl } from '$features/navigation/legacy-links'; +import { getEnvironmentOverride, PUBLIC_EXCEPTIONLESS_API_KEY, PUBLIC_EXCEPTIONLESS_SERVER_URL } from '$features/shared/environment'; +import { trackHistoryTraversals } from '$features/shared/history-traversal'; import { normalizePath, normalizeRouteId } from '$lib/telemetry'; import { installSvelteEffectDepthDiagnostics } from '$lib/telemetry/svelte-effect-depth-diagnostics'; import { Exceptionless, guid, toError } from '@exceptionless/browser'; @@ -13,23 +15,6 @@ import { useMiddleware } from '@foundatiofx/fetchclient'; installSvelteEffectDepthDiagnostics(); -// If any of these are set in local storage, use them instead of the build-time environment variables. -// This allows you to target other environments from your browser without a rebuild. -const PUBLIC_BASE_URL = localStorage?.getItem('PUBLIC_BASE_URL'); -if (PUBLIC_BASE_URL) { - env.PUBLIC_BASE_URL = PUBLIC_BASE_URL; -} - -const PUBLIC_EXCEPTIONLESS_API_KEY = localStorage?.getItem('PUBLIC_EXCEPTIONLESS_API_KEY'); -if (PUBLIC_EXCEPTIONLESS_API_KEY) { - env.PUBLIC_EXCEPTIONLESS_API_KEY = PUBLIC_EXCEPTIONLESS_API_KEY; -} - -const PUBLIC_EXCEPTIONLESS_SERVER_URL = localStorage?.getItem('PUBLIC_EXCEPTIONLESS_SERVER_URL'); -if (PUBLIC_EXCEPTIONLESS_SERVER_URL) { - env.PUBLIC_EXCEPTIONLESS_SERVER_URL = PUBLIC_EXCEPTIONLESS_SERVER_URL; -} - function normalizeCurrentAppUrl() { const canonical = canonicalAppUrl(new URL(window.location.href)); if (canonical.href !== window.location.href) { @@ -38,7 +23,7 @@ function normalizeCurrentAppUrl() { } export const init: ClientInit = async () => { - const httpsDestination = getHttpsRedirectUrl(new URL(window.location.href), env.PUBLIC_ENABLE_SSL); + const httpsDestination = getHttpsRedirectUrl(new URL(window.location.href), PUBLIC_ENABLE_SSL); if (httpsDestination) { window.location.replace(httpsDestination.href); // Keep the router, authentication, and telemetry from starting on the HTTP page. @@ -49,19 +34,22 @@ export const init: ClientInit = async () => { normalizeCurrentAppUrl(); // Normalize existing entries before the router reads them; cancelling popstate would undo Back/Forward. window.addEventListener('popstate', normalizeCurrentAppUrl, { capture: true }); + trackHistoryTraversals(); - if (!env.PUBLIC_EXCEPTIONLESS_API_KEY) { + if (!PUBLIC_EXCEPTIONLESS_API_KEY) { return; } await Exceptionless.startup((c) => { - c.apiKey = env.PUBLIC_EXCEPTIONLESS_API_KEY; + c.apiKey = PUBLIC_EXCEPTIONLESS_API_KEY; c.serverUrl = - PUBLIC_EXCEPTIONLESS_SERVER_URL || (env.PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL ?? env.PUBLIC_EXCEPTIONLESS_SERVER_URL) || window.location.origin; + getEnvironmentOverride('PUBLIC_EXCEPTIONLESS_SERVER_URL') || + (PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL ?? PUBLIC_EXCEPTIONLESS_SERVER_URL) || + window.location.origin; c.defaultTags.push('UI', 'Svelte'); - if (env.PUBLIC_APP_VERSION) { - c.version = env.PUBLIC_APP_VERSION; + if (PUBLIC_APP_VERSION) { + c.version = PUBLIC_APP_VERSION; } if (dev) { @@ -114,18 +102,23 @@ export const init: ClientInit = async () => { }); }; -export const handleError: HandleClientError = async ({ error, event, message, status }) => { +export const handleError: HandleClientError = async ({ error, event, kind }) => { if (dev) { - console.warn({ error, event, message, status }); + console.warn({ error, event, kind }); + } + + // Errors created with error(...) are expected and already safe to show. + if (kind === 'app') { + return error; } let errorId: null | string = null; try { - if (status === 404) { + if (kind === 'framework' && error.status === 404) { await Exceptionless.submitNotFound(normalizePath(event.url.pathname)); } else { - await Exceptionless.createException(toError(error ?? message)) - .setProperty('status', String(status)) + await Exceptionless.createException(toError(kind === 'framework' ? error.message : error)) + .setProperty('status', String(kind === 'framework' ? error.status : 500)) .submit(); } @@ -134,5 +127,5 @@ export const handleError: HandleClientError = async ({ error, event, message, st // never throw } - return { errorId: errorId ?? guid(), message }; + return { errorId: errorId ?? guid() }; }; diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.ts b/src/Exceptionless.Web/ClientApp/src/hooks.ts index 604ad1bc5f..707d128e7f 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.ts @@ -1,4 +1,4 @@ -import type { Reroute } from '@sveltejs/kit'; +import type { Reroute } from '@sveltejs/kit/hooks'; import { canonicalAppUrl, isSignInCallback } from '$features/navigation/legacy-links'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts index da38de9ea6..7161a82f28 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.test.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const paths = vi.hoisted(() => ({ base: '' })); -vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path}` })); +// Route IDs drop their groups; other paths have no leading slash, so '' is the app root. +vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path.startsWith('/') ? path.replace(/\/\([^)]+\)/g, '') || '/' : `/${path}`}` })); vi.mock('$app/state', () => ({ page: { url: new URL('https://exceptionless.local/') } })); import type { AssistantToolActivity } from './models'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts index c074b1626b..d07d815503 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/assistant-links.ts @@ -36,7 +36,7 @@ export function normalizeAssistantUrl(url: string, key: string): string { try { const parsedUrl = new URL(url); // At the root, the pathname alone cannot distinguish app links from external links. - if (isAssistantPath(parsedUrl.pathname) && (resolve('/') !== '/' || parsedUrl.origin === page.url.origin)) { + if (isAssistantPath(parsedUrl.pathname) && (resolve('') !== '/' || parsedUrl.origin === page.url.origin)) { return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; } } catch { @@ -231,7 +231,7 @@ function getAssistantResourceLinks(tools: AssistantToolActivity[]): AssistantRes } function isAssistantPath(value: string): boolean { - const root = resolve('/'); + const root = resolve(''); return !value.startsWith('//') && !/[\\\s]/.test(value) && (value === root.slice(0, -1) || value.startsWith(root)); } @@ -247,7 +247,7 @@ function readNonEmptyString(record: Record, key: string): strin function replaceOutsideProtectedMarkdown(content: string, replace: (text: string) => string): string { const protectedMarkdown = /(^(?:(?: {0,3}>[\t ]?)*(?: {4}|\t)[^\r\n]*(?:\r?\n|$))+|!?\[[^\]\n]*\]\s*\[[^\]\n]*\]|!?\[[^\]\n]*\]|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*|(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}|https?:\/\/[^\s<]+)/gm; - const root = resolve('/'); + const root = resolve(''); const appPathPattern = root === '/' ? /\/[^\s<]*/g : new RegExp(`${escapeRegularExpression(root.slice(0, -1))}(?:/[^\\s<]*)?`, 'g'); const protectedRanges = [...content.matchAll(protectedMarkdown), ...content.matchAll(appPathPattern)].map((match) => ({ end: match.index + match[0].length, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte.test.ts index 6ae129fc66..1002dab15e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-panel.svelte.test.ts @@ -3,7 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { env } = vi.hoisted(() => ({ env: { PUBLIC_BASE_URL: '' } })); -vi.mock('$env/dynamic/public', () => ({ env })); +vi.mock('$features/shared/environment', () => ({ + get PUBLIC_BASE_URL() { + return env.PUBLIC_BASE_URL; + } +})); vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'access-token' } })); vi.mock('$features/billing/stripe.svelte', () => ({ isStripeEnabled: () => true })); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts index 7571b6b417..e194833f5f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/api.svelte.ts @@ -1,4 +1,4 @@ -import { env } from '$env/dynamic/public'; +import { PUBLIC_INTERCOM_APPID } from '$app/env/public'; import { getIntercomTokenSessionKey, intercomTokenRefreshIntervalMs } from '$features/intercom/config'; import { organization } from '$features/organizations/context.svelte'; import { ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; @@ -50,7 +50,7 @@ export async function forgotPassword(email: string) { export function getIntercomTokenQuery() { return createQuery(() => ({ - enabled: () => !!accessToken.current && !!env.PUBLIC_INTERCOM_APPID, + enabled: () => !!accessToken.current && !!PUBLIC_INTERCOM_APPID, queryFn: async ({ signal }) => { const client = useFetchClient(); const response = await client.getJSON('auth/intercom', { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.test.ts index 317842a042..8afef971ea 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.test.ts @@ -1,7 +1,7 @@ import { Exceptionless } from '@exceptionless/browser'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$app/env', () => ({ browser: true })); vi.mock('@exceptionless/browser', async () => { const { Configuration, ExceptionlessClient } = await import('@exceptionless/core'); const config = new Configuration(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.ts index 2931b40ffe..66fb3fb045 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/exceptionless-session.ts @@ -1,6 +1,6 @@ import type { Configuration } from '@exceptionless/browser'; -import { browser } from '$app/environment'; +import { browser } from '$app/env'; let _activeUserId: null | string = null; let _identityGeneration = 0; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts index 406b926252..2f31e8daca 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/index.svelte.ts @@ -1,7 +1,15 @@ +import { + PUBLIC_ENABLE_ACCOUNT_CREATION, + PUBLIC_FACEBOOK_APPID, + PUBLIC_GITHUB_APPID, + PUBLIC_GOOGLE_APPID, + PUBLIC_MICROSOFT_APPID, + PUBLIC_SLACK_APPID +} from '$app/env/public'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; -import { page } from '$app/state'; -import { env } from '$env/dynamic/public'; +import { gotoRedirect } from '$features/navigation/redirect'; +import { getVisibleUrl } from '$features/navigation/visible-url'; import { useFetchClient } from '@foundatiofx/fetchclient'; import type { TokenResult } from './models'; @@ -47,12 +55,12 @@ export interface OAuthResponseData { export type SupportedOAuthProviders = 'facebook' | 'github' | 'google' | 'live' | 'slack'; -export const enableAccountCreation = env.PUBLIC_ENABLE_ACCOUNT_CREATION === 'true'; -export const facebookClientId = env.PUBLIC_FACEBOOK_APPID; -export const gitHubClientId = env.PUBLIC_GITHUB_APPID; -export const googleClientId = env.PUBLIC_GOOGLE_APPID; -export const microsoftClientId = env.PUBLIC_MICROSOFT_APPID; -export const slackClientId = env.PUBLIC_SLACK_APPID; +export const enableAccountCreation = PUBLIC_ENABLE_ACCOUNT_CREATION === 'true'; +export const facebookClientId = PUBLIC_FACEBOOK_APPID; +export const gitHubClientId = PUBLIC_GITHUB_APPID; +export const googleClientId = PUBLIC_GOOGLE_APPID; +export const microsoftClientId = PUBLIC_MICROSOFT_APPID; +export const slackClientId = PUBLIC_SLACK_APPID; export const enableOAuthLogin = facebookClientId || gitHubClientId || googleClientId || microsoftClientId; // OAuth login functions (interactive popup-based, not pure API calls) @@ -114,13 +122,13 @@ export async function googleLogin(redirectUrl?: string, inviteToken?: null | str } export async function gotoLogin() { - const url = page.url; + const url = getVisibleUrl(); const loginPath = resolve('/(auth)/login'); const isAuthPath = url.pathname === loginPath || url.pathname === `${loginPath}/`; const returnUrl = `${url.pathname}${url.search}${url.hash}`; - const redirect = url.pathname === resolve('/') || isAuthPath ? loginPath : `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`; + const redirect = url.pathname === resolve('/(app)') || isAuthPath ? loginPath : `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`; await goto(redirect, { - replaceState: true + replace: true }); } @@ -180,7 +188,7 @@ async function oauthLogin(options: OAuthLoginOptions) { if (response.ok && response.data?.token) { accessToken.current = response.data.token; - await goto(options.redirectUrl || resolve('/')); + await gotoRedirect(options.redirectUrl || resolve('/(app)')); } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts index 91f165b666..4828c2aac9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/auth/navigation.test.ts @@ -7,9 +7,8 @@ const { goto, page, paths } = vi.hoisted(() => ({ })); vi.mock('$app/navigation', () => ({ goto })); -vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path.replace('/(auth)', '')}` })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => `${paths.base}${path.replace(/\/\([^)]+\)/g, '') || '/'}` })); vi.mock('$app/state', () => ({ page })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('./api.svelte', () => ({})); vi.mock('./state.svelte', () => ({ accessToken: { current: null } })); vi.mock('./validators', () => ({ validateEmailAvailability: vi.fn() })); @@ -33,7 +32,7 @@ describe('gotoLogin', () => { await gotoLogin(); - expect(goto).toHaveBeenCalledExactlyOnceWith(`/login?redirect=${encodeURIComponent(destination)}`, { replaceState: true }); + expect(goto).toHaveBeenCalledExactlyOnceWith(`/login?redirect=${encodeURIComponent(destination)}`, { replace: true }); const loginUrl = new URL(goto.mock.calls[0]![0], page.url.origin); expect(loginUrl.searchParams.get('redirect')).toBe(destination); expect([...loginUrl.searchParams.keys()]).toEqual(['redirect']); @@ -45,7 +44,7 @@ describe('gotoLogin', () => { await gotoLogin(); - expect(goto).toHaveBeenCalledExactlyOnceWith('/login', { replaceState: true }); + expect(goto).toHaveBeenCalledExactlyOnceWith('/login', { replace: true }); }); it('uses a configured login route under an explicit base', async () => { @@ -54,6 +53,6 @@ describe('gotoLogin', () => { await gotoLogin(); - expect(goto).toHaveBeenCalledExactlyOnceWith('/custom/login', { replaceState: true }); + expect(goto).toHaveBeenCalledExactlyOnceWith('/custom/login', { replace: true }); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-host.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-host.svelte.test.ts index e3ec2aca97..528b381ce3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-host.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/components/change-plan-dialog-host.svelte.test.ts @@ -13,7 +13,6 @@ const organizationQuery = vi.hoisted(() => ({ vi.mock('$features/organizations/api.svelte', () => ({ getOrganizationQuery: () => organizationQuery })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); import ChangePlanDialogHost from './change-plan-dialog-host.svelte'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts index c2c581143e..913baba766 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/billing/stripe.svelte.ts @@ -1,6 +1,6 @@ import type { Stripe, StripeElements } from '@stripe/stripe-js'; -import { env } from '$env/dynamic/public'; +import { PUBLIC_STRIPE_PUBLISHABLE_KEY } from '$app/env/public'; import { loadStripe } from '@stripe/stripe-js/pure'; import { getContext, setContext } from 'svelte'; @@ -14,11 +14,11 @@ export interface StripeContext { } export function getStripePublishableKey(): string | undefined { - return env.PUBLIC_STRIPE_PUBLISHABLE_KEY; + return PUBLIC_STRIPE_PUBLISHABLE_KEY; } export function isStripeEnabled(): boolean { - return !!env.PUBLIC_STRIPE_PUBLISHABLE_KEY; + return !!PUBLIC_STRIPE_PUBLISHABLE_KEY; } let _stripePromise: null | Promise = null; @@ -34,7 +34,7 @@ export async function loadStripeOnce(): Promise { } if (!_stripePromise) { - _stripePromise = loadStripe(env.PUBLIC_STRIPE_PUBLISHABLE_KEY!); + _stripePromise = loadStripe(PUBLIC_STRIPE_PUBLISHABLE_KEY!); } try { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts index 67d7e23efc..506f028077 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter.svelte.test.ts @@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({ client: undefined as QueryClientType | undefined, getJSON: vi.fn() })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-session' } })); vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts index a20ffa9a82..5c6dad42fb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/tag-suggestions-api.svelte.test.ts @@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({ createQuery: vi.fn<(options: () => QueryObserverOptions) => void>(), getJSON: vi.fn() })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('$features/auth/index.svelte', () => ({ accessToken: mocks.accessToken })); vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-shell.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-shell.svelte.test.ts index 844cef6232..a09ab0e06d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-shell.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/intercom/intercom-shell.svelte.test.ts @@ -9,8 +9,6 @@ import IntercomShellTestHarness from './intercom-shell.test-harness.svelte'; const intercomShowMessages = vi.hoisted(() => vi.fn()); const intercomUpdate = vi.hoisted(() => vi.fn()); -vi.mock('$env/dynamic/public', () => ({ env: {} })); - vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'token_123' } })); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/redirect.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/redirect.ts new file mode 100644 index 0000000000..7af6968392 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/redirect.ts @@ -0,0 +1,16 @@ +import { goto } from '$app/navigation'; +import { match } from '$app/paths'; + +/** + * Follows a same-origin redirect. `goto` only navigates to app routes, so any other path, + * such as an API endpoint or the documentation, is loaded as a full page instead. + */ +export async function gotoRedirect(url: string, { replace = false }: { replace?: boolean } = {}): Promise { + if (await match(url)) { + await goto(url, { replace }); + } else if (replace) { + window.location.replace(url); + } else { + window.location.assign(url); + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/scoped-dashboard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/scoped-dashboard.svelte index 9c2fed76ee..8cd727c634 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/scoped-dashboard.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/scoped-dashboard.svelte @@ -37,7 +37,7 @@ } const destination = view === 'stacks' ? resolve('/(app)/stack') : resolve('/(app)/event'); void goto(`${destination}?${query}${page.url.hash}`, { - replaceState: true + replace: true }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/visible-url.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/visible-url.ts new file mode 100644 index 0000000000..49f0b500b3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/visible-url.ts @@ -0,0 +1,12 @@ +import type { ReadonlyURL } from '$app/state'; + +import { page } from '$app/state'; + +/** + * The URL in the address bar. Shallow routing, such as query parameter updates and detail + * sheets, changes it without rendering a new page, so `page.url` keeps describing the page + * that was rendered and its query string can be out of date. + */ +export function getVisibleUrl(): ReadonlyURL { + return page.shallow?.url ?? page.url; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte index 239309d107..dc8adeeb76 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/components/notifications.svelte @@ -1,10 +1,10 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts index 01f1d43db2..727e2562b9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -141,7 +141,7 @@ export const productTourCatalog: readonly ProductTourDefinition[] = [ canResume: (checkpoint) => checkpoint === 'open-exie', description: 'Get help understanding errors and finding patterns.', name: 'exie-overview', - start: () => ({ checkpointName: 'open-exie', route: resolve('/') }), + start: () => ({ checkpointName: 'open-exie', route: resolve('/(app)') }), stateKey: 'exie_overview', title: 'Meet Exie' } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts index 3f69579626..21dffb38af 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts @@ -5,7 +5,6 @@ import { MutationObserver, type MutationObserverOptions, QueryClient } from '@ta import { describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ fetchApiJson: vi.fn(), useQueryClient: vi.fn() })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('$features/shared/api/api.svelte', () => ({ fetchApiJson: mocks.fetchApiJson })); vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts index 8e997bbfef..d63516c2fd 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/projects/api.svelte.test.ts @@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({ createQuery: vi.fn<(options: () => QueryObserverOptions) => void>(), getJSON: vi.fn() })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ ...(await importOriginal()), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 84f5c75351..3c2e5903f5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -1,7 +1,8 @@ +import type { ReadonlyURL } from '$app/state'; import type { IFilter } from '$comp/faceted-filter'; import type { ColumnOrderState, ColumnSizingState, ColumnVisibilityState } from '@tanstack/svelte-table'; -import { afterNavigate, goto, replaceState } from '$app/navigation'; +import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { page } from '$app/state'; import { applyTimeFilter, @@ -10,7 +11,9 @@ import { getFiltersFromCache, serializeFilters } from '$features/events/components/filters/helpers.svelte'; +import { getVisibleUrl } from '$features/navigation/visible-url'; import { organization } from '$features/organizations/context.svelte'; +import { updateShallowHistory } from '$features/shared/history-state'; import { getMeQuery } from '$features/users/api.svelte'; import { tick, untrack } from 'svelte'; import { SvelteSet, SvelteURL } from 'svelte/reactivity'; @@ -144,11 +147,6 @@ type SavedViewDraftFilterPageState = App.PageState & { [savedViewDraftFilterHistoryStateKey]?: SavedViewDraftFilterHistoryEntry; }; -type SvelteKitSavedViewDraftFilterHistoryState = { - 'sveltekit:history'?: number; - 'sveltekit:states'?: SavedViewDraftFilterPageState; -}; - const SAVED_VIEW_QUERY_PARAMETER_FILTER_KEYS: readonly string[] = [ 'boolean-bot', 'boolean-first', @@ -163,7 +161,6 @@ const SAVED_VIEW_QUERY_PARAMETER_FILTER_KEYS: readonly string[] = [ 'version-version' ]; const savedViewDraftFilterHistoryStateKey = '__exceptionlessSavedViewDraftFilter'; -const savedViewDraftFilterStoragePrefix = 'exceptionless:saved-view-filter-history:'; export function clearSavedViewQueryParams(queryParams: SavedViewQueryParams): void { queryParams.filter = null; @@ -332,7 +329,7 @@ const SAVED_VIEW_OVERRIDE_QUERY_PARAMETERS = [ 'version' ] as const; -export function getSavedViewOverrideSignature(url: URL): string { +export function getSavedViewOverrideSignature(url: ReadonlyURL): string { return JSON.stringify(SAVED_VIEW_OVERRIDE_QUERY_PARAMETERS.map((parameter) => [parameter, url.searchParams.getAll(parameter)])); } @@ -522,53 +519,10 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return getSavedViewDraftIdentity(view, currentUserQuery.data?.id); } - function getCurrentPageState(): SavedViewDraftFilterPageState { - const historyState = window.history.state as null | SvelteKitSavedViewDraftFilterHistoryState; - return historyState?.['sveltekit:states'] ?? (page.state as SavedViewDraftFilterPageState); - } - - function getCurrentHistoryEntryId(): number | undefined { - return (window.history.state as null | SvelteKitSavedViewDraftFilterHistoryState)?.['sveltekit:history']; - } - - function getStoredDraftFilterHistoryEntry(): SavedViewDraftFilterHistoryEntry | undefined { - const historyEntryId = getCurrentHistoryEntryId(); - if (historyEntryId === undefined) { - return undefined; - } - - try { - return JSON.parse(sessionStorage.getItem(`${savedViewDraftFilterStoragePrefix}${historyEntryId}`) ?? 'null') as - SavedViewDraftFilterHistoryEntry | undefined; - } catch { - return undefined; - } - } - - function storeDraftFilterHistoryEntry(entry: SavedViewDraftFilterHistoryEntry | undefined): void { - const historyEntryId = getCurrentHistoryEntryId(); - if (historyEntryId === undefined) { - return; - } - - try { - const key = `${savedViewDraftFilterStoragePrefix}${historyEntryId}`; - if (entry) { - sessionStorage.setItem(key, JSON.stringify(entry)); - } else { - sessionStorage.removeItem(key); - } - } catch { - // Storage can be unavailable by browser policy; page-state provenance still works until reload. - } - } - - function isDraftGeneratedFilter(viewId: string, url: URL = page.url, state: App.PageState = getCurrentPageState()): boolean { + function isDraftGeneratedFilter(viewId: string, url: ReadonlyURL = page.url, state: App.PageState = page.state): boolean { + // Shallow history writes persist page state, so the marker survives a reload with its entry. const entry = (state as SavedViewDraftFilterPageState)[savedViewDraftFilterHistoryStateKey]; - const filter = url.searchParams.get('filter'); - return ( - isSavedViewDraftFilterHistoryEntry(entry, viewId, filter) || isSavedViewDraftFilterHistoryEntry(getStoredDraftFilterHistoryEntry(), viewId, filter) - ); + return isSavedViewDraftFilterHistoryEntry(entry, viewId, url.searchParams.get('filter')); } async function updateDraftFilterHistoryState(viewId: string, isDraftGenerated: boolean, generation: number): Promise { @@ -580,20 +534,23 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur return; } - const state = { - ...getCurrentPageState() - }; - const filter = new SvelteURL(window.location.href).searchParams.get('filter'); - const entry = createSavedViewDraftFilterHistoryEntry(viewId, filter, isDraftGenerated); - if (entry) { - state[savedViewDraftFilterHistoryStateKey] = entry; - storeDraftFilterHistoryEntry(entry); - } else { - delete state[savedViewDraftFilterHistoryStateKey]; - storeDraftFilterHistoryEntry(undefined); - } + await updateShallowHistory({ + replace: true, + state: (currentState) => { + const state = { + ...currentState + } as SavedViewDraftFilterPageState; + const filter = new SvelteURL(window.location.href).searchParams.get('filter'); + const entry = createSavedViewDraftFilterHistoryEntry(viewId, filter, isDraftGenerated); + if (entry) { + state[savedViewDraftFilterHistoryStateKey] = entry; + } else { + delete state[savedViewDraftFilterHistoryStateKey]; + } - replaceState('', state); + return state; + } + }); } function getServerFilters(view: SavedView): IFilter[] { @@ -610,7 +567,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur serverFilters: IFilter[], currentFilters: IFilter[], draft: SavedViewDraft | undefined, - url: URL = page.url, + url: ReadonlyURL = page.url, draftGeneratedFilter = false ): string[] { const keys: string[] = []; @@ -838,7 +795,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur draftGeneratedFilter: isDraftGeneratedFilter(viewId), filterDefinitions: options.getFilterDefinitions?.() ?? '[]', sort: options.getSort?.() ?? options.queryParams.sort ?? null, - url: new SvelteURL(page.url), + url: new SvelteURL(page.url.href), viewId }; } @@ -1020,7 +977,7 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur const pendingOverrideKeys = draftGeneratedFilter ? [] : getChangedFilterKeys(initialFilters, currentFilters); const overrideKeys = [...new SvelteSet([...initialOverrideKeys, ...pendingOverrideKeys])]; activeFilterOverrideBaselines = buildFilterOverrideBaselines(initialFilters, initialOverrideKeys); - activeSortOverride = (matchingInitialState?.url ?? page.url).searchParams.has('sort') + activeSortOverride = initialUrl.searchParams.has('sort') ? { value: matchingInitialState?.sort ?? options.getSort?.() ?? options.queryParams.sort ?? null } @@ -1233,16 +1190,33 @@ export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsRetur draftHydrationGeneration++; } - afterNavigate(({ from, to }) => { - if (!from?.url || !to?.url || from.url.pathname !== to.url.pathname) { + // SvelteKit reports the rendered page's URL as `from`, but shallow writes such as applied + // draft overrides may have changed the URL the user saw, so compare against that instead. + let visibleUrlBeforeNavigation: ReadonlyURL | undefined; + beforeNavigate(({ shallow, type }) => { + if (shallow && type === 'goto') { + return; + } + + visibleUrlBeforeNavigation = getVisibleUrl(); + }); + + afterNavigate(({ from, shallow, to, type }) => { + if (shallow && type === 'goto') { + return; + } + + const fromUrl = visibleUrlBeforeNavigation ?? from?.url; + visibleUrlBeforeNavigation = undefined; + if (!fromUrl || !to?.url || fromUrl.pathname !== to.url.pathname) { return; } - if (supportsSaved && from.url.searchParams.get('saved') !== to.url.searchParams.get('saved')) { + if (supportsSaved && fromUrl.searchParams.get('saved') !== to.url.searchParams.get('saved')) { return; } - if (getSavedViewOverrideSignature(from.url) !== getSavedViewOverrideSignature(to.url)) { + if (getSavedViewOverrideSignature(fromUrl) !== getSavedViewOverrideSignature(to.url)) { rehydrateQueryOverrides(); } }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/api.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/api.svelte.test.ts index ba4739762b..361489c13d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/api.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/api.svelte.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import { fetchApiJson } from './api.svelte'; -vi.mock('$env/dynamic/public', () => ({ env: { PUBLIC_BASE_URL: 'https://localhost:8443/' } })); +vi.mock('$features/shared/environment', () => ({ PUBLIC_BASE_URL: 'https://localhost:8443/' })); vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'existing-session' } })); afterEach(() => vi.unstubAllGlobals()); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/query-gating.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/query-gating.svelte.test.ts index 03a32505a3..1172364008 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/query-gating.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/query-gating.svelte.test.ts @@ -7,7 +7,6 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import QueryGateTestHarness from './query-gating.test-harness.svelte'; const mocks = vi.hoisted(() => ({ getJSON: vi.fn() })); -vi.mock('$env/dynamic/public', () => ({ env: {} })); vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ ...(await importOriginal()), useFetchClient: () => ({ getJSON: mocks.getJSON }) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.test.ts index 676d54fddd..acee629066 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.test.ts @@ -3,7 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getApiUrl, getServerUrl } from './urls'; const { env } = vi.hoisted(() => ({ env: { PUBLIC_BASE_URL: '' } })); -vi.mock('$env/dynamic/public', () => ({ env })); +vi.mock('$features/shared/environment', () => ({ + get PUBLIC_BASE_URL() { + return env.PUBLIC_BASE_URL; + } +})); beforeEach(() => { vi.resetModules(); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.ts index ff4001de06..e5080f384b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/api/urls.ts @@ -1,4 +1,4 @@ -import { env } from '$env/dynamic/public'; +import { PUBLIC_BASE_URL } from '$features/shared/environment'; export function getApiUrl(path = ''): string { return getServerUrl(`api/v2${path ? `/${path.replace(/^\/+/, '')}` : ''}`); @@ -6,6 +6,6 @@ export function getApiUrl(path = ''): string { /** EX_ApiUrl may point to a separate API origin; an unset value uses the UI origin. */ export function getServerUrl(path: string): string { - const base = (env.PUBLIC_BASE_URL ?? '').trim().replace(/\/+$/, ''); + const base = (PUBLIC_BASE_URL ?? '').trim().replace(/\/+$/, ''); return `${base}/${path.replace(/^\/+/, '')}`; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/detail-sheet.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/detail-sheet.svelte index 0b94f14da4..050547a410 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/detail-sheet.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/detail-sheet.svelte @@ -1,20 +1,39 @@ -