diff --git a/package.json b/package.json index 67663eb69..620af1ac0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "deploy:publish:bootstrap": "bash ./scripts/publish/bootstrap-npm-package.sh", "dev": "concurrently \"pnpm run watch\" \"vite dev --open\"", "dev:ssh": "pnpm run setup-dev-ssh && export SSH=true && pnpm run dev", + "dev:nuqs": "vite --config test-server/nuqs-spa/vite.config.mjs", "dev:trc": "concurrently \"pnpm run watch\" \"vite --config test-server/session-replay-browser/trc-e2e/vite.config.mjs\"", "dev:trc:npm": "SR_MODE=npm vite --config test-server/session-replay-browser/trc-e2e/vite.config.mjs --force", "docs": "typedoc", @@ -85,6 +86,7 @@ "lint-staged": "^15.2.0", "morgan": "^1.10.0", "nodemon": "^3.0.1", + "nuqs": "^2.10.2", "nx": "^21.2.1", "prettier": "^2.8.1", "prismjs": "^1.30.0", diff --git a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts index 2548c1434..87a144569 100644 --- a/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts +++ b/packages/plugin-page-view-tracking-browser/src/page-view-tracking.ts @@ -136,16 +136,21 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op /* istanbul ignore next */ // There is no global browser listener for changes to history, so we have - // to modify pushState directly. + // to modify pushState and replaceState directly. // https://stackoverflow.com/a/64927639 - // eslint-disable-next-line @typescript-eslint/unbound-method - globalScope.history.pushState = new Proxy(globalScope.history.pushState, { - apply: (target, thisArg, [state, unused, url]) => { - target.apply(thisArg, [state, unused, url]); - if (isTracking) { - handlePageChange(); - } - }, + (['pushState', 'replaceState'] as const).forEach((method) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + globalScope.history[method] = new Proxy(globalScope.history[method], { + apply: (target, thisArg, args: Parameters) => { + const prevUrl = location.href; + const result = target.apply(thisArg, args); + // Routers also replace state during hydration without navigating. + if (isTracking && (method === 'pushState' || location.href !== prevUrl)) { + handlePageChange(); + } + return result; + }, + }); }); } diff --git a/packages/plugin-page-view-tracking-browser/test/history.test.ts b/packages/plugin-page-view-tracking-browser/test/history.test.ts new file mode 100644 index 000000000..9035f0b6c --- /dev/null +++ b/packages/plugin-page-view-tracking-browser/test/history.test.ts @@ -0,0 +1,277 @@ +import { + BrowserConfig, + BASE_CAMPAIGN, + CampaignParser, + CookieStorage, + FetchTransport, + LogLevel, + EnrichmentPlugin, + Logger, + clearPageViewState, + clearSdkInitializationOffset, +} from '@amplitude/analytics-core'; +import { pageViewTrackingPlugin } from '../src/page-view-tracking'; +import { Options } from '../src/typings/page-view-tracking'; +import { createMockBrowserClient } from './mock-browser-client'; + +// Exercise the native history implementation: the URL changes inside the wrapped call. +describe('page view history tracking', () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalPushState = window.history.pushState; + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalReplaceState = window.history.replaceState; + const config: BrowserConfig = { + apiKey: 'history-test', + flushIntervalMillis: 0, + flushMaxRetries: 0, + flushQueueSize: 0, + logLevel: LogLevel.None, + loggerProvider: new Logger(), + offline: false, + optOut: false, + serverUrl: undefined, + transportProvider: new FetchTransport(), + useBatch: false, + cookieOptions: { + domain: '.amplitude.com', + expiration: 365, + sameSite: 'Lax', + secure: false, + upgrade: true, + }, + cookieStorage: new CookieStorage(), + sessionTimeout: 30 * 60 * 1000, + trackingOptions: { + ipAddress: true, + language: true, + platform: true, + }, + pageCounter: 0, + }; + let plugins: EnrichmentPlugin[]; + + const install = async (options: Options = {}, apiKey = config.apiKey) => { + const client = createMockBrowserClient(); + const plugin = pageViewTrackingPlugin(options); + plugins.push(plugin); + await plugin.setup?.({ ...config, apiKey }, client); + return { client, plugin }; + }; + + // Campaign parsing and page view creation complete asynchronously. + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const locations = (client: ReturnType) => + client.track.mock.calls.map(([event]) => + typeof event === 'object' ? (event.event_properties?.['[Amplitude] Page Location'] as unknown) : undefined, + ); + + beforeEach(() => { + plugins = []; + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + window.history.replaceState(null, '', '/home'); + clearPageViewState(); + clearSdkInitializationOffset(); + window.sessionStorage.clear(); + }); + + afterEach(async () => { + for (const plugin of plugins) { + await plugin.teardown?.(); + } + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }); + + test.each([undefined, 'all', 'pathOnly'] as const)( + 'tracks replaceState routes with %s', + async (trackHistoryChanges) => { + const { client } = await install({ trackHistoryChanges }); + expect(window.history.replaceState({ route: true }, '', new URL('/settings', location.href))).toBeUndefined(); + await settle(); + + expect(locations(client)).toEqual(['http://localhost/home', 'http://localhost/settings']); + expect(client.track.mock.calls[1][0]).toMatchObject({ event_type: '[Amplitude] Page Viewed' }); + expect(window.history.state).toEqual({ route: true }); + }, + ); + + test('ignores hydration, state-only, omitted, null, and unchanged URLs', async () => { + const trackOn = jest.fn(() => true); + const { client } = await install({ trackOn }); + window.history.replaceState({ idx: 0 }, ''); + window.history.replaceState({ idx: 1 }, '', null); + window.history.replaceState({ idx: 2 }, '', ''); + window.history.replaceState({ idx: 3 }, '', '/home'); + window.history.replaceState({ idx: 4 }, '', location.href); + await settle(); + expect(locations(client)).toEqual(['http://localhost/home']); + + // An unobserved hash change must not turn a subsequent state-only update into a navigation. + location.hash = 'anchor'; + window.history.replaceState({ idx: 5 }, ''); + expect(trackOn).toHaveBeenCalledTimes(1); + await settle(); + }); + + test.each(['all', 'pathOnly'] as const)('filters query and hash changes with %s', async (trackHistoryChanges) => { + window.history.replaceState(null, '', '/home?tracking=1'); + const { client } = await install({ trackHistoryChanges }); + window.history.replaceState(null, '', '/home'); // Query cleanup is a URL change under "all". + await settle(); + window.history.replaceState(null, '', '/home#section'); + await settle(); + window.history.replaceState(null, '', '/settings#section'); + await settle(); + expect(locations(client)).toEqual( + trackHistoryChanges === 'all' + ? [ + 'http://localhost/home?tracking=1', + 'http://localhost/home', + 'http://localhost/home#section', + 'http://localhost/settings#section', + ] + : ['http://localhost/home?tracking=1', 'http://localhost/settings#section'], + ); + }); + + test('respects trackOn and advances the URL baseline even when suppressed', async () => { + let enabled = true; + const trackOn = jest.fn(() => enabled); + const { client } = await install({ trackOn }); + enabled = false; + window.history.replaceState(null, '', '/suppressed'); + await settle(); + enabled = true; + window.history.pushState(null, '', '/suppressed'); + await settle(); + expect(trackOn).toHaveBeenCalledTimes(2); + expect(client.track.mock.calls).toHaveLength(1); + window.history.replaceState(null, '', '/allowed'); + await settle(); + expect(locations(client)).toEqual(['http://localhost/home', 'http://localhost/allowed']); + }); + + test('does not track history changes in attribution mode', async () => { + const { client } = await install({ trackOn: 'attribution' }); + window.history.replaceState(null, '', '/settings'); + await settle(); + expect(client.track.mock.calls).toHaveLength(0); + }); + + test('deduplicates synchronous replace/push calls to the same URL', async () => { + const { client } = await install(); + window.history.replaceState(null, '', '/settings'); + window.history.replaceState({ idx: 1 }, '', '/settings'); + window.history.pushState(null, '', '/settings'); + await settle(); + expect(locations(client)).toEqual(['http://localhost/home', 'http://localhost/settings']); + }); + + test('tracks synchronous redirects and subsequent Back navigation', async () => { + const { client } = await install(); + window.history.replaceState(null, '', '/redirect'); + window.history.replaceState(null, '', '/settings'); + await settle(); + window.history.pushState(null, '', '/next'); + await settle(); + const back = new Promise((resolve) => window.addEventListener('popstate', resolve, { once: true })); + window.history.back(); + await back; + await settle(); + expect(locations(client)).toEqual([ + 'http://localhost/home', + 'http://localhost/redirect', + 'http://localhost/settings', + 'http://localhost/next', + 'http://localhost/settings', + ]); + }); + + test('preserves native exceptions and does not track failed calls', async () => { + const { client } = await install(); + expect(() => window.history.replaceState(null, '', 'https://other.example/')).toThrow(DOMException); + expect(() => window.history.replaceState.call({}, null, '', '/settings')).toThrow(TypeError); + await settle(); + expect(locations(client)).toEqual(['http://localhost/home']); + window.history.replaceState(null, '', '/settings'); + await settle(); + expect(client.track.mock.calls).toHaveLength(2); + }); + + test.each(['pushState', 'replaceState'] as const)( + 'preserves %s wrapper arguments, receiver, and return value', + async (method) => { + const result = { wrapperResult: true }; + const wrapped = jest.fn(function (this: History, ...args: Parameters) { + originalReplaceState.apply(this, args); + return result; + }); + window.history[method] = wrapped; + const { client } = await install(); + const state = { route: true }; + const url = new URL('/settings', location.href); + expect(window.history[method](state, 'title', url)).toBe(result); + expect(wrapped).toHaveBeenCalledWith(state, 'title', url); + expect(wrapped.mock.contexts[0]).toBe(window.history); + await settle(); + expect(client.track.mock.calls).toHaveLength(2); + }, + ); + + test('keeps later wrappers and other instances active after teardown', async () => { + const first = await install(); + const second = await install(); + const otherProject = await install({}, 'other-project'); + // Simulate another history consumer installed after the SDK. + // eslint-disable-next-line @typescript-eslint/unbound-method + const sdkReplaceState = window.history.replaceState; + const outerWrapper = jest.fn(function (this: History, ...args: Parameters) { + return sdkReplaceState.apply(this, args); + }); + window.history.replaceState = outerWrapper; + window.history.replaceState(null, '', '/shared'); + await settle(); + const pageViewId = (client: typeof first.client): unknown => { + const event = client.track.mock.calls[1][0]; + return typeof event === 'object' && event.event_properties?.['[Amplitude] Page View ID']; + }; + expect(pageViewId(first.client)).toEqual(expect.any(String)); + expect(pageViewId(second.client)).toBe(pageViewId(first.client)); + expect(pageViewId(otherProject.client)).not.toBe(pageViewId(first.client)); + await first.plugin.teardown?.(); + window.history.replaceState(null, '', '/settings'); + await settle(); + expect(first.client.track.mock.calls).toHaveLength(2); + expect(second.client.track.mock.calls).toHaveLength(3); + expect(otherProject.client.track.mock.calls).toHaveLength(3); + await second.plugin.teardown?.(); + await otherProject.plugin.teardown?.(); + window.history.replaceState(null, '', '/after-teardown'); + await settle(); + expect(second.client.track.mock.calls).toHaveLength(3); + expect(otherProject.client.track.mock.calls).toHaveLength(3); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(window.history.replaceState).toBe(outerWrapper); + expect(outerWrapper).toHaveBeenCalledTimes(3); + }); + + test('allows an already pending page view to finish after teardown', async () => { + const { client, plugin } = await install(); + let finishCampaign: (value: Awaited>) => void = () => undefined; + jest.spyOn(CampaignParser.prototype, 'parse').mockReturnValueOnce( + new Promise((resolve) => { + finishCampaign = resolve; + }), + ); + window.history.replaceState(null, '', '/settings'); + await plugin.teardown?.(); + expect(client.track.mock.calls).toHaveLength(1); + finishCampaign({ ...BASE_CAMPAIGN }); + await settle(); + expect(locations(client)).toEqual(['http://localhost/home', 'http://localhost/settings']); + window.history.replaceState(null, '', '/after-teardown'); + await settle(); + expect(client.track.mock.calls).toHaveLength(2); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8f3b949..17e0a8f5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,9 @@ importers: nodemon: specifier: ^3.0.1 version: 3.1.11 + nuqs: + specifier: ^2.10.2 + version: 2.10.2(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.4(react@18.3.1))(react@18.3.1) nx: specifier: ^21.2.1 version: 21.6.8(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0) @@ -8533,6 +8536,27 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + nuqs@2.10.2: + resolution: {integrity: sha512-HaLMCr9H2wywEnTu3j+QW98EteDT3Kfmx3SJJ6JfcvCvkFi/g/fKd/oCXa5DF0x1s1l4zcFtzsq9bflj6RcM6A==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 || ^8 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nwsapi@2.2.22: resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} @@ -21671,6 +21695,14 @@ snapshots: nullthrows@1.1.1: {} + nuqs@2.10.2(react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.4(react@18.3.1))(react@18.3.1): + dependencies: + '@standard-schema/spec': 1.1.0 + react: 18.3.1 + optionalDependencies: + react-router: 6.30.4(react@18.3.1) + react-router-dom: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + nwsapi@2.2.22: {} nx@21.6.8(debug@4.4.3(supports-color@5.5.0))(supports-color@5.5.0): diff --git a/test-server/README.md b/test-server/README.md index 65526deba..ac921a42c 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -23,3 +23,7 @@ To run the server as HTTPS follow these steps ## TIPS * You can enable a "Workspace" in Chrome on this site, which allows you to edit the files from inside the browser + +## nuqs SPA + +Run `pnpm dev:nuqs` for the [nuqs page-view example](./nuqs-spa/README.md) at http://127.0.0.1:5174/. Initialize the real SDK with a test project's API key, select `all` (default) or `pathOnly`, and observe page views from nuqs query-state updates. diff --git a/test-server/nuqs-spa/README.md b/test-server/nuqs-spa/README.md new file mode 100644 index 000000000..8f1ce8b21 --- /dev/null +++ b/test-server/nuqs-spa/README.md @@ -0,0 +1,44 @@ +# nuqs SPA page-view example + +A React Router v6 SPA using nuqs 2 and the local workspace Browser SDK, including the SDK-413 replaceState fix. Visitors initialize a real SDK instance with their test project's API key and choose `trackHistoryChanges: 'all'` (the default) or `'pathOnly'`. + +## Run + +From the repository root, after the usual `pnpm install` and `pnpm build`: + +```sh +pnpm dev:nuqs +``` + +Open http://127.0.0.1:5174/. This dedicated Vite server supports deep links and stays separate from the shared test server and the TRC harness on port 5173. It resolves every `@amplitude/*` import to the workspace build. Rebuild packages after changing SDK source; app edits cause a full reload. + +## Initialize the SDK + +1. Set `VITE_AMPLITUDE_API_KEY` in the repository-root `.env` file. +2. Choose **all (default)** or **pathOnly**. +3. Click **Initialize SDK**. Refresh to change the tracking mode. + +The app uses the real workspace Browser SDK with the `.env` key, US server zone, and `Types.LogLevel.Debug`. Inspect SDK debug logs in the browser console, requests in the Network panel, or events in the Amplitude project. There are no event observers or history instrumentation in the app. `window.amplitude` exposes the SDK instance for console inspection. + +Remote config is disabled so it cannot override the selected mode; diagnostics and unrelated autocapture features are disabled. Neither `.env` nor its values are committed. + +## Try it + +The SPA controls table puts buttons and their underlying code in rows, with expected additional page views in the `all` and `pathOnly` columns. Query-button annotations follow the selected nuqs history mode. + +Initialization tracks one initial page view. The selected mode determines how later URL changes are tracked: + +| Action | `all` | `pathOnly` | +| --- | --- | --- | +| Next page via nuqs | +1 per actual URL write | +0 | +| Clear page query via nuqs | +1 | +0 | +| Write same value / state-only replacement | +0 | +0 | +| Replace route to a different path | +1 | +1 | +| Replace hash | +1 | +0 | +| Catalog / Settings navigation to a different path | +1 | +1 | + +nuqs defaults to `history: 'replace'`. Switch its history mode to `push`, change the page number, and use Back/Forward to visit earlier query states. Query-only Back/Forward increments `all`; path changes are tracked in either mode. + +nuqs may batch rapid query updates into one URL write. + +References: [nuqs React Router adapter](https://nuqs.dev/docs/adapters#react-router-v6), [history options](https://nuqs.dev/docs/options#history), [batching](https://nuqs.dev/docs/batching). diff --git a/test-server/nuqs-spa/index.html b/test-server/nuqs-spa/index.html new file mode 100644 index 000000000..155ce9d35 --- /dev/null +++ b/test-server/nuqs-spa/index.html @@ -0,0 +1,12 @@ + + + + + + nuqs · Amplitude page views + + +
Starting local SDK instances…
+ + + diff --git a/test-server/nuqs-spa/main.jsx b/test-server/nuqs-spa/main.jsx new file mode 100644 index 000000000..5d5734f76 --- /dev/null +++ b/test-server/nuqs-spa/main.jsx @@ -0,0 +1,231 @@ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, useNavigate } from 'react-router-dom'; +import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import { createInstance, Types } from '@amplitude/analytics-browser'; +import './styles.css'; + +const client = createInstance(); +const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY; +window.amplitude = client; + +function Action({ children, code, operation, expectedAll, expectedPathOnly, ...buttonProps }) { + return ( + + +
+ + {code} + {operation} +
+ + {expectedAll} + {expectedPathOnly} + + ); +} + +function App() { + const [mode, setMode] = useState('all'); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [historyMode, setHistoryMode] = useState('replace'); + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + const queryOperation = `nuqs → history.${historyMode === 'push' ? 'pushState' : 'replaceState'}(...)`; + const navigate = useNavigate(); + + function replaceRoute() { + const nextRoute = `${location.pathname === '/settings' ? '/' : '/settings'}${location.search}`; + navigate(nextRoute, { replace: true }); + } + + function replaceHash() { + const nextHashUrl = `${location.pathname}${location.search}${ + location.hash === '#details' ? '#summary' : '#details' + }`; + navigate(nextHashUrl, { replace: true }); + } + + async function initialize(event) { + event.preventDefault(); + setStatus('initializing'); + try { + await client.init(apiKey, undefined, { + instanceName: 'nuqs-spa', + serverZone: 'US', + logLevel: Types.LogLevel.Debug, + identityStorage: 'memory', + fetchRemoteConfig: false, + enableDiagnostics: false, + flushQueueSize: 1, + flushIntervalMillis: 100, + autocapture: { + attribution: false, + fileDownloads: false, + formInteractions: false, + sessions: false, + pageViews: { trackHistoryChanges: mode }, + }, + }).promise; + setStatus('ready'); + } catch (initError) { + setError(initError.message); + setStatus('error'); + } + } + + return ( +
+

nuqs page-view test

+

Watch SDK debug logs in the console and requests in the Network panel.

+
+
+ + +
+
+ {!apiKey &&

Set VITE_AMPLITUDE_API_KEY in the repository .env and restart the dev server.

} + {status === 'ready' &&

SDK initialized with {mode}. Refresh to change mode.

} + {error &&

{error} Refresh to retry.

} + +
+ SPA controls + +
+ + + + + + + + + + + navigate(`/${location.search}`)} + > + Catalog + + navigate(`/settings${location.search}`)} + > + Settings + + navigate(-1)} + > + ← Back + + navigate(1)} + > + Forward → + + setPage(page + 1, { history: historyMode })} + > + Next page ({page}) + + setPage(page, { history: historyMode })} + > + Write same value + + setPage(null, { history: historyMode })} + > + Clear page query + + + Replace route + + + Replace hash + + history.replaceState({ ...history.state, demoState: Date.now() }, '')} + > + State only + + +
+ Expected additional page views. Back/Forward require an existing SPA history entry. nuqs may batch query + updates. +
Button / underlying code + all (default) + + pathOnly +
+
+
+
+ ); +} + +createRoot(document.getElementById('root')).render( + + + + + , +); diff --git a/test-server/nuqs-spa/styles.css b/test-server/nuqs-spa/styles.css new file mode 100644 index 000000000..f27f65530 --- /dev/null +++ b/test-server/nuqs-spa/styles.css @@ -0,0 +1,118 @@ +body { + margin: 0; + font: 14px system-ui, sans-serif; + color: #22332c; + background: #f6f8f6; +} +main { + max-width: 720px; + margin: auto; + padding: 24px 16px; +} +h1 { + font-size: 24px; +} +p { + line-height: 1.5; +} +fieldset { + min-width: 0; + margin: 20px 0; + padding: 16px; + border: 1px solid #ccd6ce; + border-radius: 6px; +} +form fieldset { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 12px; +} +label { + display: grid; + gap: 6px; + margin-bottom: 12px; +} +form label { + margin: 0; +} +button, +select { + font: inherit; + padding: 8px; + border: 1px solid #b7c6bc; + border-radius: 4px; + background: white; + color: inherit; +} +button { + cursor: pointer; +} +button:hover { + background: #e3eee7; +} +button:disabled, +fieldset:disabled { + opacity: 0.6; + cursor: default; +} +button:focus-visible, +select:focus-visible { + outline: 2px solid #318a69; + outline-offset: 2px; +} + +.action { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; +} +.action code, +.action small { + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.5; +} +.action small, +.control-help { + font-size: 12px; + margin-bottom: 0; +} + +.table-scroll { + overflow-x: auto; + margin: 18px 0; +} +table { + width: 100%; + min-width: 540px; + border-collapse: collapse; + table-layout: fixed; +} +caption { + text-align: left; + font-size: 12px; + line-height: 1.5; + margin-bottom: 12px; + color: #53685b; +} +th, +td { + padding: 12px; + border: 1px solid #ccd6ce; + text-align: left; + vertical-align: top; +} +thead th { + background: #eaf0ec; +} +thead th:first-child { + width: 50%; +} +th[scope='row'] { + font-weight: normal; +} +thead .active-mode { + border-bottom: 3px solid #318a69; +} diff --git a/test-server/nuqs-spa/vite.config.mjs b/test-server/nuqs-spa/vite.config.mjs new file mode 100644 index 000000000..93b298828 --- /dev/null +++ b/test-server/nuqs-spa/vite.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(root, '../..'); +const packagesDir = path.join(repoRoot, 'packages'); +const alias = Object.fromEntries( + fs.readdirSync(packagesDir).map((name) => [`@amplitude/${name}`, path.join(packagesDir, name)]), +); + +export default defineConfig({ + root, + envDir: repoRoot, + appType: 'spa', + // Full-page reloads keep the SDK instances and history observers from stacking during edits. + esbuild: { jsx: 'automatic' }, + resolve: { alias }, + server: { host: '127.0.0.1', port: 5174, strictPort: true, fs: { allow: [repoRoot] } }, +}); diff --git a/vite.config.js b/vite.config.js index 1cf5523be..143a4bcc8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -76,7 +76,7 @@ function fileListingPlugin() { const files = await glob('**/*.html', { cwd: testServerDir, absolute: false, - ignore: ['**/dist/**', '**/trc-e2e/**'] + ignore: ['**/dist/**', '**/trc-e2e/**', '**/nuqs-spa/**'] }); const fileList = files.map(file => ({ @@ -152,9 +152,9 @@ export default defineConfig({ host: true, }, plugins: [ - // trc-e2e is a standalone React SPA with its own dedicated vite config (see `dev:trc`). - // It references /main.jsx and must not be swept into the shared multi-page build. - htmlEntriesPlugin('**/*.html', { cwd: testServerDir, ignore: ['**/trc-e2e/**'] }), + // These standalone React SPAs have dedicated Vite configs (`dev:trc` and `dev:nuqs`). + // Their root-relative entry points must not be swept into the shared multi-page build. + htmlEntriesPlugin('**/*.html', { cwd: testServerDir, ignore: ['**/trc-e2e/**', '**/nuqs-spa/**'] }), // Scoped to configurator/ so the babel + react-refresh transform stays off the other test pages // and the packages/* builds they pull in. react({ include: [/\/configurator\/.*\.[jt]sx?$/] }),