From f2bd44cb62d848bf30fe12a11d22e08b6a8b6986 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 13:45:19 -0700 Subject: [PATCH 1/8] fix(browser): track page views on replaceState navigation --- .../src/page-view-tracking.ts | 23 +- .../test/history.test.ts | 277 ++++++++++++++++++ 2 files changed, 291 insertions(+), 9 deletions(-) create mode 100644 packages/plugin-page-view-tracking-browser/test/history.test.ts 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..899ee68a4 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 oldURL = location.href; + const result = target.apply(thisArg, args); + // Routers also replace state during hydration without navigating. + if (isTracking && (method === 'pushState' || location.href !== oldURL)) { + 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); + }); +}); From 8feb8d93b1132a3560b9518b2c007c2d4a920d6f Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 14:13:31 -0700 Subject: [PATCH 2/8] test(browser): add nuqs SPA with real SDK initialization --- package.json | 2 + pnpm-lock.yaml | 32 +++ test-server/README.md | 4 + test-server/nuqs-spa/README.md | 45 ++++ test-server/nuqs-spa/index.html | 12 + test-server/nuqs-spa/main.jsx | 363 ++++++++++++++++++++++++++ test-server/nuqs-spa/styles.css | 375 +++++++++++++++++++++++++++ test-server/nuqs-spa/vite.config.mjs | 20 ++ vite.config.js | 8 +- 9 files changed, 857 insertions(+), 4 deletions(-) create mode 100644 test-server/nuqs-spa/README.md create mode 100644 test-server/nuqs-spa/index.html create mode 100644 test-server/nuqs-spa/main.jsx create mode 100644 test-server/nuqs-spa/styles.css create mode 100644 test-server/nuqs-spa/vite.config.mjs 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/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..6045b709e --- /dev/null +++ b/test-server/nuqs-spa/README.md @@ -0,0 +1,45 @@ +# 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. Enter your test project's API key. +2. Choose **all (default)** or **pathOnly**, and the project's US/EU server zone. +3. Click **Initialize SDK**. The page confirms the active mode and enables the SPA controls. + +The app calls the workspace Browser SDK's `init()` with the supplied API key. Events use the standard SDK transport and are sent to the selected Amplitude project. An enrichment observer displays generated page views without replacing the transport, destination, or delivery responses. The visible list is an observation of generated events, not an acknowledgement of successful ingestion; inspect the browser Network panel or your Amplitude project to verify delivery. + +The API key is not saved. Remote config is disabled so it cannot override the selected tracking mode; diagnostics and unrelated autocapture features are disabled. Refresh to change initialization options or reset the observed count. The current SPA URL is preserved by a refresh. + +## Try it + +The counter starts at 1 for the initial page view after initialization. The selected mode determines how later URL changes are tracked: + +| Action | `all` | `pathOnly` | +| --- | --- | --- | +| Next page / search / category via nuqs | +1 per actual URL write | +0 | +| Clear nonempty 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 | +| Uncheck trackOn, then change URL | +0 | +0 | + +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 batches/throttles URL writes, so rapid keystrokes may produce fewer history calls and page views than characters typed. The history trace shows the actual calls; it does not dispatch synthetic navigation or analytics events. Expand any page-view entry to inspect the observed event. Only the latest ten observed events and latest 100 history calls are shown; the counter retains the full session count. + +For console inspection, `window.amplitude` (also `window.nuqsDemo.client`) exposes the initialized SDK instance and `window.nuqsDemo.getSnapshot()` returns the event/trace state. + +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..ae80cab7d --- /dev/null +++ b/test-server/nuqs-spa/main.jsx @@ -0,0 +1,363 @@ +import React, { useState, useSyncExternalStore } 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, parseAsString, useQueryStates } from 'nuqs'; +import { createInstance } from '@amplitude/analytics-browser'; +import './styles.css'; + +const listeners = new Set(); +let snapshot = { href: location.href, history: [], events: [], enabled: true, status: 'idle', mode: 'all', error: '' }; +let sequence = 0; + +function publish(change) { + snapshot = { ...snapshot, ...change }; + listeners.forEach((listener) => listener()); +} + +function recordHistory(method, before) { + const entry = { id: ++sequence, method, before, after: location.href, changed: before !== location.href }; + publish({ href: location.href, history: [...snapshot.history, entry].slice(-100) }); +} + +// Observe successful calls without generating synthetic popstate events or SDK events. +// nuqs and the SDK still own their normal history wrappers. +for (const method of ['pushState', 'replaceState']) { + const original = history[method]; + history[method] = function (...args) { + const before = location.href; + const result = Reflect.apply(original, this, args); + recordHistory(method, before); + return result; + }; +} +window.addEventListener('popstate', () => recordHistory('popstate', snapshot.href)); + +const client = createInstance(); +window.amplitude = client; +window.nuqsDemo = { client, getSnapshot: () => snapshot }; + +async function initialize({ apiKey, mode, serverZone }) { + publish({ status: 'initializing', mode, error: '' }); + try { + // Observe generated page views without replacing the SDK's transport or destination. + client.add({ + name: 'nuqs-event-observer', + type: 'enrichment', + async execute(event) { + if (event.event_type === '[Amplitude] Page Viewed') { + publish({ events: [...snapshot.events, event] }); + } + return event; + }, + }); + await client.init(apiKey, undefined, { + instanceName: 'nuqs-spa', + serverZone, + identityStorage: 'memory', + fetchRemoteConfig: false, + enableDiagnostics: false, + flushQueueSize: 1, + flushIntervalMillis: 100, + autocapture: { + attribution: false, + fileDownloads: false, + formInteractions: false, + sessions: false, + pageViews: { trackHistoryChanges: mode, trackOn: () => snapshot.enabled }, + }, + }).promise; + publish({ status: 'ready' }); + } catch (error) { + publish({ status: 'error', error: error.message }); + } +} + +const subscribe = (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; +const getSnapshot = () => snapshot; +const shortUrl = (href) => { + const url = new URL(href); + return `${url.pathname}${url.search}${url.hash}`; +}; +const parsers = { + q: parseAsString.withDefault(''), + tab: parseAsString.withDefault('all'), + page: parseAsInteger.withDefault(1), +}; + +function App() { + const state = useSyncExternalStore(subscribe, getSnapshot); + const [apiKey, setApiKey] = useState(''); + const [serverZone, setServerZone] = useState('US'); + const [historyMode, setHistoryMode] = useState('replace'); + const [filters, setFilters] = useQueryStates(parsers); + const navigate = useNavigate(); + // Leave options unset for replace mode to exercise nuqs' actual default. + const update = (patch) => setFilters(patch, historyMode === 'push' ? { history: 'push' } : undefined); + const current = new URL(state.href); + + return ( +
+
+
+ SDK-413 · INTERACTIVE EXAMPLE +

