Skip to content

🐛 Prevented Escape keypresses in welcome emails Customize modal from exiting settings#27516

Merged
troyciesco merged 7 commits intomainfrom
chris-ny-1234-flaky-member-welcome-emails-e2e-test
Apr 23, 2026
Merged

🐛 Prevented Escape keypresses in welcome emails Customize modal from exiting settings#27516
troyciesco merged 7 commits intomainfrom
chris-ny-1234-flaky-member-welcome-emails-e2e-test

Conversation

@cmraible
Copy link
Copy Markdown
Collaborator

@cmraible cmraible commented Apr 22, 2026

ref https://linear.app/ghost/issue/NY-1234/flaky-member-welcome-emails-e2e-test

Why this change?

Pressing Escape inside the welcome emails "Customize" flow could exit settings instead of just dismissing the active UI layer. That made the interaction flaky in E2E and, more importantly, let a nested control bypass the expected unsaved-changes flow.

Reproduction

  1. Go to Settings > Welcome emails
  2. Open the "Customize" modal
  3. Go to the Design tab
  4. In rapid succession: click on one of the color swatches and immediately hit Escape

Expected behavior

Escape should close the color picker, but leave the Customize modal open

Actual behavior

If you time it just right, the Customize modal will close, then Settings will close, and you'll land on the Analytics screen (or where ever you were before opening settings to begin with).

Root cause

admin-x-settings has a page-level Escape handler that navigates away from settings when no modal is open.

The welcome email customize UI is built with Shade/Radix dialogs and popovers, but the page-level guard was still only checking for the legacy #modal-backdrop element. That created two failure modes:

  1. The customize modal and dirty-confirm dialog were not always recognized as "an open modal", so the page-level handler could still react to Escape.
  2. The color picker popover mounts through a portal. Right after opening it, there is a brief window where the popover has been requested but its content has not mounted yet, so its onEscapeKeyDown handler cannot intercept the keypress. In that gap, Escape can bubble to the page-level handler and exit settings.

Why this fix is correct

This fix closes the gap at the right layers instead of weakening the global shortcut.

  • MainContent now treats open Radix dialog and alertdialog elements as active modals, not just the legacy backdrop. That keeps the page-level Escape shortcut disabled while the customize modal or unsaved-changes confirmation is open.
  • ColorPickerField attaches an early capture-phase Escape listener as soon as the popover is opened. That means the first Escape is claimed by the color picker even if the popover content has not mounted yet. Once the popover is mounted, the normal popover escape handling still applies, and the temporary listener is removed when the popover closes or the component unmounts.

Together, those changes preserve the intended dismissal order:

  1. First Escape: close the nested color picker.
  2. Next Escape: show or dismiss the customize modal's unsaved-changes confirmation (if dirty).
  3. Only when no modal is open should the settings page itself handle Escape.

Test coverage

The PR adds:

  • unit coverage for both the mounted-popover case and the immediate-after-open race
  • e2e coverage proving Escape closes the color picker first and still respects the unsaved-changes confirmation flow

The new unit test captures the early Escape race where the color picker has not mounted yet and the parent customize modal closes, so we have a deterministic red test before fixing the dismissal handling.
The previous test left a render tick between opening the picker and
pressing Escape, which missed the race that dismisses the modal.
Immediate Escape presses could bypass the color picker, leak to the settings shell, and navigate back to Analytics. This keeps Escape scoped to the open picker and ensures the settings shell recognizes Shade dialogs as active modals.
@cmraible cmraible marked this pull request as ready for review April 22, 2026 18:37
@cmraible cmraible requested a review from 9larsons as a code owner April 22, 2026 18:37
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

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

Adds a capturing-window Escape key handler for the email-design color picker popover that prevents default/propagation and closes the popover via a centralized closePopover() routine; the handler is attached when the popover opens, detached on close, and cleaned up on unmount. Refactors onOpenChange to reset picker interaction state only on open/close transitions and switches swatch selection to use closePopover(). Adds hasOpenModal() in MainContent to detect open dialogs by #modal-backdrop or role="dialog"/role="alertdialog" with data-state="open". Tests add a delayed PopoverContent mock and re-enable an e2e escape test.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: preventing Escape keypresses in the welcome emails Customize modal from exiting the settings page.
Description check ✅ Passed The description is directly related to the changeset, providing detailed context about the bug, root causes, and how the fix addresses them across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chris-ny-1234-flaky-member-welcome-emails-e2e-test

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@e2e/tests/admin/settings/member-welcome-emails.test.ts`:
- Line 384: Rename the test title string in the test(...) call for the Escape
behavior so it follows the lowercase "what is tested - expected outcome"
convention; change the current title "Escape closes welcome email color picker
without bypassing unsaved changes confirmation" to a lowercase, dash-separated
form such as "escape closes welcome email color picker - does not bypass unsaved
changes confirmation" by updating the first argument to the test(...)
invocation.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ec99ab7-c093-4a30-bdc4-2d1a20ac1fed

📥 Commits

Reviewing files that changed from the base of the PR and between ffc637e and 2b9fda7.

📒 Files selected for processing (4)
  • apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx
  • apps/admin-x-settings/src/main-content.tsx
  • apps/admin-x-settings/test/unit/membership/welcome-email-customize-modal.test.tsx
  • e2e/tests/admin/settings/member-welcome-emails.test.ts

Comment thread e2e/tests/admin/settings/member-welcome-emails.test.ts Outdated
This trims unnecessary useCallback wrappers from the local reset and close helpers while keeping the global Escape listener callbacks stable for add/removeEventListener cleanup.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx (1)

113-121: Minor: redundant reset/detach on close path.

When nextOpen is false, this path calls resetInteractionState() and detachEarlyEscapeListener(), which closePopover() already performs before driving setOpen(false). It's harmless (both are idempotent), but you could simplify by only resetting/attaching on the open transition:

♻️ Optional simplification
             onOpenChange={(nextOpen) => {
-                resetInteractionState();
                 if (nextOpen) {
+                    resetInteractionState();
                     attachEarlyEscapeListener();
                 } else {
                     detachEarlyEscapeListener();
                 }
                 setOpen(nextOpen);
             }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx`
