diff --git a/.agents/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md index a2965c6298..f01e6de497 100644 --- a/.agents/skills/frontend-architecture/SKILL.md +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -17,7 +17,7 @@ Keep feature behavior in `src/lib/features//`: API operations in `api.s ## Local UI conventions -- Reuse `$comp`, `$shared`, and `$lib` helpers. In particular, use `createQueryParameters` for route query binding. +- Reuse `#comp`, `#shared`, and `#lib` helpers. In particular, use `createQueryParameters` for route query binding. - Use shared formatter components in `src/lib/features/shared/components/formatters/` for dates, relative time, duration, bytes, numbers, currency, percentages, booleans, and date math. - Match the existing dense, restrained operational UI and its theme tokens. - Use `href` for ordinary navigation; use action handlers when work must finish before navigating. @@ -26,8 +26,10 @@ Keep feature behavior in `src/lib/features//`: API operations in `api.s ## Svelte and TypeScript details -- 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. +- 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`. +- The `#` aliases are subpath imports declared in `package.json`. Name the file on disk in each specifier: `#lib/utils.ts`, `#features/auth/index.svelte.ts`, `#comp/ui/button/index.ts` for a barrel, or `#comp/logo.svelte` for a component. +- For shallow routing, write history through `updateShallowHistory` in `#shared/history-state.ts` and read the address-bar URL with `getVisibleUrl()` from `#features/navigation/visible-url.ts`; `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/AGENTS.md b/src/Exceptionless.Web/ClientApp/AGENTS.md index 6b99ff1405..b08df89025 100644 --- a/src/Exceptionless.Web/ClientApp/AGENTS.md +++ b/src/Exceptionless.Web/ClientApp/AGENTS.md @@ -31,14 +31,14 @@ Use focused verification while iterating. Do not run broad Svelte validation aft - Organize code by feature under `src/lib/features`; match the nearest existing feature before adding files. - Use generated API types and feature-local `api.svelte.ts`, `models.ts`, `schemas.ts`, and `validators.ts` patterns. - Use TanStack Query for server state and TanStack Form with Zod for forms. -- Use `createQueryParameters` from `$shared/query-params` for route query parameters instead of ad-hoc URL parsing. -- Prefer shared components and formatters from `$comp`, `$shared`, and `$lib` before creating new primitives. -- Use installed shadcn-svelte components from `$comp/ui/*`; check `components.json` and the `src/lib/features/shared/components/ui` directory before importing a component. +- Use `createQueryParameters` from `#shared/query-params/index.ts` for route query parameters instead of ad-hoc URL parsing. +- Prefer shared components and formatters from `#comp`, `#shared`, and `#lib` before creating new primitives. These are `package.json` subpath imports, so specifiers name the file on disk, such as `#lib/utils.ts`, `#comp/ui/button/index.ts`, or `#comp/logo.svelte`. +- Use installed shadcn-svelte components from `#comp/ui/*`; check `components.json` and the `src/lib/features/shared/components/ui` directory before importing a component. ## Applying Installed Skills - Repository conventions govern application changes when an upstream skill uses generic examples. Keep the existing operational UI, theme, and typography; an ordinary component change does not require a redesign. -- Run component CLI commands from this directory with `npx shadcn-svelte@latest`. Resolve imports through `components.json`; this app uses `$comp/ui/*`. Use `@lucide/svelte` unless the app's configured icon library changes. +- Run component CLI commands from this directory with `npx shadcn-svelte@latest`. Resolve imports through `components.json`; this app uses `#comp/ui/*`. The CLI writes `.js` specifiers, so rename them to the `.ts` files they import after adding a component. Use `@lucide/svelte` unless the app's configured icon library changes. - Import only installed components. Use existing `RadioGroup` or `Select` for options and `Card`, `Alert`, or other installed primitives for empty states; add `ToggleGroup`, `Empty`, or other registry components only when the requested design needs them. ## Local URLs diff --git a/src/Exceptionless.Web/ClientApp/components.json b/src/Exceptionless.Web/ClientApp/components.json index b7d1927f0b..6e6c29cec8 100644 --- a/src/Exceptionless.Web/ClientApp/components.json +++ b/src/Exceptionless.Web/ClientApp/components.json @@ -5,11 +5,11 @@ "baseColor": "zinc" }, "aliases": { - "components": "$comp", - "utils": "$lib/utils", - "ui": "$comp/ui", - "hooks": "$lib/hooks", - "lib": "$lib" + "components": "#comp", + "utils": "#lib/utils", + "ui": "#comp/ui", + "hooks": "#lib/hooks", + "lib": "#lib" }, "typescript": true, "registry": "https://shadcn-svelte.com/registry" 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/support/table-layout.ts b/src/Exceptionless.Web/ClientApp/e2e/support/table-layout.ts index bb99569ced..7f68663d78 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/table-layout.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/table-layout.ts @@ -4,7 +4,11 @@ const ORGANIZATION_ID = '000000000000000000000001'; const PROJECT_ID = '000000000000000000000002'; const USER_ID = '000000000000000000000003'; -export async function mockTablePage(page: Page, rowCount: number): Promise { +export interface MockTablePageOptions { + hasPremiumFeatures?: boolean; +} + +export async function mockTablePage(page: Page, rowCount: number, { hasPremiumFeatures = true }: MockTablePageOptions = {}): Promise { await page.addInitScript( ({ organizationId }) => { window.localStorage.setItem('satellizer_token', 'table-layout-token'); @@ -14,15 +18,15 @@ export async function mockTablePage(page: Page, rowCount: number): Promise ); await page.routeWebSocket('**/api/v2/push*', () => {}); await page.route('**/health', (route) => route.fulfill({ body: 'OK', contentType: 'text/plain' })); - await page.route('**/api/v2/**', (route) => fulfillTablePageRequest(route, rowCount)); + await page.route('**/api/v2/**', (route) => fulfillTablePageRequest(route, rowCount, hasPremiumFeatures)); } -async function fulfillTablePageRequest(route: Route, rowCount: number): Promise { +async function fulfillTablePageRequest(route: Route, rowCount: number, hasPremiumFeatures: boolean): Promise { const url = new URL(route.request().url()); const path = url.pathname; const organization = { features: [], - has_premium_features: true, + has_premium_features: hasPremiumFeatures, id: ORGANIZATION_ID, name: 'Layout Organization', plan_id: 'EX_UNLIMITED', 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/premium-notice-navigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/premium-notice-navigation.e2e.ts new file mode 100644 index 0000000000..6ca8a80e7a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/premium-notice-navigation.e2e.ts @@ -0,0 +1,38 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { mockTablePage } from '../support/table-layout'; + +test.afterEach(async ({ page }) => { + expect(await page.pageErrors()).toEqual([]); +}); + +test('the Sessions premium notice stays visible when Back returns to an earlier filter', async ({ page }) => { + // Arrange + await page.clock.install(); + await mockTablePage(page, 0, { hasPremiumFeatures: false }); + await page.goto('/sessions'); + const notice = page.getByText('to enable Sessions and other premium features!'); + await expect(notice).toBeVisible(); + + // Act: each filter change adds a query history entry to the same page, and Back returns to one. + await selectDateRange(page, 'Last 7 days', 'Last 30 days'); + // Rapid changes share one entry, so let the first one settle. + await page.clock.fastForward(250); + await selectDateRange(page, 'Last 30 days', 'Last 24 hours'); + await page.goBack(); + + // Assert + await expect(page).toHaveURL(/[?&]time=30d(?:&|$)/); + await expect(dateFilter(page, 'Last 30 days')).toBeVisible(); + await expect(notice).toBeVisible(); +}); + +function dateFilter(page: Page, range: string) { + return page.getByRole('button', { exact: true, name: `Date ${range}` }); +} + +async function selectDateRange(page: Page, from: string, to: string): Promise { + await dateFilter(page, from).click(); + await page.getByRole('button', { exact: true, name: to }).click(); + await expect(dateFilter(page, to)).toBeVisible(); +} 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..9de4671196 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 @@ -97,6 +97,9 @@ test('home navigation honors personal and organization saved views and survives await expect(page).toHaveURL(new RegExp(`/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`)); }); + // Assert: switching home views reuses in-flight requests instead of cancelling them. + expect(failedApiRequests).toEqual([]); + await test.step('clear deleted defaults and return to the first Stacks saved view', async () => { // Act const deletion = await page.evaluate( @@ -113,17 +116,16 @@ test('home navigation honors personal and organization saved views and survives }, { organizationId: e2eScenario.organizationId, token: e2eScenario.userToken, viewName } ); - // Assert + // Assert: the open view no longer exists. The full-page error replaces the app shell, + // which cancels the shell's in-flight requests. expect(deletion).toBe(202); + await expect(page.getByRole('heading', { name: "We couldn't find that page" })).toBeVisible(); // Act await page.goto('/'); // Assert await expect(page).toHaveURL(/\/stack\/all(?:[?#]|$)/); }); - - // Assert - expect(failedApiRequests).toEqual([]); }); test('saved view navigation order is personal, persistent, and resettable', async ({ e2eScenario, page, request }) => { @@ -857,12 +859,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 +889,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..647ca3e1d1 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", @@ -112,12 +113,20 @@ "tw-animate-css": "^1.4.0" }, "type": "module", + "imports": { + "#comp/*": "./src/lib/features/shared/components/*", + "#features/*": "./src/lib/features/*", + "#generated/*": "./src/lib/generated/*", + "#lib": "./src/lib/index.ts", + "#lib/*": "./src/lib/*", + "#shared/*": "./src/lib/features/shared/*" + }, "overrides": { "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.test.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.test.ts new file mode 100644 index 0000000000..463fa4b53c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.test.ts @@ -0,0 +1,63 @@ +import type { HandleClientError } from '@sveltejs/kit/hooks'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleError } from './hooks.client'; + +const telemetry = vi.hoisted(() => { + const builder = { + setProperty: vi.fn(() => builder), + setReferenceId: vi.fn(() => builder), + submit: vi.fn(() => Promise.resolve()) + }; + + return { + builder, + createException: vi.fn(() => builder), + createNotFound: vi.fn(() => builder) + }; +}); + +vi.mock('$app/env', () => ({ browser: false, dev: false })); +vi.mock('#lib/telemetry/svelte-effect-depth-diagnostics.ts', () => ({ installSvelteEffectDepthDiagnostics: vi.fn() })); +vi.mock('@exceptionless/browser', () => ({ + Exceptionless: { createException: telemetry.createException, createNotFound: telemetry.createNotFound }, + guid: () => 'client-error-0001', + toError: (error: unknown) => (error instanceof Error ? error : new Error(String(error))) +})); + +type HandleErrorInput = Parameters[0]; + +const event = { params: {}, route: { id: '/(app)/stream' }, url: new URL('http://localhost/stream') } as HandleErrorInput['event']; + +describe('handleError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each<{ input: HandleErrorInput; name: string; submittedWith: typeof telemetry.createException }>([ + { input: { error: new Error('Render failed'), event, kind: 'unknown' }, name: 'an unexpected error', submittedWith: telemetry.createException }, + { input: { error: { message: 'Not Found', status: 404 }, event, kind: 'framework' }, name: 'a missing route', submittedWith: telemetry.createNotFound } + ])('returns an error id at once for $name and tags the submitted event with it', ({ input, submittedWith }) => { + // Act + const result = handleError(input); + + // Assert + expect(result).toEqual({ errorId: 'client-error-0001' }); + expect(submittedWith).toHaveBeenCalledOnce(); + expect(telemetry.builder.setReferenceId).toHaveBeenCalledExactlyOnceWith('client-error-0001'); + expect(telemetry.builder.submit).toHaveBeenCalledOnce(); + }); + + it('returns expected errors without submitting them', () => { + // Arrange + const error = { message: 'The saved view could not be found.', status: 404 }; + + // Act + const result = handleError({ error, event, kind: 'app' }); + + // Assert + expect(result).toBe(error); + expect(telemetry.builder.submit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts index 8e7e8c4c9f..1edfd6f16b 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.client.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.client.ts @@ -1,17 +1,20 @@ -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 { normalizePath, normalizeRouteId } from '$lib/telemetry'; -import { installSvelteEffectDepthDiagnostics } from '$lib/telemetry/svelte-effect-depth-diagnostics'; import { Exceptionless, guid, toError } from '@exceptionless/browser'; import { useMiddleware } from '@foundatiofx/fetchclient'; import { config } from 'zod'; +import { configureSessions } from '#features/auth/exceptionless-session.ts'; +import { getHttpsRedirectUrl } from '#features/navigation/https-redirect.ts'; +import { canonicalAppUrl } from '#features/navigation/legacy-links.ts'; +import { getEnvironmentOverride, PUBLIC_EXCEPTIONLESS_API_KEY, PUBLIC_EXCEPTIONLESS_SERVER_URL } from '#features/shared/environment.ts'; +import { trackHistoryTraversals } from '#features/shared/history-traversal.ts'; +import { normalizePath, normalizeRouteId } from '#lib/telemetry/index.ts'; +import { installSvelteEffectDepthDiagnostics } from '#lib/telemetry/svelte-effect-depth-diagnostics.ts'; + // Zod's object-validator JIT uses Function(), which strict CSP blocks without unsafe-eval. // Use its interpreted validator instead of weakening script-src (validation rules are unchanged). // https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts @@ -19,23 +22,6 @@ config({ jitless: true }); 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) { @@ -44,7 +30,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. @@ -55,19 +41,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) { @@ -120,25 +109,36 @@ export const init: ClientInit = async () => { }); }; -export const handleError: HandleClientError = async ({ error, event, message, status }) => { +// SvelteKit 3 needs Svelte's async mode for an async hook, which this app leaves off. The hook +// returns at once and submits in the background, so telemetry never delays the error page. +export const handleError: HandleClientError = (input) => { + const { error, event, kind } = input; if (dev) { - console.warn({ error, event, message, status }); + console.warn({ error, event, kind }); } - let errorId: null | string = null; - try { - if (status === 404) { - await Exceptionless.submitNotFound(normalizePath(event.url.pathname)); - } else { - await Exceptionless.createException(toError(error ?? message)) - .setProperty('status', String(status)) - .submit(); - } + // Errors created with error(...) are expected and already safe to show. + if (kind === 'app') { + return error; + } + + const errorId = guid(); + void submitError(errorId, input); + return { errorId }; +}; - errorId = Exceptionless.getLastReferenceId(); +async function submitError(errorId: string, { error, event, kind }: Parameters[0]): Promise { + try { + const builder = + kind === 'framework' && error.status === 404 + ? Exceptionless.createNotFound(normalizePath(event.url.pathname)) + : Exceptionless.createException(toError(kind === 'framework' ? error.message : error)).setProperty( + 'status', + String(kind === 'framework' ? error.status : 500) + ); + + await builder.setReferenceId(errorId).submit(); } catch { // never throw } - - return { errorId: errorId ?? guid(), message }; -}; +} diff --git a/src/Exceptionless.Web/ClientApp/src/hooks.ts b/src/Exceptionless.Web/ClientApp/src/hooks.ts index 604ad1bc5f..64c883b5e1 100644 --- a/src/Exceptionless.Web/ClientApp/src/hooks.ts +++ b/src/Exceptionless.Web/ClientApp/src/hooks.ts @@ -1,6 +1,6 @@ -import type { Reroute } from '@sveltejs/kit'; +import type { Reroute } from '@sveltejs/kit/hooks'; -import { canonicalAppUrl, isSignInCallback } from '$features/navigation/legacy-links'; +import { canonicalAppUrl, isSignInCallback } from '#features/navigation/legacy-links.ts'; export const reroute: Reroute = ({ url }) => { const canonical = canonicalAppUrl(url); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts index a10d835149..3e1b05d92a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/api.svelte.ts @@ -1,9 +1,10 @@ -import type { WorkInProgressResult } from '$generated/api'; - -import { invalidateAssistantAccessQueries } from '$features/assistant/api.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; +import type { WorkInProgressResult } from '#generated/api.ts'; + +import { invalidateAssistantAccessQueries } from '#features/assistant/api.svelte.ts'; + import type { AdminAssistantSettings, AdminAssistantUsage, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/assistant-settings.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/assistant-settings.svelte index 6d4330ab5a..00b47edb71 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/assistant-settings.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/assistant-settings.svelte @@ -1,23 +1,24 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte index 6bb6136d20..88d18ef7bf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-expand-cell.svelte @@ -1,11 +1,13 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte index b93192b2f1..69a703fccb 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-organizations-cell.svelte @@ -1,8 +1,9 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-scopes-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-scopes-cell.svelte index a2903e0722..a1566a2674 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-scopes-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/admin/components/oauth-applications/table/oauth-application-scopes-cell.svelte @@ -1,8 +1,8 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.test.ts new file mode 100644 index 0000000000..a9f56bddcc --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.test.ts @@ -0,0 +1,24 @@ +import type { NavigationBase, NavigationTarget } from '$app/navigation'; + +import { describe, expect, it } from 'vitest'; + +import { isShallowNavigationWithinPage } from './shallow-navigation'; + +function target(routeId: string): NavigationTarget { + return { params: {}, route: { id: routeId }, scroll: null, url: new URL(`http://localhost${routeId}`) } as NavigationTarget; +} + +describe('isShallowNavigationWithinPage', () => { + it.each<{ expected: boolean; name: string; navigation: Pick }>([ + { expected: true, name: 'a shallow navigation on the same route', navigation: { from: target('/sessions'), shallow: true, to: target('/sessions') } }, + { expected: false, name: 'Back or Forward into another route', navigation: { from: target('/stream'), shallow: true, to: target('/sessions') } }, + { expected: false, name: 'a full navigation on the same route', navigation: { from: target('/sessions'), shallow: false, to: target('/sessions') } }, + { expected: false, name: 'the first navigation', navigation: { from: null, shallow: false, to: target('/sessions') } } + ])('returns $expected for $name', ({ expected, navigation }) => { + // Act + const result = isShallowNavigationWithinPage(navigation); + + // Assert + expect(result).toBe(expected); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.ts new file mode 100644 index 0000000000..aa3a43f588 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/navigation/shallow-navigation.ts @@ -0,0 +1,11 @@ +import type { NavigationBase } from '$app/navigation'; + +/** + * Whether a navigation stays on the page that is already rendered. SvelteKit runs the + * navigation hooks with `shallow: true` for a shallow `goto` and for Back or Forward to a + * shallow history entry, such as a query parameter update. Both keep the same page mounted + * unless Back or Forward crosses into another route's entry. + */ +export function isShallowNavigationWithinPage({ from, shallow, to }: Pick): boolean { + return shallow && from?.route.id === to?.route.id; +} 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/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts index 37d6568f8e..6ba2e1d15f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/notifications/api.svelte.ts @@ -1,8 +1,8 @@ -import type { SystemNotification } from '$features/websockets/models'; - import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'; +import type { SystemNotification } from '#features/websockets/models.ts'; + export const queryKeys = { current: ['notifications', 'system'] as const }; 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..c64c61cc78 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,12 +1,5 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts index b46af15a25..a6fda428b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/index.ts @@ -7,7 +7,7 @@ import RequestLimitNotification from './request-limit-notification.svelte'; import SetupFirstProjectNotification from './setup-first-project-notification.svelte'; import SuspendedOrganizationNotification from './suspended-organization-notification.svelte'; -export type { NotificationProps } from '$comp/notification'; +export type { NotificationProps } from '#comp/notification/index.ts'; export { FreePlanNotification, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte index 56d24944ed..9f2c14c955 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/monthly-overage-notification.svelte @@ -1,9 +1,9 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/suspended-organization-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/suspended-organization-notification.svelte index 4279a5a8f4..e48acd2b4a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/suspended-organization-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/suspended-organization-notification.svelte @@ -1,10 +1,11 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte index dba195af77..36bef2e5c0 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -1,19 +1,20 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-badge-value.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-badge-value.svelte index 2a7f1da971..a2e5143698 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-badge-value.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-badge-value.svelte @@ -1,7 +1,7 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/navigation/nav-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/navigation/nav-item.svelte index 2e7c13d54b..a1d3acc572 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/navigation/nav-item.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/navigation/nav-item.svelte @@ -2,10 +2,11 @@ import type { HTMLAttributes } from 'svelte/elements'; import { page } from '$app/state'; - import { Button } from '$comp/ui/button'; import { cubicInOut } from 'svelte/easing'; import { crossfade } from 'svelte/transition'; + import { Button } from '#comp/ui/button/index.ts'; + type Props = HTMLAttributes & { href: string; }; @@ -19,7 +20,7 @@ }); - - - - - + + + + + -{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..11799f4534 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 type { QueryParameterInput, QueryParameterSchema, QueryParameterState, QueryParameterType, QueryParameterValue } from './types.ts'; -import { coerceQueryParameter } from './coerce.js'; +import { coerceQueryParameter, getListItemType } from './coerce.ts'; 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; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/shallow-routing.test-fake.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/shallow-routing.test-fake.svelte.ts new file mode 100644 index 0000000000..09c20432ae --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/shallow-routing.test-fake.svelte.ts @@ -0,0 +1,256 @@ +import type { AfterNavigate, BeforeNavigate, GotoOptions, NavigationType } from '$app/navigation'; + +import { flushSync, onDestroy, tick } from 'svelte'; +import { SvelteURL } from 'svelte/reactivity'; +import { vi } from 'vitest'; + +import { trackHistoryTraversals } from './history-traversal'; + +/** + * A stand-in for SvelteKit 3 shallow routing in unit tests. A shallow `goto` runs the + * navigation hooks with `shallow: true`, writes the history entry, and then updates + * `page.state`. Back and Forward restore the URL first. As in SvelteKit, an entry with the + * rendered page's URL gets its page state in a popstate listener. An entry with another URL, + * such as a query parameter update, gets it during a `popstate` navigation that runs the + * navigation hooks with `shallow: true`. + * + * Mock `$app/navigation` and `$app/state` with the exports of `shallowRouting`. + */ +const pageStateKey = 'testPageState'; +const afterNavigateCallbacks = new Set<(navigation: AfterNavigate) => void>(); +const beforeNavigateCallbacks = new Set<(navigation: BeforeNavigate) => void>(); + +class FakePage { + public shallow = $state.raw(null); + public state = $state.raw({}); + public url = $state.raw(new SvelteURL('http://localhost/')); +} + +const page = new FakePage(); +const navigating = { + complete: null as null | Promise +}; + +function getHistoryEntryState(historyState: unknown): App.PageState { + return (historyState as null | Record)?.[pageStateKey] ?? {}; +} + +function setShallowUrl(): void { + page.shallow = + window.location.href === page.url.href + ? null + : { + url: new SvelteURL(window.location.href) + }; +} + +// As in the app's client `init` hook, count traversals before the router starts listening. +trackHistoryTraversals(); + +// Like SvelteKit's router, apply the destination's page state in a popstate listener that is +// registered before any component's. Browsers run a microtask checkpoint after each listener, +// so effects that track `page.state` run before later listeners do; `flushSync` models that. +window.addEventListener('popstate', (event) => { + const state = getHistoryEntryState(event.state); + if (window.location.href !== page.url.href) { + void navigateToShallowEntry(state); + return; + } + + page.state = state; + setShallowUrl(); + flushSync(); +}); + +function createNavigation(type: NavigationType, to: URL, shallow: boolean, willUnload = false) { + return { + complete: Promise.resolve(), + delta: undefined, + event: undefined, + from: { + params: {}, + route: { + id: null + }, + scroll: null, + url: page.url + }, + shallow, + to: { + params: {}, + route: { + id: null + }, + scroll: null, + url: to + }, + type, + willUnload + }; +} + +/** SvelteKit resolves the route first, so the hooks run after the other popstate listeners. */ +async function navigateToShallowEntry(state: App.PageState): Promise { + await Promise.resolve(); + const navigation = createNavigation('popstate', new SvelteURL(window.location.href), true); + for (const callback of beforeNavigateCallbacks) { + callback({ + ...navigation, + cancel: () => undefined + } as BeforeNavigate); + } + + page.state = state; + setShallowUrl(); + flushSync(); + for (const callback of afterNavigateCallbacks) { + callback(navigation as AfterNavigate); + } +} + +function register(callbacks: Set, callback: T): void { + callbacks.add(callback); + onDestroy(() => callbacks.delete(callback)); +} + +export const shallowRouting = { + afterNavigate: vi.fn((callback: (navigation: AfterNavigate) => void) => register(afterNavigateCallbacks, callback)), + + /** Runs the `afterNavigate` hooks for a navigation that rendered the current page. */ + afterNavigating(type: Exclude = 'link'): void { + const navigation = createNavigation(type, page.url, false); + for (const callback of afterNavigateCallbacks) { + callback(navigation as AfterNavigate); + } + }, + + /** Runs the `beforeNavigate` hooks for a navigation away from the page and returns its `cancel` spy. */ + beforeLeaving(type: Exclude, to = '/elsewhere', willUnload = type === 'leave'): ReturnType { + const cancel = vi.fn(); + const navigation = createNavigation(type, new SvelteURL(to, window.location.href), false, willUnload); + for (const callback of beforeNavigateCallbacks) { + callback({ + ...navigation, + cancel + } as BeforeNavigate); + } + + return cancel; + }, + + beforeNavigate: vi.fn((callback: (navigation: BeforeNavigate) => void) => register(beforeNavigateCallbacks, callback)), + + /** Applies pending shallow writes and the reactive updates that follow them. */ + async flush(): Promise { + for (let index = 0; index < 10; index++) { + await Promise.resolve(); + } + + await tick(); + }, + + goto: vi.fn(async (url: string | URL, { replace = false, shallow = false, state = {} }: GotoOptions = {}) => { + const target = new SvelteURL(url, window.location.href); + if (!shallow) { + return; + } + + const navigation = createNavigation('goto', target, true); + let cancelled = false; + for (const callback of beforeNavigateCallbacks) { + callback({ + ...navigation, + cancel: () => (cancelled = true) + } as BeforeNavigate); + } + + if (cancelled) { + return; + } + + if (replace) { + window.history.replaceState( + { + [pageStateKey]: state + }, + '', + target + ); + } else { + window.history.pushState( + { + [pageStateKey]: state + }, + '', + target + ); + } + + await Promise.resolve(); + page.state = state; + page.shallow = { + url: target + }; + for (const callback of afterNavigateCallbacks) { + callback(navigation as AfterNavigate); + } + }), + + /** Returns the page state stored with the current history entry. */ + historyEntryState(): App.PageState | undefined { + return (window.history.state as null | Record)?.[pageStateKey]; + }, + + navigating, + + page, + + /** Starts a test on a rendered page at `url` whose history entry has `state`. */ + reset(url = '/', state: App.PageState = {}): void { + shallowRouting.goto.mockClear(); + shallowRouting.visit(url, state); + }, + + /** Marks a navigation as in progress and returns a function that finishes it. */ + startNavigation(): () => Promise { + const { promise, resolve } = Promise.withResolvers(); + navigating.complete = promise; + return async () => { + navigating.complete = null; + resolve(); + await shallowRouting.flush(); + }; + }, + + /** Simulates Back or Forward to an entry with `url` and its stored page state. */ + async traverse(url: string, state: App.PageState = {}): Promise { + window.history.replaceState( + { + [pageStateKey]: state + }, + '', + url + ); + window.dispatchEvent( + new PopStateEvent('popstate', { + state: window.history.state + }) + ); + await shallowRouting.flush(); + }, + + /** Renders the page for an entry with `url` and `state`, as a navigation or reload does. */ + visit(url: string, state: App.PageState = {}): void { + window.history.replaceState( + { + [pageStateKey]: state + }, + '', + url + ); + page.state = state; + page.url = new SvelteURL(window.location.href); + setShallowUrl(); + navigating.complete = null; + } +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.test.ts index 2df4952a61..b6c1447616 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/utils/datemath.test.ts @@ -1,6 +1,7 @@ -import { type QuickRangeOption, quickRanges } from '$features/shared/components/date-range-picker/quick-ranges'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type QuickRangeOption, quickRanges } from '#features/shared/components/date-range-picker/quick-ranges.ts'; + import { extractRangeExpressions, getDateMathValidationError, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts index f97b41a497..24a6277315 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.svelte.ts @@ -1,11 +1,12 @@ -import type { WebSocketMessageValue } from '$features/websockets/models'; -import type { WorkInProgressResult } from '$shared/models'; - -import { accessToken } from '$features/auth/index.svelte'; import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; import { SvelteSet } from 'svelte/reactivity'; +import type { WebSocketMessageValue } from '#features/websockets/models.ts'; +import type { WorkInProgressResult } from '#shared/models/index.ts'; + +import { accessToken } from '#features/auth/index.svelte.ts'; + import type { Stack, StackStatus } from './models'; export interface ProjectStackNotificationRefresher { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.test.ts index ed7c1598ac..a7d3b580a6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/api.test.ts @@ -1,7 +1,8 @@ -import { ChangeType } from '$features/websockets/models'; import { QueryClient } from '@tanstack/svelte-query'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChangeType } from '#features/websockets/models.ts'; + import { createProjectStackNotificationRefresher, invalidateStackQueries, queryKeys, STACK_NOTIFICATION_THROTTLE_MS } from './api.svelte'; afterEach(() => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/add-stack-reference-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/add-stack-reference-dialog.svelte index f81d43d0e3..7e570a3f0e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/add-stack-reference-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/dialogs/add-stack-reference-dialog.svelte @@ -1,14 +1,15 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index d5aca498c6..d0a5d69d1f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -1,20 +1,6 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte index 9b705c95d5..3e05faebe0 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/+layout.svelte @@ -1,9 +1,10 @@
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 1778786dfd..bf58b7f9e3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -1,33 +1,27 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte index 4c80f77680..b0af296f6a 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte @@ -1,18 +1,19 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index 365da227dc..365323a751 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -1,12 +1,13 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; -import { organization } from '$features/organizations/context.svelte'; import Usage from '@lucide/svelte/icons/bar-chart'; import Billing from '@lucide/svelte/icons/credit-card'; import Settings from '@lucide/svelte/icons/settings'; import Users from '@lucide/svelte/icons/users'; import Zap from '@lucide/svelte/icons/zap'; +import { organization } from '#features/organizations/context.svelte.ts'; + import type { NavigationItem } from '../../../routes.svelte'; export function routes(): NavigationItem[] { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/upgrade/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/upgrade/+page.svelte index 697da5183a..718fc54f4f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/upgrade/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/upgrade/+page.svelte @@ -9,12 +9,12 @@ const path = resolve('/(app)/organization/[organizationId]/billing', { organizationId: page.params.organizationId! }); - const destination = new SvelteURL(path, page.url); + const destination = new SvelteURL(path, page.url.href); destination.search = page.url.search; destination.hash = page.url.hash; destination.searchParams.set('changePlan', 'true'); void goto(destination, { - replaceState: true + replace: true }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte index d0a4b1a289..454149affb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/usage/+page.svelte @@ -1,21 +1,22 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte index 33b17020cf..d5603bd9b2 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/integrations/+page.svelte @@ -1,36 +1,37 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte index 3d0355a28b..2bc55c0a36 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/actions/[[category]]/+page.svelte @@ -1,20 +1,21 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte index 348e46f1b2..086c0efb26 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/elasticsearch/backups/+page.svelte @@ -1,15 +1,16 @@
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte index a6acadcead..926652a3db 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/system/overview/+page.svelte @@ -1,11 +1,4 @@