+ nuqs × page views +

+

Change URL state. Watch what the SDK actually captures.

+
+ + {state.status === 'ready' ? `SDK initialized · ${state.mode}` : 'SDK not initialized'} + +
+
+

Initialize Amplitude SDK

+

+ Enter a test project's API key. The real Browser SDK sends events to that Amplitude project. +

+
{ + event.preventDefault(); + void initialize({ apiKey: apiKey.trim(), mode: state.mode, serverZone }); + }} + > +
+
+ + + +
+ +
+
+ {state.status === 'ready' && ( +

+ SDK initialized with {state.mode}. Refresh to change initialization options. +

+ )} + {state.error &&

{state.error} Refresh to retry initialization.

} +

+ The API key is not saved. Remote config is disabled so the selected tracking mode stays in effect. +

+
+
+ URL + {state.href} +
+
+
+
+ 01 +

Try the SPA

+
+

React Router + nuqs query state. Initialize the SDK above to enable these controls.

+ + + + +
+ + + +
+
{JSON.stringify(filters, null, 2)}
+

More history cases

+
+ + + +
+ +

+ nuqs batches and throttles URL writes. Several keystrokes can become one history call and one page view. +

+
+
+
+ 02 +

Observed page views

+
+
+
+ + {state.mode} + {state.mode === 'all' ? ' (default)' : ''} + + {state.events.length} +
+

{state.mode === 'all' ? 'Path, query & hash changes' : 'Path changes only'}

+ {state.status !== 'ready' &&

Initialize the SDK to start capturing.

} +
+ {state.events + .slice(-10) + .reverse() + .map((event, index) => ( +
+ + + {shortUrl(event.event_properties['[Amplitude] Page Location'])} + +
{JSON.stringify(event, null, 2)}
+
+ ))} +
+
+
+

What to look for

+

+ Query or hash replacement: all +1, pathOnly +0. +

+

+ Route replacement: +1 with either mode. +

+

+ Same URL or state only: +0 with either mode. +

+

+ trackOn unchecked: URL changes still happen; capture pauses. +

+

+ Initialization captures one initial page view. Expand an entry to inspect the SDK event. This list shows + observed events; use the Network panel or your Amplitude project to confirm delivery. +

+
+
+
+
+
+ 03 +

History trace

