fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(ui): sync BaseRouter state on pushState/replaceState - #7840

Merged
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync
Feb 13, 2026
Merged

fix(ui): sync BaseRouter state on pushState/replaceState#7840
brkalow merged 9 commits into
mainfrom
brk.fix/router-sync

Conversation

@brkalow

@brkalowbrkalow commented Feb 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Problem: After popup OAuth completes, the parent page's URL changes via clerk.navigate(), which ultimately calls the host application's router (e.g., to /sso-callback then the redirect URL), but BaseRouter only listened for popstate events — which only fire on back/forward navigation, not programmatic pushState/replaceState. This left the router's internal state out of sync with the actual URL.
  • Fix: Replace useWindowEventListener with a new useHistoryChangeObserver hook that uses the Navigation API (currententrychange) when available, falling back to monkey-patching history.pushState/replaceState. PathRouter now listens for pushstate, replacestate, and popstate events.
  • Safety: Uses global subscription sets so multiple BaseRouter instances don't conflict when patching history methods. Navigation API detection is evaluated at effect-time (not module-load) for SSR safety. Notifications are deferred via microtask to prevent re-entrancy.
  • Removes unused useWindowEventListener hook.
  • Adds integration test for popup OAuth with path-based routing.

Test plan

  • Existing integration tests pass (OAuth redirect flows, hash-based routing)
  • New oauth popup with path-based routing integration test validates popup OAuth completes and parent navigates to redirect URL
  • Manual: open <SignIn oauthFlow="popup" /> with path-based routing, complete OAuth in popup, verify parent page lands on the redirect URL

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a popup-based sign-in page and an in-page "Sign in" button that opens the OAuth popup.
  • Bug Fixes

    • Improved router history handling so post-popup OAuth redirects reliably sync with the app.
  • Tests

    • Added end-to-end tests covering the OAuth popup sign-in flow for React/Vite.
  • Chores

    • Removed an unused UI hook export and updated router history observation to support the popup flow.

brkalowand others added 2 commits February 12, 2026 20:22
…pup OAuth
BaseRouter now uses a single useHistoryChangeObserver hook driven by the
refreshEvents array to stay in sync with the URL. With the Navigation API
it listens to currententrychange filtered by navigationType; without it,
it wraps pushState/replaceState and listens for native window events.
PathRouter opts in to pushstate, replacestate, and popstate. This fixes
the popup OAuth flow where the SSO callback URL was pushed via
history.pushState but the PathRouter never re-rendered because popstate
does not fire on pushState calls.
Adds an integration test that forces the popup OAuth flow with path-based
routing to verify the fix.
…afety
Use global subscription sets for history monkey-patching to prevent
conflicts when multiple BaseRouter instances mount. Move Navigation API
detection into the effect for SSR safety. Defer notify via microtask to
avoid re-entrancy. Remove unused useWindowEventListener hook.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel

vercelBot commented Feb 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentFeb 13, 2026 4:05pm

Request Review

@changeset-bot

