fix(browser): capture SPA pageviews on query string and hash changes - #4729
fix(browser): capture SPA pageviews on query string and hash changes#4729posthog[bot] wants to merge 2 commits into
Conversation
`capture_pageview: 'history_change'` expanded to `{ path: true }`, so a pushState, replaceState, or popstate that changed only the query string or only the hash captured no `$pageview`. Single-page apps that navigate for search, filters, pagination, and tab state lost those pageviews. This is the default for any SDK initialized with `defaults` of '2025-05-24' or later.
`'history_change'` now expands to `{ path: true, search: true, hash: true }`. `disable_capture_url_hashes` still suppresses hash-only changes.
Generated-By: PostHog Desktop
Task-Id: ca7d1e51-64ae-42fc-9c7c-20c2eb623cf9
🦔 PostHog Review reviewed this pull requestFound 2 must fix, 2 should fix, 2 consider. Published 6 findings (view the review). |
posthog-js Compliance ReportDate: 2026-09-01 17:48:54 UTC ✅ All Tests Passed!26/26 tests passed Capture Tests✅ 26/26 tests passed View Details
|
|
Size Change: -647 B (0%) Total Size: 20.8 MB 📦 View Changed
ℹ️ View Unchanged
|
The versioning check requires a patch bump for a `fix:` title, and this is a bug fix, so the changeset drops from minor to patch. The public API reference file picks up the reworded `capture_pageview` doc comment. Generated-By: PostHog Desktop Task-Id: ca7d1e51-64ae-42fc-9c7c-20c2eb623cf9
|
PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
| '@posthog/types': minor | ||
| --- | ||
|
|
||
| `capture_pageview: 'history_change'` now captures a `$pageview` when a single-page-app navigation changes the query string or the hash, not only the path. Before this change, navigation to search results, filters, pagination, and tab state captured no pageview, so these apps undercounted pageviews. `disable_capture_url_hashes` still suppresses hash-only changes. To keep the old behaviour, set `capture_pageview: { path: true }`. |
There was a problem hiding this comment.
State when modern defaults disable hash capture
Why we think it's a valid issue
- Checked: the default resolution in
packages/browser/src/posthog-core.ts, the hash gate inpackages/browser/src/extensions/history-autocapture.ts, the doc comment the PR edits inpackages/types/src/posthog-config.ts, and the changeset text itself. - Found:
packages/browser/src/posthog-core.ts:251setsdisable_capture_url_hashes: !!(defaults && defaults >= '2026-06-25'), and line 244 setscapture_pageview: 'history_change'fordefaults >= '2025-05-24'. A project on'2026-06-25'defaults therefore gets'history_change'and hash stripping at the same time. - Found:
packages/browser/src/extensions/history-autocapture.ts:127-129returns false from_shouldCaptureHashChangesin that state, so_hasLocationChanged(line 139) ignores hash-only navigation andmonitorHistoryChanges(line 73) never adds the hashchange listener. The test atpackages/browser/src/__tests__/extensions/history-autocapture.test.ts:214-222confirms the silence. - Found: the changeset (line 6) and the doc comment (
packages/types/src/posthog-config.ts:1371-1372) both namedisable_capture_url_hashes, but neither says the SDK turns it on from'2026-06-25'defaults. So the premise of the finding is correct: the note promises hash pageviews to a cohort that will not receive them. - Impact: the omission is real but small. The note already names the exact option that suppresses hash capture, and a project on
'2026-06-25'defaults opted into hash stripping through a change that its own release note called a breaking change for hash-routing apps. Such a project already collapses hash routes in$current_url, so no hash-only pageview is the consistent result, not a surprise. The added clause improves the note; its absence misleads few readers. - Priority: lowered to
consider. No code behavior is wrong, the escape hatch is already named in the same paragraph, and the fix is one clause of release-note text.
Issue description
The release note names disable_capture_url_hashes, but it does not state when the SDK enables that option automatically. Users with defaults: '2026-06-25' or later still receive no hash-only pageviews.
Suggested fix
State that modern defaults enable disable_capture_url_hashes. Tell users to set disable_capture_url_hashes: false if they want hash-only navigation pageviews.
Prompt to fix with AI (copy-paste)
## Context
@.changeset/history-change-captures-search-and-hash.md#L6
<issue_description>
The release note names `disable_capture_url_hashes`, but it does not state when the SDK enables that option automatically. Users with `defaults: '2026-06-25'` or later still receive no hash-only pageviews.
</issue_description>
<issue_validation>
- **Checked:** the default resolution in `packages/browser/src/posthog-core.ts`, the hash gate in `packages/browser/src/extensions/history-autocapture.ts`, the doc comment the PR edits in `packages/types/src/posthog-config.ts`, and the changeset text itself.
- **Found:** `packages/browser/src/posthog-core.ts:251` sets `disable_capture_url_hashes: !!(defaults && defaults >= '2026-06-25')`, and line 244 sets `capture_pageview: 'history_change'` for `defaults >= '2025-05-24'`. A project on `'2026-06-25'` defaults therefore gets `'history_change'` and hash stripping at the same time.
- **Found:** `packages/browser/src/extensions/history-autocapture.ts:127-129` returns false from `_shouldCaptureHashChanges` in that state, so `_hasLocationChanged` (line 139) ignores hash-only navigation and `monitorHistoryChanges` (line 73) never adds the hashchange listener. The test at `packages/browser/src/__tests__/extensions/history-autocapture.test.ts:214-222` confirms the silence.
- **Found:** the changeset (line 6) and the doc comment (`packages/types/src/posthog-config.ts:1371-1372`) both name `disable_capture_url_hashes`, but neither says the SDK turns it on from `'2026-06-25'` defaults. So the premise of the finding is correct: the note promises hash pageviews to a cohort that will not receive them.
- **Impact:** the omission is real but small. The note already names the exact option that suppresses hash capture, and a project on `'2026-06-25'` defaults opted into hash stripping through a change that its own release note called a breaking change for hash-routing apps. Such a project already collapses hash routes in `$current_url`, so no hash-only pageview is the consistent result, not a surprise. The added clause improves the note; its absence misleads few readers.
- **Priority:** lowered to `consider`. No code behavior is wrong, the escape hatch is already named in the same paragraph, and the fix is one clause of release-note text.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
State that modern defaults enable `disable_capture_url_hashes`. Tell users to set `disable_capture_url_hashes: false` if they want hash-only navigation pageviews.
</potential_solution>
|
|
||
| if (capturePageview === 'history_change') { | ||
| return { path: true } | ||
| return { path: true, search: true, hash: true } |
There was a problem hiding this comment.
Startup query changes can send duplicate pageviews
Why we think it's a valid issue
- Checked: the init order in
packages/browser/src/posthog-core.ts, every reference to_initialPageviewCaptured, the$pageviewcapture sites, and the capture path inpackages/browser/src/extensions/history-autocapture.ts. - Found: the race window is real in the default configuration.
posthog-core.ts:902calls_initExtensionssynchronously, soHistoryAutocapturesnapshots_lastLocation(history-autocapture.ts:25) and patchespushState/replaceState(history-autocapture.ts:69-70, 98) before_loaded()runs atposthog-core.ts:1018._loaded()then defers the initial pageview withsetTimeout(..., 1)atposthog-core.ts:1293-1301. Any history call in the same task, or in a microtask, runs before that timer. - Found: the two capture paths share no state.
_initialPageviewCapturedappears only atposthog-core.ts:463,661, and4670-4671, so the history capture athistory-autocapture.ts:151-152never sets it. The$pageviewhandling incapture()(posthog-core.ts:1902,1911) adds properties but does not deduplicate. Two$pageviewevents therefore reach the queue with the same$current_url. - Found: the defect class already exists on
main. Before this PR'history_change'expanded to{ path: true }, so a bootstrap redirect that changes the path (for example/to/login) already produced the same pair of events, and the granular{ path, search, hash }config was already public. Line 121 widens the trigger to query-only and hash-only startup changes; it does not create the coordination gap. - Impact: confirmed duplicate pageviews, but only for a narrow window. The URL mutation must happen inside the init task or a microtask, before the 1 ms timer. The OAuth cleanup named in the finding usually runs after an
await, so it lands after the initial pageview and then reports a genuinely different URL, which is the intended new behavior and not a duplicate. Hash-routing apps ondefaultsof'2026-06-25'or later are also unaffected, becausedisable_capture_url_hashessuppresses the hash capture (posthog-core.ts:251,history-autocapture.ts:127-129). - Priority: lowered to
consider. The mechanism is verified, but the trigger is a sub-millisecond startup window, the same double capture already occurs onmainfor the far more common path redirect, and the suggested fix redesigns initial-pageview coordination well beyond this one-line change.
Issue description
Line 121 enables search and hash captures as soon as HistoryAutocapture initializes. PostHog initializes this extension before config.loaded, but _loaded() defers the initial pageview with a timer. If startup code changes only the query or hash, the history hook sends a pageview for the final URL. The timer then sends another pageview for that same URL because the history capture does not set _initialPageviewCaptured. This creates duplicate pageviews during common router or OAuth URL normalization.
Suggested fix
Coordinate history capture with the initial pageview state. Before the initial pageview fires, make a history capture satisfy it or suppress the history capture. Add an integration test that changes the query and hash during loaded, advances the timer, and expects exactly one pageview.
Prompt to fix with AI (copy-paste)
## Context
@packages/browser/src/extensions/history-autocapture.ts#L121
<issue_description>
Line 121 enables search and hash captures as soon as `HistoryAutocapture` initializes. PostHog initializes this extension before `config.loaded`, but `_loaded()` defers the initial pageview with a timer. If startup code changes only the query or hash, the history hook sends a pageview for the final URL. The timer then sends another pageview for that same URL because the history capture does not set `_initialPageviewCaptured`. This creates duplicate pageviews during common router or OAuth URL normalization.
</issue_description>
<issue_validation>
- **Checked:** the init order in `packages/browser/src/posthog-core.ts`, every reference to `_initialPageviewCaptured`, the `$pageview` capture sites, and the capture path in `packages/browser/src/extensions/history-autocapture.ts`.
- **Found:** the race window is real in the default configuration. `posthog-core.ts:902` calls `_initExtensions` synchronously, so `HistoryAutocapture` snapshots `_lastLocation` (`history-autocapture.ts:25`) and patches `pushState`/`replaceState` (`history-autocapture.ts:69-70, 98`) before `_loaded()` runs at `posthog-core.ts:1018`. `_loaded()` then defers the initial pageview with `setTimeout(..., 1)` at `posthog-core.ts:1293-1301`. Any history call in the same task, or in a microtask, runs before that timer.
- **Found:** the two capture paths share no state. `_initialPageviewCaptured` appears only at `posthog-core.ts:463`, `661`, and `4670-4671`, so the history capture at `history-autocapture.ts:151-152` never sets it. The `$pageview` handling in `capture()` (`posthog-core.ts:1902`, `1911`) adds properties but does not deduplicate. Two `$pageview` events therefore reach the queue with the same `$current_url`.
- **Found:** the defect class already exists on `main`. Before this PR `'history_change'` expanded to `{ path: true }`, so a bootstrap redirect that changes the path (for example `/` to `/login`) already produced the same pair of events, and the granular `{ path, search, hash }` config was already public. Line 121 widens the trigger to query-only and hash-only startup changes; it does not create the coordination gap.
- **Impact:** confirmed duplicate pageviews, but only for a narrow window. The URL mutation must happen inside the init task or a microtask, before the 1 ms timer. The OAuth cleanup named in the finding usually runs after an `await`, so it lands after the initial pageview and then reports a genuinely different URL, which is the intended new behavior and not a duplicate. Hash-routing apps on `defaults` of `'2026-06-25'` or later are also unaffected, because `disable_capture_url_hashes` suppresses the hash capture (`posthog-core.ts:251`, `history-autocapture.ts:127-129`).
- **Priority:** lowered to `consider`. The mechanism is verified, but the trigger is a sub-millisecond startup window, the same double capture already occurs on `main` for the far more common path redirect, and the suggested fix redesigns initial-pageview coordination well beyond this one-line change.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Coordinate history capture with the initial pageview state. Before the initial pageview fires, make a history capture satisfy it or suppress the history capture. Add an integration test that changes the query and hash during `loaded`, advances the timer, and expects exactly one pageview.
</potential_solution>
| * - `'history_change'`: Capture the initial pageview and pageviews when the path, query string, or hash changes. | ||
| * `disable_capture_url_hashes` still suppresses hash-only changes. |
There was a problem hiding this comment.
Regenerate the public API reference
Why we think it's a valid issue
- Checked: the committed reference JSON, the source of the doc text, the
generate-referencesjob in.github/workflows/library-ci.yml, thescripts/check-public-api.jsgate it calls, and.github/workflows/generated-files-notice.yml. - Found: the committed artifact is stale.
packages/browser/references/posthog-js-references-latest.jsonstill carries the pre-PR sentence "Capture the initial pageview and pageviews when the pathname changes", next to the unchanged bullet "An object: Capture the initial pageview and pageviews when any selected URL component changes" — the same doc block the PR edits atpackages/types/src/posthog-config.ts:1369-1373. A grep acrosspackages/**/*.tsfinds the old wording in no source file, so regeneration will rewrite that string. - Found: the check runs on this PR and fails deterministically.
.github/workflows/library-ci.yml:3-4triggers on everypull_request, and thegenerate-referencesjob at line 474 runspnpm generate-referencesthenpnpm check:public-apiwith noneedsorifgate.scripts/check-public-api.js:10-56runsgit status --porcelainoverpackages/browser/references/posthog-js-references-latest.jsonand callsprocess.exit(1)on any diff, with the message "Runpnpm generate-referencesand commit the updated latest reference files." The suggested action matches the repo's own instruction. - Found: the working tree shows the file clean, so the stale copy is what the PR carries. The PR touches 4 files and none is a reference file.
- Found:
.github/workflows/generated-files-notice.ymldoes not contradict this. It only prints a non-blocking warning when a human PR edits generated paths, and it states that it "does not fail the workflow and does not block merge". - Impact: confirmed red CI job on this PR, plus a published API reference that states the superseded
'history_change'contract for consumers who read the reference rather than the source. - Priority: raised to
must_fix. The failure is deterministic and the author must land the regenerated file before this PR can merge, which is a harder blocker thanshould_fiximplies.
Issue description
The checked-in browser API reference still says that history_change watches only the pathname. CI regenerates this reference and fails when it finds the difference. The published reference also retains the old contract.
Suggested fix
Run pnpm generate-references. Commit the updated packages/browser/references/posthog-js-references-latest.json.
Prompt to fix with AI (copy-paste)
## Context
@packages/types/src/posthog-config.ts#L1371-1372
<issue_description>
The checked-in browser API reference still says that `history_change` watches only the pathname. CI regenerates this reference and fails when it finds the difference. The published reference also retains the old contract.
</issue_description>
<issue_validation>
- **Checked:** the committed reference JSON, the source of the doc text, the `generate-references` job in `.github/workflows/library-ci.yml`, the `scripts/check-public-api.js` gate it calls, and `.github/workflows/generated-files-notice.yml`.
- **Found:** the committed artifact is stale. `packages/browser/references/posthog-js-references-latest.json` still carries the pre-PR sentence "Capture the initial pageview and pageviews when the pathname changes", next to the unchanged bullet "An object: Capture the initial pageview and pageviews when any selected URL component changes" — the same doc block the PR edits at `packages/types/src/posthog-config.ts:1369-1373`. A grep across `packages/**/*.ts` finds the old wording in no source file, so regeneration will rewrite that string.
- **Found:** the check runs on this PR and fails deterministically. `.github/workflows/library-ci.yml:3-4` triggers on every `pull_request`, and the `generate-references` job at line 474 runs `pnpm generate-references` then `pnpm check:public-api` with no `needs` or `if` gate. `scripts/check-public-api.js:10-56` runs `git status --porcelain` over `packages/browser/references/posthog-js-references-latest.json` and calls `process.exit(1)` on any diff, with the message "Run `pnpm generate-references` and commit the updated latest reference files." The suggested action matches the repo's own instruction.
- **Found:** the working tree shows the file clean, so the stale copy is what the PR carries. The PR touches 4 files and none is a reference file.
- **Found:** `.github/workflows/generated-files-notice.yml` does not contradict this. It only prints a non-blocking warning when a human PR edits generated paths, and it states that it "does not fail the workflow and does not block merge".
- **Impact:** confirmed red CI job on this PR, plus a published API reference that states the superseded `'history_change'` contract for consumers who read the reference rather than the source.
- **Priority:** raised to `must_fix`. The failure is deterministic and the author must land the regenerated file before this PR can merge, which is a harder blocker than `should_fix` implies.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Run `pnpm generate-references`. Commit the updated `packages/browser/references/posthog-js-references-latest.json`.
</potential_solution>
| * - `'history_change'`: Capture the initial pageview and pageviews when the path, query string, or hash changes. | ||
| * `disable_capture_url_hashes` still suppresses hash-only changes. |
There was a problem hiding this comment.
Update the JavaScript SDK config guide
Why we think it's a valid issue
- Checked: the live public guide at
posthog.com/docs/libraries/js/config, the generated reference artifact in this repo, and the repo's own convention comment about website docs. - Found: the premise holds. The published config guide describes the option as "If the special string history_change is provided, PostHog will capture pageviews based on path changes by listening to the browser's history API which is useful for single page apps." It names no query string, search, or hash component. After this PR that sentence states the wrong contract, not merely an incomplete one.
- Found: the guide text is hand-written, so no generation step fixes it. Its wording ("path changes", "useful for single page apps") differs from the api-extractor output in
packages/browser/references/posthog-js-references-latest.json, which uses "Capture the initial pageview and pageviews when the pathname changes". The two artifacts are separate: regenerating the reference JSON leaves the guide unchanged, so this does not duplicate the stale-reference finding. - Found: the repo asks contributors to do this.
packages/browser/src/posthog-core.ts:255-256states "Remember to updatetypes.tswhen changing a default value / to guarantee documentation is up to date, make sure to also update our website docs." The request follows a house rule rather than inventing one. - Impact: confirmed. This PR raises
$pageviewvolume for every project on'history_change', and the guide customers read to explain that number still documents path-only capture. A reader cannot connect the volume change to the SDK behavior from the current text. - Impact: the fix lands in the
posthog.comrepository, so it cannot block this PR and reads as a follow-up. I verified the rendered page, not the exact source pathcontents/docs/libraries/js/config.mdx, so the author should confirm the file name when they make that change.
Issue description
The public JavaScript config guide still says that history_change captures path changes only. Users who read that guide will expect the old behavior and event volume.
Suggested fix
Update contents/docs/libraries/js/config.mdx in the posthog.com repository. List path, query string, and hash changes. Also explain the disable_capture_url_hashes interaction.
Prompt to fix with AI (copy-paste)
## Context
@packages/types/src/posthog-config.ts#L1371-1372
<issue_description>
The public JavaScript config guide still says that `history_change` captures path changes only. Users who read that guide will expect the old behavior and event volume.
</issue_description>
<issue_validation>
- **Checked:** the live public guide at `posthog.com/docs/libraries/js/config`, the generated reference artifact in this repo, and the repo's own convention comment about website docs.
- **Found:** the premise holds. The published config guide describes the option as "If the special string history_change is provided, PostHog will capture pageviews based on **path changes** by listening to the browser's history API which is useful for single page apps." It names no query string, search, or hash component. After this PR that sentence states the wrong contract, not merely an incomplete one.
- **Found:** the guide text is hand-written, so no generation step fixes it. Its wording ("path changes", "useful for single page apps") differs from the api-extractor output in `packages/browser/references/posthog-js-references-latest.json`, which uses "Capture the initial pageview and pageviews when the pathname changes". The two artifacts are separate: regenerating the reference JSON leaves the guide unchanged, so this does not duplicate the stale-reference finding.
- **Found:** the repo asks contributors to do this. `packages/browser/src/posthog-core.ts:255-256` states "Remember to update `types.ts` when changing a default value / to guarantee documentation is up to date, make sure to also update our website docs." The request follows a house rule rather than inventing one.
- **Impact:** confirmed. This PR raises `$pageview` volume for every project on `'history_change'`, and the guide customers read to explain that number still documents path-only capture. A reader cannot connect the volume change to the SDK behavior from the current text.
- **Impact:** the fix lands in the `posthog.com` repository, so it cannot block this PR and reads as a follow-up. I verified the rendered page, not the exact source path `contents/docs/libraries/js/config.mdx`, so the author should confirm the file name when they make that change.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Update `contents/docs/libraries/js/config.mdx` in the `posthog.com` repository. List path, query string, and hash changes. Also explain the `disable_capture_url_hashes` interaction.
</potential_solution>
|
|
||
| if (capturePageview === 'history_change') { | ||
| return { path: true } | ||
| return { path: true, search: true, hash: true } |
There was a problem hiding this comment.
Preserve the pinned history_change contract
Why we think it's a valid issue
- Checked: the
defaultscontract inpackages/types/src/posthog-config.ts, the date-gated default map inpackages/browser/src/posthog-core.ts, the runtime expansion inhistory-autocapture.ts, the shippedCHANGELOGhistory, and the versioned reference snapshots inpackages/browser/references/. - Found: a pinned
defaultsdate cannot freeze this behavior.posthog-core.ts:244pins only the value'history_change';history-autocapture.ts:117-125expands that string at runtime with no reference todefaults. Every project pinned from'2025-05-24'through'2026-08-30'therefore changes behavior on a minor upgrade with no config edit.posthog-config.ts:1737-1739documentsdefaultsas "Configuration defaults for breaking changes. When set to a specific date, enables new default behaviors that were introduced on that date" — the mechanism this change routes around. - Found: the team made the opposite decision nine days ago and told customers so.
packages/browser/CHANGELOG.md:287(release 1.418.13, PR feat(browser): add granular SPA pageview options #4418, dated 2026-08-24) shipped the granular{ path, search, hash }object and states "The existing'history_change'option continues to capture pathname changes." The object form was published as the opt-in for search and hash. This PR reverses that promise in the next minor. - Found: direct precedent for gating.
posthog-core.ts:251gatesdisable_capture_url_hashesat'2026-06-25', andpackages/core/CHANGELOG.md:508calls that URL-capture change "a breaking behavior change" that is "disabled by default for backwards compatibility." Eight dated revisions exist, so adding one is the idiomatic and cheap path the suggestion proposes. - Found: one point cuts the other way, and the author should weigh it. Before 1.418.13 the reference described
'history_change'as "Capture pageviews on the initial page load and on history API changes (pushState, replaceState, popstate)", which supports reading path-only capture as an under-implementation rather than a promise. The explicit pathname wording is recent; the path-only behavior is about a year old. - Impact: confirmed contract break for existing installs. Extra
$pageviewevents land in every SPA on'history_change'after a minor bump, which shifts pageview counts, funnel and conversion baselines, and billable event volume without any customer action. The changeset marksposthog-jsasminor, and the PR leaves the backwards-compatibility checklist item unticked.
Issue description
This mapping changes the documented meaning of the public history_change option. It also changes every pinned defaults snapshot from 2025-05-24 onward. Existing apps will send new query-only and hash-only $pageview events after a minor upgrade. This can change analytics, billing volume, and captured URL data without a configuration change.
Suggested fix
Keep history_change mapped to { path: true }. Add a new dated defaults revision that uses { path: true, search: true, hash: true }. Customers can also opt in with the existing object form. Update the type comment and changeset to explain the gated behavior.
Prompt to fix with AI (copy-paste)
## Context
@packages/browser/src/extensions/history-autocapture.ts#L121
<issue_description>
This mapping changes the documented meaning of the public `history_change` option. It also changes every pinned defaults snapshot from `2025-05-24` onward. Existing apps will send new query-only and hash-only `$pageview` events after a minor upgrade. This can change analytics, billing volume, and captured URL data without a configuration change.
</issue_description>
<issue_validation>
- **Checked:** the `defaults` contract in `packages/types/src/posthog-config.ts`, the date-gated default map in `packages/browser/src/posthog-core.ts`, the runtime expansion in `history-autocapture.ts`, the shipped `CHANGELOG` history, and the versioned reference snapshots in `packages/browser/references/`.
- **Found:** a pinned `defaults` date cannot freeze this behavior. `posthog-core.ts:244` pins only the *value* `'history_change'`; `history-autocapture.ts:117-125` expands that string at runtime with no reference to `defaults`. Every project pinned from `'2025-05-24'` through `'2026-08-30'` therefore changes behavior on a minor upgrade with no config edit. `posthog-config.ts:1737-1739` documents `defaults` as "Configuration defaults for **breaking changes**. When set to a specific date, enables new default behaviors that were introduced on that date" — the mechanism this change routes around.
- **Found:** the team made the opposite decision nine days ago and told customers so. `packages/browser/CHANGELOG.md:287` (release 1.418.13, PR #4418, dated 2026-08-24) shipped the granular `{ path, search, hash }` object and states "The existing `'history_change'` option continues to capture pathname changes." The object form was published as the opt-in for search and hash. This PR reverses that promise in the next minor.
- **Found:** direct precedent for gating. `posthog-core.ts:251` gates `disable_capture_url_hashes` at `'2026-06-25'`, and `packages/core/CHANGELOG.md:508` calls that URL-capture change "a breaking behavior change" that is "disabled by default for backwards compatibility." Eight dated revisions exist, so adding one is the idiomatic and cheap path the suggestion proposes.
- **Found:** one point cuts the other way, and the author should weigh it. Before 1.418.13 the reference described `'history_change'` as "Capture pageviews on the initial page load and on history API changes (pushState, replaceState, popstate)", which supports reading path-only capture as an under-implementation rather than a promise. The explicit pathname wording is recent; the path-only behavior is about a year old.
- **Impact:** confirmed contract break for existing installs. Extra `$pageview` events land in every SPA on `'history_change'` after a minor bump, which shifts pageview counts, funnel and conversion baselines, and billable event volume without any customer action. The changeset marks `posthog-js` as `minor`, and the PR leaves the backwards-compatibility checklist item unticked.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Keep `history_change` mapped to `{ path: true }`. Add a new dated defaults revision that uses `{ path: true, search: true, hash: true }`. Customers can also opt in with the existing object form. Update the type comment and changeset to explain the gated behavior.
</potential_solution>
|
|
||
| if (capturePageview === 'history_change') { | ||
| return { path: true } | ||
| return { path: true, search: true, hash: true } |
There was a problem hiding this comment.
Do not count PostHog's URL cleanup as a pageview
Why we think it's a valid issue
- Checked: Whether any SDK-owned code calls the patched
replaceStatewith a URL that differs only in the query string. Searchedpackages/browser/srcandpackages/browser-common/srcforreplaceStateoutside tests. - Found:
clearRestoreTokenFromUrlatpackages/browser/src/extensions/conversations/external/url-utils.ts:63-78deletesph_conv_restorefrom the URL and calls the globalwindow.history.replaceState(window.history.state, '', newUrl). It uses the global method, so the patch installed by_patchHistoryMethod(packages/browser/src/extensions/history-autocapture.ts:78-101) intercepts it. The path and the hash do not change; onlylocation.searchchanges. - Found: The changed line makes
options.searchtrue forhistory_change, so the search clause in_hasLocationChanged(packages/browser/src/extensions/history-autocapture.ts:130-140) now fires._capturePageviewthen sends$pageviewwithnavigation_type: 'replaceState'(packages/browser/src/extensions/history-autocapture.ts:151). Before this changehistory_changecomparedpathnamealone, so the same cleanup was silent. The regression is introduced by this line. - Checked: The order of installation, to confirm the patch is live when the cleanup runs. Read the extension registration in
packages/browser/src/posthog-core.ts:1102-1103and:1131-1132, and the conversations load path. - Found:
historyAutocaptureis registered beforeconversations, and the conversations manager loads lazily on remote config throughloadExternalDependency(packages/browser/src/extensions/conversations/posthog-conversations.ts:51and:155). The patch is therefore always in place first._lastLocationis set in the constructor while the token is still in the URL, so the cleanup produces a genuine search difference. - Checked: The downstream effect of an extra
$pageview. - Found:
doPageViewatpackages/browser/src/page-view.ts:77-84resets the scroll context and closes the previous pageview segment. The synthetic pageview therefore ends the real pageview's duration and scroll measurement almost immediately, and emits$prev_pageview_max_scrolland related properties for a segment of near-zero length. - Found: The repeat-cleanup concern is supported by the code's own comment at
packages/browser/src/extensions/conversations/external/index.tsx:480-484: cleanup runs three times, because "SPA routers (Next.js, React Router, etc.) maintain their own URL state and can overwrite a single replaceState call." Each overwrite and re-clean pair changessearchagain, so a router that restores the parameter multiplies the synthetic pageviews. - Impact: A user who opens a conversations restore link on any install with
capture_pageview: 'history_change'gets at least one$pageviewthat represents no navigation, plus corrupted scroll and duration attribution on the real pageview. The trigger is a designed product path, not a rare edge case. The same class of problem also reaches the toolbar hash cleanup atpackages/browser/src/extensions/toolbar.ts:109, which now changes a captured URL component whendisable_capture_url_hashesis false. This is a correctness defect that works against the accuracy goal of the change itself.
Issue description
This enables search capture for every wrapped replaceState call. Conversations uses replaceState to remove its ph_conv_restore parameter after remote configuration loads. That cleanup now sends a $pageview, although the user did not navigate. It also resets pageview duration and scroll state. Routers can restore the parameter between cleanup attempts, which can create more synthetic pageviews.
Suggested fix
Add an internal way to call replaceState without history autocapture. Use it for SDK-owned URL cleanup. Add an integration test with ?ph_conv_restore=.... Assert that cleanup sends no pageview after the initial pageview.
Prompt to fix with AI (copy-paste)
## Context
@packages/browser/src/extensions/history-autocapture.ts#L121
<issue_description>
This enables search capture for every wrapped `replaceState` call. Conversations uses `replaceState` to remove its `ph_conv_restore` parameter after remote configuration loads. That cleanup now sends a `$pageview`, although the user did not navigate. It also resets pageview duration and scroll state. Routers can restore the parameter between cleanup attempts, which can create more synthetic pageviews.
</issue_description>
<issue_validation>
- **Checked:** Whether any SDK-owned code calls the patched `replaceState` with a URL that differs only in the query string. Searched `packages/browser/src` and `packages/browser-common/src` for `replaceState` outside tests.
- **Found:** `clearRestoreTokenFromUrl` at `packages/browser/src/extensions/conversations/external/url-utils.ts:63-78` deletes `ph_conv_restore` from the URL and calls the global `window.history.replaceState(window.history.state, '', newUrl)`. It uses the global method, so the patch installed by `_patchHistoryMethod` (`packages/browser/src/extensions/history-autocapture.ts:78-101`) intercepts it. The path and the hash do not change; only `location.search` changes.
- **Found:** The changed line makes `options.search` true for `history_change`, so the search clause in `_hasLocationChanged` (`packages/browser/src/extensions/history-autocapture.ts:130-140`) now fires. `_capturePageview` then sends `$pageview` with `navigation_type: 'replaceState'` (`packages/browser/src/extensions/history-autocapture.ts:151`). Before this change `history_change` compared `pathname` alone, so the same cleanup was silent. The regression is introduced by this line.
- **Checked:** The order of installation, to confirm the patch is live when the cleanup runs. Read the extension registration in `packages/browser/src/posthog-core.ts:1102-1103` and `:1131-1132`, and the conversations load path.
- **Found:** `historyAutocapture` is registered before `conversations`, and the conversations manager loads lazily on remote config through `loadExternalDependency` (`packages/browser/src/extensions/conversations/posthog-conversations.ts:51` and `:155`). The patch is therefore always in place first. `_lastLocation` is set in the constructor while the token is still in the URL, so the cleanup produces a genuine search difference.
- **Checked:** The downstream effect of an extra `$pageview`.
- **Found:** `doPageView` at `packages/browser/src/page-view.ts:77-84` resets the scroll context and closes the previous pageview segment. The synthetic pageview therefore ends the real pageview's duration and scroll measurement almost immediately, and emits `$prev_pageview_max_scroll` and related properties for a segment of near-zero length.
- **Found:** The repeat-cleanup concern is supported by the code's own comment at `packages/browser/src/extensions/conversations/external/index.tsx:480-484`: cleanup runs three times, because "SPA routers (Next.js, React Router, etc.) maintain their own URL state and can overwrite a single replaceState call." Each overwrite and re-clean pair changes `search` again, so a router that restores the parameter multiplies the synthetic pageviews.
- **Impact:** A user who opens a conversations restore link on any install with `capture_pageview: 'history_change'` gets at least one `$pageview` that represents no navigation, plus corrupted scroll and duration attribution on the real pageview. The trigger is a designed product path, not a rare edge case. The same class of problem also reaches the toolbar hash cleanup at `packages/browser/src/extensions/toolbar.ts:109`, which now changes a captured URL component when `disable_capture_url_hashes` is false. This is a correctness defect that works against the accuracy goal of the change itself.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Add an internal way to call `replaceState` without history autocapture. Use it for SDK-owned URL cleanup. Add an integration test with `?ph_conv_restore=...`. Assert that cleanup sends no pageview after the initial pageview.
</potential_solution>
Problem
$pageviewevents. Every number built on pageviews reads low.capture_pageview: 'history_change'expanded to{ path: true }only, so_hasLocationChangedcomparedlocation.pathnamealone. ApushState,replaceState, orpopstatethat changed only the query string or only the hash captured nothing.defaultsof'2025-05-24'or later, so it hits every new SPA install.$pageleavestill fires from a real page unload, which is why an affected customer sees pageleaves match their old tracker while pageviews come up short.Changes
'history_change'now selects all three URL components instead of the path alone.capture_pageview'history_change'before'history_change'afterdisable_capture_url_hashes){ path: true }disable_capture_url_hasheskeeps priority:_shouldCaptureHashChangesalready gates the hash component, anddisable_capture_url_hashesis itself a default from'2026-06-25'onward. A test covers this combination.capture_pageviewand a changeset.Note
This raises the
$pageviewcount for SPAs on'history_change', which is the point, but it is a visible change in event volume. Anyone who wants the old behaviour can setcapture_pageview: { path: true }. The granular{ path, search, hash }shape is already public, so no new API is added.Release info Sub-libraries affected
Libraries affected
Checklist
'history_change'changes on purpose; see the note aboveIf releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Fully autonomous
searcha default and leavinghashopt-in. Rejected it: hash routing is a normal SPA pattern, anddisable_capture_url_hashesalready gives the escape hatch, and it is on by default for'2026-06-25'defaults.history-autocapture,config,cookieless, andposthog-core-also: all pass. One unrelated suite (persistence key policy) fails onmainin this environment as well.pnpm typecheckcannot finish here because the@posthog/rrweb*workspace packages are not built; CI covers it.Created with PostHog Desktop from this inbox report.