Skip to content

Conversation

@ia319
Copy link
Member

@ia319 ia319 commented Oct 7, 2025

Closes #32603

What I did

  • add a reentrancy protection mechanism to the patched getter
  • use a Set to track active elements within the current call stack and setTimeout to defer cleanup, preventing recursive focus calls.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
    There is an error:
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯

 FAIL   @storybook/cli  test/default/cli.test.cjs > help command
AssertionError: expected '(node:2813) [DEP0190] DeprecationWarn…' to be '' // Object.is equality

- Expected
+ Received

+ (node:2813) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.                                           
+ (Use `node --trace-deprecation ...` to show where the warning was created)
+

 ❯ test/default/cli.test.cjs:21:24
     19|   const stdoutString = cleanLog(stdout.toString());
     20| 
     21|   expect(stderrString).toBe('');
       |                        ^
     22|   expect(stdoutString).toContain('init');
     23|   expect(stdoutString).toContain('Initialize Storybook into your project');


 Test Files  1 failed | 473 passed | 17 skipped (491)
      Tests  1 failed | 4450 passed | 45 skipped | 2 todo (4498)
Type Errors  no errors
   Start at  14:33:03
   Duration  427.61s (transform 442.96s, setup 3128.87s, collect 1683.45s, tests 369.15s, environment 15.06s, prepare 46724.53s, typecheck 18.95s)
  • integration tests
  • end-to-end tests

Manual testing

Created a sandbox and added stories for the Cally, Chakra, and React Aria Components libraries.
After testing the pages, everything worked as expected with no errors.
Sandbox link:https://github.com/ia319/storybook-react-vite-focus-sandbox

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook canary-release-pr.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a rare recursive-focus issue that could cause UI freezes or crashes.
    • Improved stability of programmatic focus during rapid focus changes.
    • Prevented focus-related lockups to enhance keyboard navigation and accessibility.
    • Reduced risk of stack overflows in complex focus chains for smoother behavior in dialogs, modals, and interactive components.

- add a reentrancy protection mechanism to the patched  getter
- use a Set to track active elements within the current call stack and
setTimeout to defer cleanup, preventing recursive focus calls.

Fixes storybookjs#32603
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 7, 2025

📝 Walkthrough

Walkthrough

Adds a runtime patch in code/core/src/test/preview.ts that replaces HTMLElement.prototype.focus with a guarded getter/setter wrapper using originalFocus, a focusingElements Set to detect reentrancy, a patchedFocus flag, and a next-tick (setTimeout(..., 0)) cleanup. Also adds exported test-related interfaces.

Changes

Cohort / File(s) Summary
Focus recursion guard & test type exports
code/core/src/test/preview.ts
Implements a guarded patch of HTMLElement.prototype.focus that captures originalFocus, exposes currentFocus, tracks active focus operations in focusingElements to detect re-entry, installs the wrapper once via patchedFocus, defers Set cleanup to the next tick with setTimeout(..., 0), and adds exported interfaces TestParameters and TestTypes (with parameters: TestParameters) including flags dangerouslyIgnoreUnhandledErrors? and throwPlayFunctionExceptions?.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Test as Test code
  participant Elem as HTMLElement instance
  participant Proto as HTMLElement.prototype.focus (getter wrapper)
  participant Orig as originalFocus

  Note over Proto: getter installed once (guarded by patchedFocus)\ntracks currentFocus and focusingElements Set

  Test->>Elem: call focus(...)
  activate Proto
  Proto->>Proto: if focusingElements.has(Elem)?
  alt Not present (first entry)
    Proto->>Proto: focusingElements.add(Elem)
    Proto->>Orig: call originalFocus with bound Elem
    Note right of Proto: schedule focusingElements.delete(Elem) on next tick (setTimeout 0)
    Orig-->>Proto: return
    Proto-->>Test: return result
  else Reentrant call
    Proto-->>Test: call/return originalFocus directly (avoid recursion)
  end
  deactivate Proto
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

  • Verify correct binding when invoking originalFocus (.call/.apply) and that return values/exceptions are preserved.
  • Confirm patchedFocus correctly prevents double-installation and that getter/setter behavior matches original semantics.
  • Check asynchronous cleanup logic (setTimeout 0) for potential memory retention or missed deletions in edge cases.
