Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(ui): hide protect-check spinner while challenge widget is visible by mwickett · Pull Request #9099 · clerk/javascript · GitHub
Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): hide protect-check spinner while challenge widget is visible by mwickett · Pull Request #9099 · clerk/javascript · GitHub
Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

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

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(ui): hide protect-check spinner while challenge widget is visible by mwickett · Pull Request #9099 · clerk/javascript · GitHub
Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): hide protect-check spinner while challenge widget is visible by mwickett · Pull Request #9099 · clerk/javascript · GitHub
Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): hide protect-check spinner while challenge widget is visible by mwickett · Pull Request #9099 · clerk/javascript · GitHub
Skip to content

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

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

fix(ui): hide protect-check spinner while challenge widget is visible - #9099

Merged
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner
Jul 7, 2026
Merged

fix(ui): hide protect-check spinner while challenge widget is visible#9099
alexcarpenter merged 9 commits into
mainfrom
mwickett/prot-863-protect-check-spinner

Conversation

@mwickett

@mwickettmwickett commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Fixes two UX issues in the Protect check card reported while dogfooding the challenge flow (Slack report):

  1. The loading spinner kept spinning below the Turnstile widget once it appeared — two competing progress indicators.
  2. The empty SDK container reserved minHeight: 60px, leaving a hole of dead space between the header and the spinner before any widget rendered.

Root cause: the card only knows "the check is running" (isRunning spans the whole lifecycle — SDK load → widget render → solve → verify → proof submit), and the challenge-script contract had no way to say "I'm showing UI now".

What changed

@clerk/shared — the executeProtectCheck contract gains an optional visibility handshake: the host passes setWidgetVisible(visible: boolean): Promise<void> in the script's init payload. The script calls it right before revealing UI in the container (and with false once its widget is done). The promise resolves only after the host has committed the change to the DOM, so the script can reveal its widget with no overlap frame; a script that knows its widget is imminent can call it immediately so the spinner never flashes. Scripts must treat the field as optional (older hosts don't send it).

packages/uiuseProtectCheckRunner implements the host side and exposes the signal-driven isWidgetVisible:

  • The callback commits via flushSync (the same commit-before-returning guarantee BaseRouter relies on), then resolves — that ordering is what makes the "no overlap frame" promise real.
  • It is constructed per run, closing over that run's cancelled flag, abort signal, and mount state: aborting a script is best-effort by contract, so a zombie script from a timed-out, retried, or superseded run can still call it late — those stale signals resolve as silent no-ops instead of flipping visibility under the active run.
  • The spinner renders only while the check runs and no widget is signalled visible; the widget-visibility decision lives in the challenge script (the Turnstile implementation), not in DOM observation — no ResizeObserver/MutationObserver.
  • The spinner's entrance is debounced with the existing useSpinDelay hook (300ms): a check that completes inside the window — or a script that signals its widget immediately — never flashes a spinner at all (the card header carries the first moments). The error and widget-visibility gates sit outside the delay hook, so its minimum-visible duration can never outrank the handshake's spinner-gone-at-resolve guarantee or hold a spinner next to the retry button.
  • The empty container drops out of flex flow (position: absolute, the CaptchaElementgapless idiom) instead of reserving 60px.
  • Each new run clears the container and resets visibility, so a retry or chained challenge starts on a clean spinner.
  • Per design-system feedback in the thread: no visible "Loading…" text — the localized label rides on the Spinner as aria-label (the Button loading idiom; Spinner already announces via aria-busy/aria-live).

Deploy ordering

The speed-bump script's side of the handshake is clerk/protect#213 (guarded fallback, safe on older hosts). Specter deploys before this reaches production: this host listens only for the signal, so against an unsignaling old script the spinner stays under the widget and the container never re-enters layout flow.

How to test

  • pnpm vitest run src/components/SignIn/__tests__/SignInProtectCheck.test.tsx src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx in packages/ui (visibility swap driven through the captured signal, async-confirm semantics, layout flip, retry hygiene, zombie-run guard, spin-delay entrance and its gates), and src/internal/clerk-js/__tests__/protectCheck.test.ts in packages/shared (contract forwarding).
  • Manually: any sign-in gated by a protect_check against a Specter serving the fix(backend-core): Fix case of buildAuthState erroneous return value #213 script — spinner alone during invisible phases, widget alone when interaction is required, spinner back during the verify round-trip.

Fixes PROT-863

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:

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Improved Protect check UI so the loading spinner no longer appears while the challenge widget is visible, and responds correctly to widget visibility changes in both Sign In and Sign Up.
    • Reduced layout “reserved” space so the cards stay compact until the widget renders.
    • Refined retry behavior to clear stale widget content and restore the correct loading state.
    • Updated loading presentation for improved accessibility (centered spinner with an aria label).
  • Tests

    • Added deterministic tests covering challenge-widget visibility transitions and retry cleanup, with proper synchronization.

The protect-check card had no notion of the challenge SDK rendering visible
content: the spinner stayed up alongside the Turnstile widget, and the empty
SDK container reserved 60px of dead space above the spinner. Track the
container's rendered height in useProtectCheckRunner (ResizeObserver) and key
the spinner + container layout off it.
PROT-863
Address external review findings on the spinner fix:
- Attach the visibility observer via a callback ref: SignUpProtectCheck
renders null until a challenge exists, so a mount-time effect could
permanently miss the container.
- Fall back to MutationObserver where ResizeObserver is unavailable
(legacy bundle) instead of freezing the empty-state layout.
- Clear the container when a new run starts, so a retry or chained
challenge doesn't keep a dead widget on screen (and the SDK can't
stack a second one under it).
PROT-863
@vercel

vercelBot commented Jul 7, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
clerk-js-sandboxReadyReadyPreview, CommentJul 7, 2026 8:51pm
swingsetReadyReadyPreview, CommentJul 7, 2026 8:51pm

Request Review

@changeset-bot

changeset-botBot commented Jul 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3225a44

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

This PR includes changesets to release 23 packages
NameType
@clerk/sharedPatch
@clerk/uiPatch
@clerk/astroPatch
@clerk/backendPatch
@clerk/chrome-extensionPatch
@clerk/clerk-jsPatch
@clerk/electronPatch
@clerk/expo-passkeysPatch
@clerk/expoPatch
@clerk/expressPatch
@clerk/fastifyPatch
@clerk/headlessPatch
@clerk/honoPatch
@clerk/localizationsPatch
@clerk/mswPatch
@clerk/nextjsPatch
@clerk/nuxtPatch
@clerk/react-routerPatch
@clerk/reactPatch
@clerk/tanstack-react-startPatch
@clerk/testingPatch
@clerk/vuePatch
@clerk/swingsetPatch

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

@coderabbitai

coderabbitaiBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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 widget visibility signaling to the Protect check SDK path, tracks that state in the UI runner, and updates SignIn and SignUp protect-check layouts and spinner behavior so the spinner hides while the widget is visible. Tests and a changeset were added.

Changes

Protect check widget visibility

Layer / File(s)Summary
SDK visibility callback
packages/shared/src/internal/clerk-js/protectCheck.ts, packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts, .changeset/protect-check-spinner-widget-visibility.md
Adds setWidgetVisible to the protect-check options and init payload, updates the forwarding test, and records the release note.
Runner visibility tracking
packages/ui/src/hooks/useProtectCheckRunner.ts
Adds isWidgetVisible state, wires setWidgetVisible into executeProtectCheck, clears prior widget DOM before a new run, and returns the new flag.
SignIn UI and tests
packages/ui/src/components/SignIn/SignInProtectCheck.tsx, packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignIn, with tests covering visibility transitions and retry cleanup.
SignUp UI and tests
packages/ui/src/components/SignUp/SignUpProtectCheck.tsx, packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
Uses isWidgetVisible to hide the spinner and adjust container positioning in SignUp, with tests covering visibility transitions and layout changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • clerk/javascript#8329: Introduced the Protect-check flow and executeProtectCheck integration that this PR extends with widget visibility signaling.

Suggested labels:clerk-js

Suggested reviewers:wobsoriano, zourzouvillys, jacekradko

Poem

A bunny saw the widget bloom,
The spinner hopped out of the room.
No extra gap, just clean display,
Then back again if it drifts away. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise, specific, and accurately summarizes the main UX change to the Protect check spinner.

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

@github-actions

github-actionsBot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-07T20:52:43.481Z

Summary

MetricCount
Packages analyzed19
Packages with changes1
🔴 Breaking changes0
🟡 Non-breaking changes0
🟢 Additions1

@clerk/shared

Current version: 4.24.0
Recommended bump: MINOR → 4.25.0

Subpath ./internal/clerk-js/protectCheck

🟢 Additions (1)

Added: ExecuteProtectCheckOptions.setWidgetVisible
+ setWidgetVisible?: (visible: boolean) => Promise<void>;

Added property ExecuteProtectCheckOptions.setWidgetVisible


Report generated by Break Check

Last ran on 3225a44.

@pkg-pr-new

pkg-pr-newBot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: 3225a44

@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.

🧹 Nitpick comments (1)
packages/ui/src/hooks/useProtectCheckRunner.ts (1)

221-232: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider synchronously resetting isWidgetVisible when clearing the container.

The container is cleared here, but isWidgetVisible state is left untouched — it only updates once the ResizeObserver/MutationObserver callback fires. ResizeObserver callbacks run after layout but before paint in the same frame, so in practice this shouldn't produce a visible flash in browsers that support it, but the MutationObserver fallback (used in legacy browsers) has less strict scheduling guarantees, and the test suite for this feature has to manually call setRenderedHeight(container, 0) after retry rather than asserting the reset happens automatically — a sign the state isn't deterministically synced with the DOM clear.

Explicitly setting isWidgetVisible to false here would remove the dependency on observer timing and make the behavior deterministic across browsers.

♻️ Proposed fix
 const container = containerNodeRef.current;
if (!container) {
return;
}
// This run owns the container outright: drop anything a previous run left behind (a solved or
// errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a
// second widget under a stale one.
while (container.firstChild) {
container.removeChild(container.firstChild);
}
+ setIsWidgetVisible(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ui/src/hooks/useProtectCheckRunner.ts` around lines 221 - 232, When
`useProtectCheckRunner` clears the `containerNodeRef.current` DOM, also
synchronously reset `isWidgetVisible` to false in the same cleanup path. Update
the logic around the container wipe so the state is kept in sync immediately
instead of waiting for the `ResizeObserver`/`MutationObserver` callbacks, using
the existing `containerNodeRef` and `isWidgetVisible` state in
`useProtectCheckRunner`. This makes the widget visibility deterministic across
browsers and avoids relying on observer timing after a retry or rerender.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 221-232: When `useProtectCheckRunner` clears the
`containerNodeRef.current` DOM, also synchronously reset `isWidgetVisible` to
false in the same cleanup path. Update the logic around the container wipe so
the state is kept in sync immediately instead of waiting for the
`ResizeObserver`/`MutationObserver` callbacks, using the existing
`containerNodeRef` and `isWidgetVisible` state in `useProtectCheckRunner`. This
makes the widget visibility deterministic across browsers and avoids relying on
observer timing after a retry or rerender.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b377013b-7548-4708-a9a0-b221429b17d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6f97ef5 and 7b6387a.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/ui/src/components/SignIn/SignInProtectCheck.tsx
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/SignUpProtectCheck.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

…container
Keeps isWidgetVisible deterministically in sync with the DOM wipe instead
of depending on observer scheduling (looser on the MutationObserver
fallback). Review feedback from CodeRabbit on #9099.
PROT-863
A spinner with visible 'Loading…' text next to it isn't a pattern the
design system uses anywhere else (thread feedback from Alex Carpenter).
Show just the spinner and carry the localized label as aria-label — the
Spinner primitive already announces via aria-busy/aria-live, matching
how Button labels its loading state.
PROT-863
…ignal
Replace DOM observation with an explicit contract (per Theo): the
executeProtectCheck init payload now carries an optional
setWidgetVisible(visible) callback. The script calls it right before
revealing UI in the container (and with false once its widget is done);
the promise resolves only after the host commits the change to the DOM
(flushSync), so the script can reveal its widget with no overlap frame.
Scripts that know their widget is imminent can call it immediately to
avoid a spinner flash.
The ResizeObserver/MutationObserver machinery and callback-ref plumbing
in useProtectCheckRunner are gone; isWidgetVisible is purely
signal-driven. Deploy note: the speed-bump script must ship its
setWidgetVisible calls (guarded, clerk/protect#213) before this reaches
production, or the spinner stays visible under the widget again.
PROT-863

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/hooks/useProtectCheckRunner.ts`:
- Around line 78-88: The current setWidgetVisible callback in
useProtectCheckRunner can still update state after a run has been cleaned up,
letting a stale script flip the next run’s isWidgetVisible. Scope the callback
to each executeProtectCheck run or add a guard using that run’s cancelled flag
before calling flushSync, so any late setWidgetVisible calls from an abandoned
challenge become no-ops.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1504b7-7333-4fe7-ba37-3725cf326f39

📥 Commits

Reviewing files that changed from the base of the PR and between 292da70 and 24e903e.

📒 Files selected for processing (6)
  • .changeset/protect-check-spinner-widget-visibility.md
  • packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts
  • packages/shared/src/internal/clerk-js/protectCheck.ts
  • packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx
  • packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx
  • packages/ui/src/hooks/useProtectCheckRunner.ts

Comment threadpackages/ui/src/hooks/useProtectCheckRunner.ts Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

take a look at #9099 (comment) before merging but else lgtm

The abort contract is best-effort, so a zombie script from a timed-out,
retried, or superseded run could still call the stable hook-level
setWidgetVisible and flip visibility under the active run. Build the
callback per run inside the effect, guarded by that run's cancelled
flag, its abort signal, and mount state. External review finding on
#9099.
PROT-863
…h it
Gate the spinner's entrance behind the house useSpinDelay (300ms): a
check that completes inside the window — or a script that signals its
widget immediately — shows no spinner at all instead of a flash. The
error and widget-visibility gates stay outside the delay hook so its
minimum-visible duration can never outrank the handshake's
spinner-gone-at-resolve guarantee or hold a spinner next to the retry
button. Matters most once PROT-864 pre-warms Turnstile and execute()
resolves in a couple hundred milliseconds.
PROT-863
Comment thread.changeset/protect-check-spinner-widget-visibility.md Outdated

@alexcarpenteralexcarpenter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

one nit about changeset but LGTM

Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
@alexcarpenter
alexcarpenter merged commit bab1f29 into mainJul 7, 2026
51 checks passed
@alexcarpenter
alexcarpenter deleted the mwickett/prot-863-protect-check-spinner branch July 7, 2026 20:53
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.

3 participants

@mwickett@alexcarpenter@zourzouvillys