+ Newest first · last 100 calls +
+
+ + + + + + + + + + {state.history + .slice() + .reverse() + .map((entry) => ( + + + + + + ))} + +
CallResulting URLURL changed?
+ {entry.method} + + {shortUrl(entry.after)} + + + {entry.changed ? 'Yes' : 'No · state only / same URL'} + +
+
+
+
+ Events are sent to your selected Amplitude project using the normal SDK transport. Refresh to reset and choose + another tracking mode. +
+
+ ); +} + +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..02c8cd8fd --- /dev/null +++ b/test-server/nuqs-spa/styles.css @@ -0,0 +1,375 @@ +:root { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #1c3031; + background: #f2f5f3; + font-synthesis: none; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + max-width: 1440px; + margin: auto; + padding: 40px; +} +header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 30px; +} +h1 { + font-size: clamp(28px, 4vw, 46px); + letter-spacing: -2px; + margin: 9px 0; +} +h1 span { + color: #77a094; + font-weight: 400; +} +p { + font-size: 14px; + line-height: 1.6; + margin: 8px 0; +} +h2 { + font-size: 18px; + margin: 0; + letter-spacing: -0.4px; +} +h3 { + font-size: 14px; + margin: 20px 0 10px; +} +.eyebrow { + font-size: 11px; + letter-spacing: 1.5px; + font-weight: 700; + color: #57736d; +} +.badge { + padding: 8px 12px; + border: 1px solid #bad1c6; + border-radius: 20px; + color: #27654e; + font-size: 12px; + background: #e6f1e9; + white-space: nowrap; +} +.url-bar { + display: flex; + gap: 20px; + align-items: center; + background: #203b37; + color: #e9fff4; + padding: 17px 20px; + border-radius: 10px; + margin-bottom: 24px; + overflow-wrap: anywhere; +} +.url-bar > span { + color: #9bb8ae; + font-size: 11px; + letter-spacing: 1px; +} +code, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} +.workspace { + display: grid; + grid-template-columns: minmax(330px, 0.85fr) minmax(0, 1.15fr); + gap: 26px; + align-items: start; +} +.panel { + background: white; + border: 1px solid #dbe3dd; + border-radius: 12px; + padding: 24px; +} +.section-heading { + display: flex; + align-items: center; + gap: 12px; +} +.step { + display: grid; + place-items: center; + border: 1px solid #ccd9d0; + border-radius: 7px; + width: 28px; + height: 28px; + font-size: 11px; + color: #627a70; +} +.muted { + color: #6e7f79; +} +nav, +.button-row { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin: 15px 0; +} +button, +input, +select { + font: inherit; + font-size: 13px; + border: 1px solid #cedad3; + border-radius: 6px; + padding: 9px 11px; + color: inherit; + background: white; +} +button { + cursor: pointer; + transition: background 0.15s; +} +button:hover { + background: #edf4ef; + border-color: #8fae9d; +} +button.selected { + background: #234f3d; + color: white; + border-color: #234f3d; +} +button:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible { + outline: 2px solid #318a69; + outline-offset: 3px; +} +label { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 12px; + font-weight: 600; + margin-top: 17px; +} +input, +select { + width: 100%; + font-weight: 400; +} +.query-state { + background: #f4f7f5; + border-radius: 7px; + padding: 14px; + color: #4a6a58; + line-height: 1.5; +} +.toggle { + flex-direction: row; + align-items: center; + margin-top: 20px; +} +.toggle input { + width: 16px; + height: 16px; + accent-color: #285e47; +} +.hint { + font-size: 12px; + color: #718277; + margin-top: 16px; +} +.results { + padding-top: 7px; +} +.counters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 18px; +} +.counter { + border: 1px solid #d0dfd7; + background: #e9f3ec; + border-radius: 12px; + padding: 20px; + min-width: 0; +} +.counter.pathOnly { + background: #edf0fa; + border-color: #d6dced; +} +.counter-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.counter-top > strong { + font-size: 45px; + letter-spacing: -2px; + font-weight: 600; +} +.counter-top code { + font-size: 14px; +} +.counter p { + font-size: 12px; + color: #5d7465; +} +.counter.pathOnly p { + color: #6b7291; +} +.event-list { + margin-top: 19px; +} +details { + border-top: 1px solid #cbdcd0; + padding: 10px 0; +} +.pathOnly details { + border-color: #d0d6e6; +} +summary { + cursor: pointer; + font: 12px ui-monospace, monospace; + overflow-wrap: anywhere; + list-style: none; + line-height: 1.6; +} +.dot { + display: inline-block; + width: 6px; + height: 6px; + margin-right: 7px; + background: #448763; + border-radius: 100%; +} +.pathOnly .dot { + background: #7986c0; +} +details pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 10px; + max-height: 300px; + overflow-y: auto; +} +.expectations { + margin-top: 20px; + border-left: 2px solid #b9cfc0; + padding: 1px 0 1px 18px; +} +.expectations h3 { + margin-top: 8px; +} +.expectations p { + font-size: 13px; +} +.trace { + margin-top: 26px; +} +.trace .section-heading > .muted { + margin-left: auto; + font-size: 12px; +} +.table-scroll { + overflow-x: auto; + max-height: 330px; + margin-top: 18px; +} +table { + border-collapse: collapse; + width: 100%; + text-align: left; +} +th { + font-size: 11px; + color: #718378; + font-weight: 500; + padding: 0 12px 12px; +} +td { + border-top: 1px solid #e4ebe6; + padding: 12px; + font-size: 12px; + vertical-align: top; +} +.trace-url { + max-width: 500px; + overflow-wrap: anywhere; +} +.changed { + color: #2f7553; +} +.unchanged { + color: #8a918a; +} +footer { + font-size: 12px; + color: #7a8980; + text-align: center; + margin-top: 24px; +} +@media (max-width: 850px) { + main { + padding: 24px 16px; + } + header { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + .workspace { + grid-template-columns: 1fr; + } +} +@media (max-width: 430px) { + .counters { + grid-template-columns: 1fr; + } + .trace .section-heading > .muted { + display: none; + } +} +fieldset { + min-width: 0; + margin: 0; +} +.sdk-setup { + margin-bottom: 24px; +} +.sdk-setup fieldset { + border: 0; + padding: 0; +} +.setup-fields { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 16px; +} +.primary { + background: #234f3d; + color: white; + margin-top: 18px; +} +button:disabled, +fieldset:disabled input, +fieldset:disabled select { + opacity: 0.6; + cursor: not-allowed; +} +.results > .counter { + margin-top: 18px; +} +@media (max-width: 650px) { + .setup-fields { + grid-template-columns: 1fr; + gap: 0; + } +} diff --git a/test-server/nuqs-spa/vite.config.mjs b/test-server/nuqs-spa/vite.config.mjs new file mode 100644 index 000000000..8191116ba --- /dev/null +++ b/test-server/nuqs-spa/vite.config.mjs @@ -0,0 +1,20 @@ +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, + 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?$/] }), From 91bf3386a6c62c25099600a317e9caca5ab83bfe Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 14:15:18 -0700 Subject: [PATCH 3/8] test(browser): load nuqs SDK key from repository env --- test-server/nuqs-spa/README.md | 4 ++-- test-server/nuqs-spa/main.jsx | 9 ++++++--- test-server/nuqs-spa/vite.config.mjs | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/test-server/nuqs-spa/README.md b/test-server/nuqs-spa/README.md index 6045b709e..00fa552e2 100644 --- a/test-server/nuqs-spa/README.md +++ b/test-server/nuqs-spa/README.md @@ -14,13 +14,13 @@ Open http://127.0.0.1:5174/. This dedicated Vite server supports deep links and ## Initialize the SDK -1. Enter your test project's API key. +1. The API-key field is prefilled from `VITE_AMPLITUDE_API_KEY` in the repository-root `.env` file. You can enter a different test project's key if needed. 2. Choose **all (default)** or **pathOnly**, and the project's US/EU server zone. 3. Click **Initialize SDK**. The page confirms the active mode and enables the SPA controls. The app calls the workspace Browser SDK's `init()` with the supplied API key. Events use the standard SDK transport and are sent to the selected Amplitude project. An enrichment observer displays generated page views without replacing the transport, destination, or delivery responses. The visible list is an observation of generated events, not an acknowledgement of successful ingestion; inspect the browser Network panel or your Amplitude project to verify delivery. -The API key is not saved. Remote config is disabled so it cannot override the selected tracking mode; diagnostics and unrelated autocapture features are disabled. Refresh to change initialization options or reset the observed count. The current SPA URL is preserved by a refresh. +The form masks the API key; edits are not saved. The `.env` file and its values are not committed. Remote config is disabled so it cannot override the selected tracking mode; diagnostics and unrelated autocapture features are disabled. Refresh to change initialization options or reset the observed count. The current SPA URL is preserved by a refresh. ## Try it diff --git a/test-server/nuqs-spa/main.jsx b/test-server/nuqs-spa/main.jsx index ae80cab7d..cada3132d 100644 --- a/test-server/nuqs-spa/main.jsx +++ b/test-server/nuqs-spa/main.jsx @@ -90,7 +90,7 @@ const parsers = { function App() { const state = useSyncExternalStore(subscribe, getSnapshot); - const [apiKey, setApiKey] = useState(''); + const [apiKey, setApiKey] = useState(import.meta.env.VITE_AMPLITUDE_API_KEY || ''); const [serverZone, setServerZone] = useState('US'); const [historyMode, setHistoryMode] = useState('replace'); const [filters, setFilters] = useQueryStates(parsers); @@ -116,7 +116,8 @@ function App() {

Initialize Amplitude SDK

- Enter a test project's API key. The real Browser SDK sends events to that Amplitude project. + Use the project API key from .env or enter another test project's key. The real SDK sends events to that + project.

{ @@ -130,6 +131,7 @@ function App() { Project API key setApiKey(event.target.value)} autoComplete="off" @@ -163,7 +165,8 @@ function App() { )} {state.error &&

{state.error} Refresh to retry initialization.

}

- The API key is not saved. Remote config is disabled so the selected tracking mode stays in effect. + Prefilled from VITE_AMPLITUDE_API_KEY in the repository .env when available. Edits are not saved. Remote + config is disabled so your selected mode stays in effect.

diff --git a/test-server/nuqs-spa/vite.config.mjs b/test-server/nuqs-spa/vite.config.mjs index 8191116ba..93b298828 100644 --- a/test-server/nuqs-spa/vite.config.mjs +++ b/test-server/nuqs-spa/vite.config.mjs @@ -12,6 +12,7 @@ const alias = Object.fromEntries( 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' }, From 87ee4898aef3766747d8aeca555143f10145516b Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 14:26:51 -0700 Subject: [PATCH 4/8] test(browser): simplify the nuqs test page --- test-server/nuqs-spa/README.md | 16 +- test-server/nuqs-spa/main.jsx | 454 +++++++++----------------------- test-server/nuqs-spa/styles.css | 380 ++++---------------------- 3 files changed, 179 insertions(+), 671 deletions(-) diff --git a/test-server/nuqs-spa/README.md b/test-server/nuqs-spa/README.md index 00fa552e2..7f2f8b1f7 100644 --- a/test-server/nuqs-spa/README.md +++ b/test-server/nuqs-spa/README.md @@ -14,17 +14,17 @@ Open http://127.0.0.1:5174/. This dedicated Vite server supports deep links and ## Initialize the SDK -1. The API-key field is prefilled from `VITE_AMPLITUDE_API_KEY` in the repository-root `.env` file. You can enter a different test project's key if needed. -2. Choose **all (default)** or **pathOnly**, and the project's US/EU server zone. -3. Click **Initialize SDK**. The page confirms the active mode and enables the SPA controls. +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 calls the workspace Browser SDK's `init()` with the supplied API key. Events use the standard SDK transport and are sent to the selected Amplitude project. An enrichment observer displays generated page views without replacing the transport, destination, or delivery responses. The visible list is an observation of generated events, not an acknowledgement of successful ingestion; inspect the browser Network panel or your Amplitude project to verify delivery. +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. -The form masks the API key; edits are not saved. The `.env` file and its values are not committed. Remote config is disabled so it cannot override the selected tracking mode; diagnostics and unrelated autocapture features are disabled. Refresh to change initialization options or reset the observed count. The current SPA URL is preserved by a refresh. +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 counter starts at 1 for the initial page view after initialization. The selected mode determines how later URL changes are tracked: +Initialization tracks one initial page view. The selected mode determines how later URL changes are tracked: | Action | `all` | `pathOnly` | | --- | --- | --- | @@ -38,8 +38,6 @@ The counter starts at 1 for the initial page view after initialization. The sele 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 batches/throttles URL writes, so rapid keystrokes may produce fewer history calls and page views than characters typed. The history trace shows the actual calls; it does not dispatch synthetic navigation or analytics events. Expand any page-view entry to inspect the observed event. Only the latest ten observed events and latest 100 history calls are shown; the counter retains the full session count. - -For console inspection, `window.amplitude` (also `window.nuqsDemo.client`) exposes the initialized SDK instance and `window.nuqsDemo.getSnapshot()` returns the event/trace state. +nuqs batches/throttles URL writes, so rapid keystrokes may produce fewer page views than characters typed. 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/main.jsx b/test-server/nuqs-spa/main.jsx index cada3132d..a62c8aa5c 100644 --- a/test-server/nuqs-spa/main.jsx +++ b/test-server/nuqs-spa/main.jsx @@ -1,87 +1,15 @@ -import React, { useState, useSyncExternalStore } from 'react'; +import React, { useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter, useNavigate } from 'react-router-dom'; +import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom'; import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'; import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; -import { createInstance } from '@amplitude/analytics-browser'; +import { createInstance, Types } from '@amplitude/analytics-browser'; import './styles.css'; -const listeners = new Set(); -let snapshot = { href: location.href, history: [], events: [], enabled: true, status: 'idle', mode: 'all', error: '' }; -let sequence = 0; - -function publish(change) { - snapshot = { ...snapshot, ...change }; - listeners.forEach((listener) => listener()); -} - -function recordHistory(method, before) { - const entry = { id: ++sequence, method, before, after: location.href, changed: before !== location.href }; - publish({ href: location.href, history: [...snapshot.history, entry].slice(-100) }); -} - -// Observe successful calls without generating synthetic popstate events or SDK events. -// nuqs and the SDK still own their normal history wrappers. -for (const method of ['pushState', 'replaceState']) { - const original = history[method]; - history[method] = function (...args) { - const before = location.href; - const result = Reflect.apply(original, this, args); - recordHistory(method, before); - return result; - }; -} -window.addEventListener('popstate', () => recordHistory('popstate', snapshot.href)); - const client = createInstance(); +const apiKey = import.meta.env.VITE_AMPLITUDE_API_KEY; window.amplitude = client; -window.nuqsDemo = { client, getSnapshot: () => snapshot }; -async function initialize({ apiKey, mode, serverZone }) { - publish({ status: 'initializing', mode, error: '' }); - try { - // Observe generated page views without replacing the SDK's transport or destination. - client.add({ - name: 'nuqs-event-observer', - type: 'enrichment', - async execute(event) { - if (event.event_type === '[Amplitude] Page Viewed') { - publish({ events: [...snapshot.events, event] }); - } - return event; - }, - }); - await client.init(apiKey, undefined, { - instanceName: 'nuqs-spa', - serverZone, - identityStorage: 'memory', - fetchRemoteConfig: false, - enableDiagnostics: false, - flushQueueSize: 1, - flushIntervalMillis: 100, - autocapture: { - attribution: false, - fileDownloads: false, - formInteractions: false, - sessions: false, - pageViews: { trackHistoryChanges: mode, trackOn: () => snapshot.enabled }, - }, - }).promise; - publish({ status: 'ready' }); - } catch (error) { - publish({ status: 'error', error: error.message }); - } -} - -const subscribe = (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); -}; -const getSnapshot = () => snapshot; -const shortUrl = (href) => { - const url = new URL(href); - return `${url.pathname}${url.search}${url.hash}`; -}; const parsers = { q: parseAsString.withDefault(''), tab: parseAsString.withDefault('all'), @@ -89,270 +17,144 @@ const parsers = { }; function App() { - const state = useSyncExternalStore(subscribe, getSnapshot); - const [apiKey, setApiKey] = useState(import.meta.env.VITE_AMPLITUDE_API_KEY || ''); - const [serverZone, setServerZone] = useState('US'); + const [mode, setMode] = useState('all'); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); const [historyMode, setHistoryMode] = useState('replace'); + const trackingEnabled = useRef(true); const [filters, setFilters] = useQueryStates(parsers); + const route = useLocation(); const navigate = useNavigate(); // Leave options unset for replace mode to exercise nuqs' actual default. const update = (patch) => setFilters(patch, historyMode === 'push' ? { history: 'push' } : undefined); - const current = new URL(state.href); + + 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, trackOn: () => trackingEnabled.current }, + }, + }).promise; + setStatus('ready'); + } catch (initError) { + setError(initError.message); + setStatus('error'); + } + } return (
-
-
- SDK-413 · INTERACTIVE EXAMPLE -

- nuqs × page views -

-

Change URL state. Watch what the SDK actually captures.

-
- - {state.status === 'ready' ? `SDK initialized · ${state.mode}` : 'SDK not initialized'} - -
-
-

Initialize Amplitude SDK

-

- Use the project API key from .env or enter another test project's key. The real SDK sends events to that - project. -

- { - event.preventDefault(); - void initialize({ apiKey: apiKey.trim(), mode: state.mode, serverZone }); - }} - > -
-
- - - -
- -
- - {state.status === 'ready' && ( -

- SDK initialized with {state.mode}. Refresh to change initialization options. -

- )} - {state.error &&

{state.error} Refresh to retry initialization.

} -

- Prefilled from VITE_AMPLITUDE_API_KEY in the repository .env when available. Edits are not saved. Remote - config is disabled so your selected mode stays in effect. -

-
-
- URL - {state.href} -
-
-
-
- 01 -

Try the SPA

-
-

React Router + nuqs query state. Initialize the SDK above to enable these controls.

- +

nuqs page-view test

+

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

+
+
- - -
- - - -
-
{JSON.stringify(filters, null, 2)}
-

More history cases

-
- - - -
- -

- nuqs batches and throttles URL writes. Several keystrokes can become one history call and one page view. -

+
-
-
- 02 -

Observed page views

-
-
-
- - {state.mode} - {state.mode === 'all' ? ' (default)' : ''} - - {state.events.length} -
-

{state.mode === 'all' ? 'Path, query & hash changes' : 'Path changes only'}

- {state.status !== 'ready' &&

Initialize the SDK to start capturing.

} -
- {state.events - .slice(-10) - .reverse() - .map((event, index) => ( -
- - - {shortUrl(event.event_properties['[Amplitude] Page Location'])} - -
{JSON.stringify(event, null, 2)}
-
- ))} -
-
-
-

What to look for

-

- Query or hash replacement: all +1, pathOnly +0. -

-

- Route replacement: +1 with either mode. -

-

- Same URL or state only: +0 with either mode. -

-

- trackOn unchecked: URL changes still happen; capture pauses. -

-

- Initialization captures one initial page view. Expand an entry to inspect the SDK event. This list shows - observed events; use the Network panel or your Amplitude project to confirm delivery. -

-
-
-
-
-
- 03 -

History trace

- Newest first · last 100 calls + + {!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 + + + + +
+ + +
-
- - - - - - - - - - {state.history - .slice() - .reverse() - .map((entry) => ( - - - - - - ))} - -
CallResulting URLURL changed?
- {entry.method} - - {shortUrl(entry.after)} - - - {entry.changed ? 'Yes' : 'No · state only / same URL'} - -
+
{JSON.stringify(filters, null, 2)}
+
+ + +
-
-
- Events are sent to your selected Amplitude project using the normal SDK transport. Refresh to reset and choose - another tracking mode. -
+ +
); } diff --git a/test-server/nuqs-spa/styles.css b/test-server/nuqs-spa/styles.css index 02c8cd8fd..8fc66370c 100644 --- a/test-server/nuqs-spa/styles.css +++ b/test-server/nuqs-spa/styles.css @@ -1,375 +1,83 @@ -:root { - font-family: Inter, ui-sans-serif, system-ui, sans-serif; - color: #1c3031; - background: #f2f5f3; - font-synthesis: none; -} -* { - box-sizing: border-box; -} body { margin: 0; + font: 14px system-ui, sans-serif; + color: #22332c; + background: #f6f8f6; } main { - max-width: 1440px; + max-width: 720px; margin: auto; - padding: 40px; -} -header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin-bottom: 30px; + padding: 24px 16px; } h1 { - font-size: clamp(28px, 4vw, 46px); - letter-spacing: -2px; - margin: 9px 0; -} -h1 span { - color: #77a094; - font-weight: 400; + font-size: 24px; } p { - font-size: 14px; - line-height: 1.6; - margin: 8px 0; -} -h2 { - font-size: 18px; - margin: 0; - letter-spacing: -0.4px; -} -h3 { - font-size: 14px; - margin: 20px 0 10px; -} -.eyebrow { - font-size: 11px; - letter-spacing: 1.5px; - font-weight: 700; - color: #57736d; -} -.badge { - padding: 8px 12px; - border: 1px solid #bad1c6; - border-radius: 20px; - color: #27654e; - font-size: 12px; - background: #e6f1e9; - white-space: nowrap; -} -.url-bar { - display: flex; - gap: 20px; - align-items: center; - background: #203b37; - color: #e9fff4; - padding: 17px 20px; - border-radius: 10px; - margin-bottom: 24px; - overflow-wrap: anywhere; -} -.url-bar > span { - color: #9bb8ae; - font-size: 11px; - letter-spacing: 1px; -} -code, -pre { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12px; -} -.workspace { - display: grid; - grid-template-columns: minmax(330px, 0.85fr) minmax(0, 1.15fr); - gap: 26px; - align-items: start; + line-height: 1.5; } -.panel { - background: white; - border: 1px solid #dbe3dd; - border-radius: 12px; - padding: 24px; +fieldset { + min-width: 0; + margin: 20px 0; + padding: 16px; + border: 1px solid #ccd6ce; + border-radius: 6px; } -.section-heading { +form fieldset { display: flex; - align-items: center; + flex-wrap: wrap; + align-items: end; gap: 12px; } -.step { +label { display: grid; - place-items: center; - border: 1px solid #ccd9d0; - border-radius: 7px; - width: 28px; - height: 28px; - font-size: 11px; - color: #627a70; + gap: 6px; + margin-bottom: 12px; } -.muted { - color: #6e7f79; -} -nav, -.button-row { - display: flex; - flex-wrap: wrap; - gap: 7px; - margin: 15px 0; +form label { + margin: 0; } button, input, select { font: inherit; - font-size: 13px; - border: 1px solid #cedad3; - border-radius: 6px; - padding: 9px 11px; - color: inherit; + padding: 8px; + border: 1px solid #b7c6bc; + border-radius: 4px; background: white; + color: inherit; } button { cursor: pointer; - transition: background 0.15s; -} -button:hover { - background: #edf4ef; - border-color: #8fae9d; } +button:hover, button.selected { - background: #234f3d; - color: white; - border-color: #234f3d; + background: #e3eee7; +} +button:disabled, +fieldset:disabled { + opacity: 0.6; + cursor: default; } button:focus-visible, input:focus-visible, -select:focus-visible, -summary:focus-visible { +select:focus-visible { outline: 2px solid #318a69; - outline-offset: 3px; + outline-offset: 2px; } -label { +nav, +.buttons { display: flex; - flex-direction: column; - gap: 7px; - font-size: 12px; - font-weight: 600; - margin-top: 17px; -} -input, -select { - width: 100%; - font-weight: 400; -} -.query-state { - background: #f4f7f5; - border-radius: 7px; - padding: 14px; - color: #4a6a58; - line-height: 1.5; -} -.toggle { - flex-direction: row; - align-items: center; - margin-top: 20px; -} -.toggle input { - width: 16px; - height: 16px; - accent-color: #285e47; -} -.hint { - font-size: 12px; - color: #718277; - margin-top: 16px; -} -.results { - padding-top: 7px; -} -.counters { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; - margin-top: 18px; -} -.counter { - border: 1px solid #d0dfd7; - background: #e9f3ec; - border-radius: 12px; - padding: 20px; - min-width: 0; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; } -.counter.pathOnly { - background: #edf0fa; - border-color: #d6dced; +pre { + padding: 12px; + background: #eaf0ec; + border-radius: 4px; } -.counter-top { +.checkbox { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; -} -.counter-top > strong { - font-size: 45px; - letter-spacing: -2px; - font-weight: 600; -} -.counter-top code { - font-size: 14px; -} -.counter p { - font-size: 12px; - color: #5d7465; -} -.counter.pathOnly p { - color: #6b7291; -} -.event-list { - margin-top: 19px; -} -details { - border-top: 1px solid #cbdcd0; - padding: 10px 0; -} -.pathOnly details { - border-color: #d0d6e6; -} -summary { - cursor: pointer; - font: 12px ui-monospace, monospace; - overflow-wrap: anywhere; - list-style: none; - line-height: 1.6; -} -.dot { - display: inline-block; - width: 6px; - height: 6px; - margin-right: 7px; - background: #448763; - border-radius: 100%; -} -.pathOnly .dot { - background: #7986c0; -} -details pre { - white-space: pre-wrap; - overflow-wrap: anywhere; - font-size: 10px; - max-height: 300px; - overflow-y: auto; -} -.expectations { - margin-top: 20px; - border-left: 2px solid #b9cfc0; - padding: 1px 0 1px 18px; -} -.expectations h3 { - margin-top: 8px; -} -.expectations p { - font-size: 13px; -} -.trace { - margin-top: 26px; -} -.trace .section-heading > .muted { - margin-left: auto; - font-size: 12px; -} -.table-scroll { - overflow-x: auto; - max-height: 330px; - margin-top: 18px; -} -table { - border-collapse: collapse; - width: 100%; - text-align: left; -} -th { - font-size: 11px; - color: #718378; - font-weight: 500; - padding: 0 12px 12px; -} -td { - border-top: 1px solid #e4ebe6; - padding: 12px; - font-size: 12px; - vertical-align: top; -} -.trace-url { - max-width: 500px; - overflow-wrap: anywhere; -} -.changed { - color: #2f7553; -} -.unchanged { - color: #8a918a; -} -footer { - font-size: 12px; - color: #7a8980; - text-align: center; - margin-top: 24px; -} -@media (max-width: 850px) { - main { - padding: 24px 16px; - } - header { - align-items: flex-start; - flex-direction: column; - gap: 10px; - } - .workspace { - grid-template-columns: 1fr; - } -} -@media (max-width: 430px) { - .counters { - grid-template-columns: 1fr; - } - .trace .section-heading > .muted { - display: none; - } -} -fieldset { - min-width: 0; margin: 0; } -.sdk-setup { - margin-bottom: 24px; -} -.sdk-setup fieldset { - border: 0; - padding: 0; -} -.setup-fields { - display: grid; - grid-template-columns: 2fr 1fr 1fr; - gap: 16px; -} -.primary { - background: #234f3d; - color: white; - margin-top: 18px; -} -button:disabled, -fieldset:disabled input, -fieldset:disabled select { - opacity: 0.6; - cursor: not-allowed; -} -.results > .counter { - margin-top: 18px; -} -@media (max-width: 650px) { - .setup-fields { - grid-template-columns: 1fr; - gap: 0; - } -} From 1411272353ea73f282a939da0bdbdb8d6606388b Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 14:29:55 -0700 Subject: [PATCH 5/8] test(browser): explain nuqs SPA control history calls --- test-server/nuqs-spa/README.md | 2 + test-server/nuqs-spa/main.jsx | 110 +++++++++++++++++++++++++------- test-server/nuqs-spa/styles.css | 38 ++++++++++- 3 files changed, 123 insertions(+), 27 deletions(-) diff --git a/test-server/nuqs-spa/README.md b/test-server/nuqs-spa/README.md index 7f2f8b1f7..ff87e37ec 100644 --- a/test-server/nuqs-spa/README.md +++ b/test-server/nuqs-spa/README.md @@ -24,6 +24,8 @@ Remote config is disabled so it cannot override the selected mode; diagnostics a ## Try it +Each SPA button shows its handler and underlying History API operation. Query-button annotations follow the selected nuqs history mode. The `trackOn` checkbox allows page views when checked and pauses them when unchecked; URL changes continue in either state. + Initialization tracks one initial page view. The selected mode determines how later URL changes are tracked: | Action | `all` | `pathOnly` | diff --git a/test-server/nuqs-spa/main.jsx b/test-server/nuqs-spa/main.jsx index a62c8aa5c..5887f8266 100644 --- a/test-server/nuqs-spa/main.jsx +++ b/test-server/nuqs-spa/main.jsx @@ -16,6 +16,16 @@ const parsers = { page: parseAsInteger.withDefault(1), }; +function Action({ children, code, operation, ...buttonProps }) { + return ( +
+ + {code} + {operation} +
+ ); +} + function App() { const [mode, setMode] = useState('all'); const [status, setStatus] = useState('idle'); @@ -23,11 +33,24 @@ function App() { const [historyMode, setHistoryMode] = useState('replace'); const trackingEnabled = useRef(true); const [filters, setFilters] = useQueryStates(parsers); + const queryOperation = `nuqs → history.${historyMode === 'push' ? 'pushState' : 'replaceState'}(...)`; const route = useLocation(); const navigate = useNavigate(); // Leave options unset for replace mode to exercise nuqs' actual default. const update = (patch) => setFilters(patch, historyMode === 'push' ? { history: 'push' } : undefined); + 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'); @@ -81,17 +104,28 @@ function App() {
SPA controls
- - - + update({ page: filters.page + 1 })} + > + Next page ({filters.page}) + + update({ page: filters.page })} + > + Write same value + + update(null)}> + Clear query +
+
+ Code used by the nuqs query controls +
+            {
+              "const update = (patch) => setFilters(\n  patch,\n  historyMode === 'push' ? { history: 'push' } : undefined\n);"
+            }
+          
+
{JSON.stringify(filters, null, 2)}
- - - +
+