✨ Finishing touches
  • 📝 Generate docstrings

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

Copy link
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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e08667 and 8b67a74.

📒 Files selected for processing (1)
  • code/core/src/test/preview.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Adhere to ESLint and Prettier rules across all JS/TS source files

Files:

  • code/core/src/test/preview.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Fix type errors and prefer precise typings instead of using any or suppressions, consistent with strict mode

Files:

  • code/core/src/test/preview.ts
🔇 Additional comments (2)
code/core/src/test/preview.ts (2)

126-142: LGTM! Reentrancy guard correctly prevents recursive focus calls.

The getter logic properly implements the reentrancy protection:

  • Detects recursive calls by checking Set membership (lines 128-131)
  • Returns originalFocus to break the cycle when recursion is detected
  • Defers cleanup via setTimeout(..., 0) (line 138) to ensure the protection marker persists throughout the entire synchronous call chain
  • The arrow function correctly captures the lexical this from the getter context

This approach aligns with the PR objective of fixing the "Maximum call stack size exceeded" error in the Cally date picker.


111-112: LGTM! Correctly preserves the original focus method.

Capturing the original focus reference before any patching is essential for the reentrancy guard to function correctly. The comment clearly documents this requirement.

@nx-cloud
Copy link

nx-cloud bot commented Oct 26, 2025

View your CI Pipeline Execution ↗ for commit c1cf4c0

Command Status Duration Result
nx run-many -t build --parallel=3 ✅ Succeeded 46s View ↗

☁️ Nx Cloud last updated this comment at 2025-11-04 15:41:10 UTC

@Sidnioulz Sidnioulz changed the title fix(core): add reentrancy guard to focus patch Core: Add reentry guard to focus patch Nov 3, 2025
Comment on lines 114 to 121
// Must save a real, original `focus` method outside of the patch beforehand
const originalFocus = HTMLElement.prototype.focus;
let currentFocus = HTMLElement.prototype.focus;

// Use a Set to track elements that are currently undergoing a focus operation
const focusingElements = new Set<HTMLElement>();