changeset-botBot commented Feb 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cbab21e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
NameType
@clerk/uiPatch
@clerk/chrome-extensionPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a SignIn popup flow to the React-Vite template: a new SignInPopup page, a /sign-in-popup route, and an additional SignInButton in Home. Introduces path-based OAuth popup tests for the React/Vite app (duplicated test suite). Removes the exported useWindowEventListener hook and its implementation. Replaces its usage with a new useHistoryChangeObserver in BaseRouter, updates BaseRouter props to accept history events, and patches history.pushState/replaceState. PathRouter now refreshes on pushstate, replacestate, and popstate. Adds a changeset and a new long-running app preset entry.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 20.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection⚠️ Warning⚠️ Unable to check for merge conflicts: Stream setup permanently failed: 14 UNAVAILABLE: read ECONNRESET
✅ Passed checks (2 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe PR title accurately describes the main fix: BaseRouter state synchronization when pushState/replaceState are called, which is the core issue being resolved.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/ui/src/router/BaseRouter.tsx`:
- Around line 43-46: The forEach callbacks currently use concise arrow bodies
that implicitly return the result (e.g., in the history.pushState override and
other locations referencing pushStateSubscribers.forEach and
replaceStateSubscribers.forEach); change those arrow callbacks to block bodies
with no return value (or replace with a for...of loop) so they do not return a
value — update the callbacks used in history.pushState override,
history.replaceState override, and any other pushStateSubscribers.forEach /
replaceStateSubscribers.forEach usages (the blocks around lines 43-46, 54-57,
100-106, 120-125) to use explicit block-bodied arrows that call the subscriber
and do not return anything.

Comment on lines +43 to +46
history.pushState = (...args: Parameters<History['pushState']>) => {
originalPushState!(...args);
pushStateSubscribers.forEach(fn => fn());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix lint errors: forEach callbacks must not return values.

Biome flags these callbacks as errors; CI will fail unless they’re changed to block bodies with no return value.

Suggested fix
- history.pushState = (...args: Parameters<History['pushState']>) => {- originalPushState!(...args);- pushStateSubscribers.forEach(fn => fn());- };+ history.pushState = (...args: Parameters<History['pushState']>) => {+ originalPushState!(...args);+ pushStateSubscribers.forEach(fn => {+ fn();+ });+ };- history.replaceState = (...args: Parameters<History['replaceState']>) => {- originalReplaceState!(...args);- replaceStateSubscribers.forEach(fn => fn());- };+ history.replaceState = (...args: Parameters<History['replaceState']>) => {+ originalReplaceState!(...args);+ replaceStateSubscribers.forEach(fn => {+ fn();+ });+ };- unmappedEvents.forEach(e => window.addEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- unmappedEvents.forEach(e => window.removeEventListener(e, notify));+ unmappedEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });- windowEvents.forEach(e => window.addEventListener(e, notify));+ windowEvents.forEach(e => {+ window.addEventListener(e, notify);+ });- windowEvents.forEach(e => window.removeEventListener(e, notify));+ windowEvents.forEach(e => {+ window.removeEventListener(e, notify);+ });

As per coding guidelines, “All code must pass ESLint checks with the project's configuration.”

Also applies to: 54-57, 100-106, 120-125

🧰 Tools
🪛 Biome (2.3.14)

[error] 45-45: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/ui/src/router/BaseRouter.tsx` around lines 43 - 46, The forEach
callbacks currently use concise arrow bodies that implicitly return the result
(e.g., in the history.pushState override and other locations referencing
pushStateSubscribers.forEach and replaceStateSubscribers.forEach); change those
arrow callbacks to block bodies with no return value (or replace with a for...of
loop) so they do not return a value — update the callbacks used in
history.pushState override, history.replaceState override, and any other
pushStateSubscribers.forEach / replaceStateSubscribers.forEach usages (the
blocks around lines 43-46, 54-57, 100-106, 120-125) to use explicit block-bodied
arrows that call the subscriber and do not return anything.

@pkg-pr-new

pkg-pr-newBot commented Feb 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7840

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7840

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7840

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7840

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7840

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7840

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7840

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7840

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7840

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7840

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7840

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7840

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7840

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7840

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7840

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7840

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7840

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7840

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7840

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7840

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7840

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7840

commit: cbab21e

…n popup OAuth test
The previous test used withEmailCodes where OAuth auto-completes the
sign-up (popup returns session directly). This meant the parent page
never navigated to sso-callback via pushState, so BaseRouter's history
observation was never exercised.
Switch to withLegalConsent where the sign-up requires an additional
step. The popup returns return_url instead of session, forcing the
parent to navigate to /sso-callback via pushState — which is the exact
code path the BaseRouter fix addresses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ent stale renders
The history observer's microtask can trigger a React render before
setActive's #updateAccessors sets clerk.session, causing task guards
to see stale state and redirect prematurely. Suppress the observer
during baseNavigate since flushSync already handles the state update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@brkalowbrkalow changed the title fix(ui): sync BaseRouter state on pushState/replaceState after popup OAuthfix(ui): sync BaseRouter state on pushState/replaceStateFeb 13, 2026
Promise.resolve().then(notify);
}
};
nav.addEventListener('currententrychange', handler);

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uses the new baseline currententrychange listener if available

Comment on lines +207 to +209
// Suppresses the history observer during baseNavigate's internal navigation.
// Without this, the observer's microtask triggers a render before setActive's
// #updateAccessors sets clerk.session, causing task guards to see stale state.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setActive strikes again 😈

getQueryString={getQueryString}
internalNavigate={internalNavigate}
refreshEvents={['popstate']}
refreshEvents={['pushstate', 'replacestate', 'popstate']}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already had the refresh mechanism included in the router, this expands it to support pushstate and repalcestate

Comment threadintegration/tests/oauth-flows.test.ts Outdated
Comment threadpackages/ui/src/router/BaseRouter.tsx

@jacekradkojacekradko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really solid overall. Just a bit light on unit tests

@brkalow

Copy link
Copy Markdown
MemberAuthor

@jacekradko fair point! I didn't feel like unit tests would provide much value here, as we'd end up with a lot of mocking and mostly ceremonious assertions. Existing integration tests and the new one cover what we need.

@brkalow
brkalow merged commit 7d783fa into mainFeb 13, 2026
65 of 66 checks passed
@brkalow
brkalow deleted the brk.fix/router-sync branch February 13, 2026 18:55
brkalow added a commit that referenced this pull request Feb 17, 2026
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jacek Radko <jacek@clerk.dev>
brkalow added a commit that referenced this pull request Feb 26, 2026
HashRouter wasn't detecting pushState/replaceState changes from popup
OAuth flows because those calls don't fire hashchange events. This adds
pushstate/replacestate to HashRouter's refreshEvents (matching the
PathRouter fix from #7840) and suppresses the history observer during
external navigation in BaseRouter to prevent stale state updates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@brkalow@jacekradko@nikosdouvlis