+ Checked allows page views; unchecked pauses them while URL changes still work. +
+ trackOn: () => trackingEnabled.current +

); diff --git a/test-server/nuqs-spa/styles.css b/test-server/nuqs-spa/styles.css index 8fc66370c..309ea6cc4 100644 --- a/test-server/nuqs-spa/styles.css +++ b/test-server/nuqs-spa/styles.css @@ -66,9 +66,9 @@ select:focus-visible { } nav, .buttons { - display: flex; - flex-wrap: wrap; - gap: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); + gap: 16px; margin-bottom: 16px; } pre { @@ -81,3 +81,35 @@ pre { align-items: center; margin: 0; } + +.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 { + color: #53685b; +} +.control-help { + font-size: 12px; + margin-bottom: 0; +} +details { + margin: 12px 0; +} +summary { + cursor: pointer; + font-size: 12px; +} +details pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} From a68acb590f7bdb9aff4d52c2e2dffb3a3fc09bf0 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Thu, 29 Oct 2026 14:55:05 -0700 Subject: [PATCH 6/8] test(browser): show SPA controls in a behavior table --- test-server/nuqs-spa/README.md | 2 +- test-server/nuqs-spa/main.jsx | 195 ++++++++++++++++++++------------ test-server/nuqs-spa/styles.css | 45 ++++++-- 3 files changed, 163 insertions(+), 79 deletions(-) diff --git a/test-server/nuqs-spa/README.md b/test-server/nuqs-spa/README.md index ff87e37ec..d4189a334 100644 --- a/test-server/nuqs-spa/README.md +++ b/test-server/nuqs-spa/README.md @@ -24,7 +24,7 @@ Remote config is disabled so it cannot override the selected mode; diagnostics a ## Try it -Each SPA button shows its handler and underlying History API operation. Query-button annotations follow the selected nuqs history mode. The `trackOn` checkbox allows page views when checked and pauses them when unchecked; URL changes continue in either state. +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. The `trackOn` checkbox allows page views when checked and pauses them when unchecked; URL changes continue in either state. Initialization tracks one initial page view. The selected mode determines how later URL changes are tracked: diff --git a/test-server/nuqs-spa/main.jsx b/test-server/nuqs-spa/main.jsx index 5887f8266..9799d850c 100644 --- a/test-server/nuqs-spa/main.jsx +++ b/test-server/nuqs-spa/main.jsx @@ -16,13 +16,19 @@ const parsers = { page: parseAsInteger.withDefault(1), }; -function Action({ children, code, operation, ...buttonProps }) { +function Action({ children, code, operation, expectedAll, expectedPathOnly, ...buttonProps }) { return ( -
- - {code} - {operation} -
+ + +
+ + {code} + {operation} +
+ + {expectedAll} + {expectedPathOnly} + ); } @@ -103,30 +109,6 @@ function App() {
SPA controls - -
- update({ page: filters.page + 1 })} - > - Next page ({filters.page}) - - update({ page: filters.page })} - > - Write same value - - update(null)}> - Clear query - -
Code used by the nuqs query controls
@@ -178,28 +141,118 @@ function App() {
           
{JSON.stringify(filters, null, 2)}
-
- - Replace route - - - Replace hash - - history.replaceState({ ...history.state, demoState: Date.now() }, '')} - > - State only - +
+ + + + + + + + + + + navigate(`/${location.search}`)} + > + Catalog + + navigate(`/settings${location.search}`)} + > + Settings + + navigate(-1)} + > + ← Back + + navigate(1)} + > + Forward → + + update({ page: filters.page + 1 })} + > + Next page ({filters.page}) + + update({ page: filters.page })} + > + Write same value + + update(null)} + > + Clear query + + + Replace route + + + Replace hash + + history.replaceState({ ...history.state, demoState: Date.now() }, '')} + > + State only + + +
+ Expected additional page views with trackOn checked. Back/Forward require an existing SPA history entry. + nuqs may batch query updates. +
Button / underlying code + all (default) + + pathOnly +
- - -
- Code used by the nuqs query controls -
-            {
-              "const update = (patch) => setFilters(\n  patch,\n  historyMode === 'push' ? { history: 'push' } : undefined\n);"
-            }
-          
-
-
{JSON.stringify(filters, null, 2)}
@@ -164,7 +129,6 @@ function App() { expectedPathOnly="1 if path changes; otherwise 0" code={"navigate('/' + location.search)"} operation="history.pushState(...)" - className={route.pathname === '/' ? 'selected' : ''} onClick={() => navigate(`/${location.search}`)} > Catalog @@ -174,7 +138,6 @@ function App() { expectedPathOnly="1 if path changes; otherwise 0" code={"navigate('/settings' + location.search)"} operation="history.pushState(...)" - className={route.pathname === '/settings' ? 'selected' : ''} onClick={() => navigate(`/settings${location.search}`)} > Settings @@ -200,29 +163,29 @@ function App() { update({ page: filters.page + 1 })} + onClick={() => setPage(page + 1, { history: historyMode })} > - Next page ({filters.page}) + Next page ({page}) update({ page: filters.page })} + onClick={() => setPage(page, { history: historyMode })} > Write same value update(null)} + onClick={() => setPage(null, { history: historyMode })} > - Clear query + Clear page query
- Expected additional page views with trackOn checked. Back/Forward require an existing SPA history entry. - nuqs may batch query updates. + Expected additional page views. Back/Forward require an existing SPA history entry. nuqs may batch query + updates.
- -

- Checked allows page views; unchecked pauses them while URL changes still work. -
- trackOn: () => trackingEnabled.current -

); diff --git a/test-server/nuqs-spa/styles.css b/test-server/nuqs-spa/styles.css index a40af88dc..f27f65530 100644 --- a/test-server/nuqs-spa/styles.css +++ b/test-server/nuqs-spa/styles.css @@ -37,7 +37,6 @@ form label { margin: 0; } button, -input, select { font: inherit; padding: 8px; @@ -49,8 +48,7 @@ select { button { cursor: pointer; } -button:hover, -button.selected { +button:hover { background: #e3eee7; } button:disabled, @@ -59,23 +57,11 @@ fieldset:disabled { cursor: default; } button:focus-visible, -input:focus-visible, select:focus-visible { outline: 2px solid #318a69; outline-offset: 2px; } -pre { - padding: 12px; - background: #eaf0ec; - border-radius: 4px; -} -.checkbox { - display: flex; - align-items: center; - margin: 0; -} - .action { display: grid; align-content: start; @@ -89,24 +75,10 @@ pre { line-height: 1.5; } .action small, -.control-help { - color: #53685b; -} .control-help { font-size: 12px; margin-bottom: 0; } -details { - margin: 12px 0; -} -summary { - cursor: pointer; - font-size: 12px; -} -details pre { - white-space: pre-wrap; - overflow-wrap: anywhere; -} .table-scroll { overflow-x: auto; From 3d57aa5d98e325c0288c49e35d4820ed6ebd2d97 Mon Sep 17 00:00:00 2001 From: Xinyi Ye Date: Fri, 30 Oct 2026 15:33:33 -0700 Subject: [PATCH 8/8] refactor(browser): rename previous navigation URL variable --- .../src/page-view-tracking.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 899ee68a4..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 @@ -142,10 +142,10 @@ export const pageViewTrackingPlugin: CreatePageViewTrackingPlugin = (options: Op // eslint-disable-next-line @typescript-eslint/unbound-method globalScope.history[method] = new Proxy(globalScope.history[method], { apply: (target, thisArg, args: Parameters) => { - const oldURL = location.href; + const prevUrl = location.href; const result = target.apply(thisArg, args); // Routers also replace state during hydration without navigating. - if (isTracking && (method === 'pushState' || location.href !== oldURL)) { + if (isTracking && (method === 'pushState' || location.href !== prevUrl)) { handlePageChange(); } return result;