feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger
, '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

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete - #9641

Draft
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze
Draft

feat(headless): hold exiting popup contents by snapshot and add onOpenChangeComplete#9641
maxyinger wants to merge 1 commit into
mainfrom
max/exit-freeze

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Replaces the Suspense-based Freeze in @clerk/headless with an element snapshot, and gives Dialog.Root / Popover.Root an onOpenChangeComplete callback.

Why change Freeze. It suspended the popup's children on a promise that never settles, so React would keep the last committed DOM while the popup animated out. That works for a sync update. Inside a transition it does not: React holds the whole update until the promise resolves, so nothing from it commits — no data-ending-style, no data-closed on the trigger, no unmount. A consumer closing a dialog from startTransition, or from a React 19 form action (useActionState, <form action>), left the dialog stuck open. freeze.test.tsx now covers that case. The Suspense version also detached every ref and ran every layout-effect cleanup in the subtree at freeze time, and depended on undoing React's display: none.

What Freeze does now. It keeps rendering the element it was handed on the last unfrozen render. Props, conditionals and callbacks baked into that JSX hold; components inside keep rendering. This is what framer-motion's AnimatePresence does, and what FlowStep already did with a ref — it now uses Freeze too. For content that reads what it shows through a hook rather than props, useFrozenValue holds that read, and useIsFrozen reports the state. Both are exported from @clerk/headless/utils.

onOpenChangeComplete(open). Fires from useTransition once the enter or exit animation has finished (on exit, right after the unmount). Reset state here rather than in onOpenChange, so the reset cannot show through the exit. Mirrors Base UI's prop of the same name. Mosaic Dialog.Root withholds it for inline, as it does onOpenChange.

Adoption.Destructive clears its typed phrase in onOpenChangeComplete. The UserButton popup reads its data through context, which the snapshot alone does not hold, so UserButtonPopup re-provides that context through useFrozenValue. The swingset Dialog page's "Confirming a discard" story moves its reset, and a new "Exit animations" story exercises both the hold and the transition path.

Also.useTransition no longer schedules an unmount in the one-frame window where ending outlives a reopen; the new interrupted-close test caught it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

🤖 Generated with Claude Code

https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp

…nChangeComplete
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqhW8vxHmRMqHiuZHN2HCp
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b7c04d1

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercelBot commented Sep 2, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated
clerk-js-sandboxReadyReadyPreviewSep 2, 2026 10:47pm UTC
swingsetReadyReadyPreviewSep 2, 2026 10:47pm UTC

Request Review

@coderabbitai

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
auto_review:
drafts: true

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

@pkg-pr-new

pkg-pr-newBot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9641

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9641

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9641

@clerk/expo

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

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9641

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: b7c04d1

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@maxyinger