if (!patchedFocus) {
Copy link
Member

Choose a reason for hiding this comment

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

The change looks good to me! What do you think of scoping the declarations to the if statement so that there's no chance for the Set to be reinstantiated?

Suggested change
// Must save a real, original `focus` method outside of the patch beforehand
const originalFocus = HTMLElement.prototype.focus;
let currentFocus = HTMLElement.prototype.focus;
// Use a Set to track elements that are currently undergoing a focus operation
const focusingElements = new Set<HTMLElement>();
if (!patchedFocus) {
if (!patchedFocus) {
// Must save a real, original `focus` method outside of the patch beforehand
const originalFocus = HTMLElement.prototype.focus;
let currentFocus = HTMLElement.prototype.focus;
// Use a Set to track elements that are currently undergoing a focus operation
const focusingElements = new Set<HTMLElement>();

Copy link
Member Author

Choose a reason for hiding this comment

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

Correct, and patchedFocus = true also needs to be placed outside, because the Set might not be invoked.

@storybook-app-bot
Copy link

storybook-app-bot bot commented Nov 4, 2025

Package Benchmarks

Commit: c1cf4c0, ran on 4 November 2025 at 15:36:17 UTC

No significant changes detected, all good. 👏

Copy link
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: 0

🧹 Nitpick comments (1)
code/core/src/test/preview.ts (1)

115-120: Reentry guard initialization looks correct.

The declarations properly capture the original focus method and set up tracking for recursive calls. The focusingElements Set and helper variables are correctly scoped for the getter closure defined below.

Optional: Consider tighter scoping per yannbf's suggestion.

Moving these declarations inside the if (!patchedFocus) block (but before Object.defineProperties) would limit their scope while still allowing the getter to capture them. This prevents unintended external access and clarifies that they're only used for the patch.

     if (!patchedFocus) {
+      // Must save a real, original `focus` method outside of the patch beforehand
+      const originalFocus = HTMLElement.prototype.focus;
+      let currentFocus = HTMLElement.prototype.focus;
+
+      // Use a Set to track elements that are currently undergoing a focus operation
+      const focusingElements = new Set<HTMLElement>();
+
       Object.defineProperties(HTMLElement.prototype, {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d991888 and c1cf4c0.

📒 Files selected for processing (1)
  • code/core/src/test/preview.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,jsx,json,html,ts,tsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{js,jsx,json,html,ts,tsx,mjs}: Run Prettier formatting on changed files before committing
Run ESLint on changed files and fix all errors/warnings before committing (use yarn lint:js:cmd <file>)

Files:

  • code/core/src/test/preview.ts
**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Export functions from modules when they need to be unit-tested

Files:

  • code/core/src/test/preview.ts
code/**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

In application code, use Storybook loggers instead of console.* (client code: storybook/internal/client-logger; server code: storybook/internal/node-logger)

Files:

  • code/core/src/test/preview.ts
{code/**,scripts/**}/**/*.{ts,tsx,js,jsx,mjs}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Do not use console.log, console.warn, or console.error directly unless in isolated files where importing loggers would significantly increase bundle size

Files:

  • code/core/src/test/preview.ts
🧠 Learnings (8)
📓 Common learnings
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.197Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Document complex mock behaviors
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.197Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Keep mock implementations simple and focused
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Implement mock behaviors in beforeEach blocks
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid mock implementations outside of beforeEach blocks
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid inline mock implementations within test cases
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Mock implementations should be placed in beforeEach blocks
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Mock all required properties and methods that the test subject uses
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Use vi.mocked() to access and implement mock behaviors
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid mocking only a subset of required dependencies
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Mock all required dependencies that the test subject uses
📚 Learning: 2025-09-17T08:11:47.197Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.197Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Keep mock implementations simple and focused

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.197Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.197Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Document complex mock behaviors

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.196Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid inline mock implementations within test cases

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.196Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid mock implementations outside of beforeEach blocks

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.196Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Implement mock behaviors in beforeEach blocks

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.196Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Avoid mocking only a subset of required dependencies

Applied to files:

  • code/core/src/test/preview.ts
📚 Learning: 2025-09-17T08:11:47.196Z
Learnt from: CR
Repo: storybookjs/storybook PR: 0
File: .cursor/rules/spy-mocking.mdc:0-0
Timestamp: 2025-09-17T08:11:47.196Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Mock all required properties and methods that the test subject uses

Applied to files:

  • code/core/src/test/preview.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: normal
  • GitHub Check: Core Unit Tests, windows-latest
🔇 Additional comments (2)
code/core/src/test/preview.ts (2)

128-144: Reentry detection logic correctly prevents infinite recursion.

The getter properly detects recursive calls by checking Set membership and returns the original focus method to break the cycle. The setTimeout cleanup ensures protection persists for the entire synchronous call chain while allowing the guard to reset for subsequent independent focus operations.


148-148: LGTM!

The flag correctly ensures the focus patch is applied only once across all loader invocations.

@yannbf
Copy link
Member

yannbf commented Nov 4, 2025

Thank you so much for your contribution @ia319 !!

@yannbf yannbf added the patch:yes Bugfix & documentation PR that need to be picked to main branch label Nov 4, 2025
@yannbf yannbf merged commit 9d1fc3e into storybookjs:next Nov 4, 2025
55 checks passed
yannbf added a commit that referenced this pull request Nov 5, 2025
Core: Add reentry guard to focus patch
(cherry picked from commit 9d1fc3e)
@github-actions github-actions bot added the patch:done Patch/release PRs already cherry-picked to main/release branch label Nov 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug ci:normal patch:done Patch/release PRs already cherry-picked to main/release branch patch:yes Bugfix & documentation PR that need to be picked to main branch test utilities

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug]: "Maximum call stack size exceeded" error when using Cally date picker

4 participants