around lines 113 - 121, The onOpenChange handler currently calls
resetInteractionState() and detachEarlyEscapeListener() for both open and close
transitions even though closePopover() already resets state and detaches the
listener before setOpen(false); update onOpenChange to only call
resetInteractionState() and attachEarlyEscapeListener() when nextOpen is true
and remove those calls from the false branch so closing relies on closePopover()
and setOpen(false) as intended. Ensure you leave setOpen(nextOpen) and the
existing attachEarlyEscapeListener() call for the open path intact and reference
the onOpenChange, resetInteractionState, attachEarlyEscapeListener,
detachEarlyEscapeListener, closePopover, and setOpen symbols when making the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx`:
- Around line 113-121: The onOpenChange handler currently calls
resetInteractionState() and detachEarlyEscapeListener() for both open and close
transitions even though closePopover() already resets state and detaches the
listener before setOpen(false); update onOpenChange to only call
resetInteractionState() and attachEarlyEscapeListener() when nextOpen is true
and remove those calls from the false branch so closing relies on closePopover()
and setOpen(false) as intended. Ensure you leave setOpen(nextOpen) and the
existing attachEarlyEscapeListener() call for the open path intact and reference
the onOpenChange, resetInteractionState, attachEarlyEscapeListener,
detachEarlyEscapeListener, closePopover, and setOpen symbols when making the
change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 969d6cf2-ca48-4c66-89c9-42ca573f46f2

📥 Commits

Reviewing files that changed from the base of the PR and between 2b9fda7 and dbd5dc7.

📒 Files selected for processing (1)
  • apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx

This removes the last unnecessary useCallback wrapper so the early Escape path reads more directly while preserving the same listener cleanup behavior.
await expect(page).toHaveURL(/\/ghost\/#\/settings\/memberemails$/);
});

test.skip('Escape closes welcome email color picker without bypassing unsaved changes confirmation', async ({page}) => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

only changes here are un-skipping this test, and updating the names to follow the convention, as pointed out by coderabbit.

This extracts the open Shade dialog selector into a named constant and documents why settings checks both the legacy modal backdrop and newer Shade/Radix dialogs before handling Escape globally.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/admin-x-settings/src/main-content.tsx (1)

45-50: Honor already-handled Escape events before the page shortcut.

If a nested dialog/control calls preventDefault() but doesn’t stop propagation, this window listener can still run. Bail out first so one Escape cannot both dismiss inner UI and trigger settings navigation if modal DOM state changes during the same event.

Suggested hardening
         const handleKeyDown = (event: KeyboardEvent) => {
             if (event.key === 'Escape') {
+                if (event.defaultPrevented) {
+                    return;
+                }
+
                 // Don't navigate away if a modal is open - let the modal handle ESC
                 if (hasOpenModal()) {
                     return;
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-x-settings/src/main-content.tsx` around lines 45 - 50, The Escape
handler can run even if a nested control already called event.preventDefault();
update handleKeyDown to bail out early by checking event.defaultPrevented (e.g.,
if (event.defaultPrevented) return;) before any logic, then keep the existing
hasOpenModal() check so a single Escape cannot both dismiss inner UI and trigger
navigation via handleKeyDown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/admin-x-settings/src/main-content.tsx`:
- Around line 45-50: The Escape handler can run even if a nested control already
called event.preventDefault(); update handleKeyDown to bail out early by
checking event.defaultPrevented (e.g., if (event.defaultPrevented) return;)
before any logic, then keep the existing hasOpenModal() check so a single Escape
cannot both dismiss inner UI and trigger navigation via handleKeyDown.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 632f0262-8896-4e6e-8e40-43a1e0a3d2b6

📥 Commits

Reviewing files that changed from the base of the PR and between 9533fe5 and 1be27dc.

📒 Files selected for processing (1)
  • apps/admin-x-settings/src/main-content.tsx

@sonarqubecloud
Copy link
Copy Markdown

@cmraible cmraible requested a review from troyciesco April 22, 2026 22:07
@cmraible cmraible added the ok to merge for me You can merge this on my behalf if you want. label Apr 22, 2026
// Don't navigate away if a modal is open - let the modal handle ESC
const modalBackdrop = document.getElementById('modal-backdrop');
if (modalBackdrop) {
if (hasOpenModal()) {
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.

interesting TIL - i was surprised the linter wasn't yelling about hasOpenModal not being in the dep array on L63, but it's because there's no state or reactivity or anything in the function, so it doesn't need to be.

I'd always thought every function/variable got declared there but I guess not! Probably because like 98% of the time they do have React stuff going on.

@troyciesco troyciesco merged commit 1a44f70 into main Apr 23, 2026
45 checks passed
@troyciesco troyciesco deleted the chris-ny-1234-flaky-member-welcome-emails-e2e-test branch April 23, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok to merge for me You can merge this on my behalf if you want.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants