Skip to content

fix: keep the arrow middleware last when merging custom Floating UI options - #3477

Open
chuckcarpenter wants to merge 1 commit into
mainfrom
claude/shepherd-issues-triage-722b8c
Open

fix: keep the arrow middleware last when merging custom Floating UI options#3477
chuckcarpenter wants to merge 1 commit into
mainfrom
claude/shepherd-issues-triage-722b8c

Conversation

@chuckcarpenter

@chuckcarpenter chuckcarpenter commented Aug 13, 2026

Copy link
Copy Markdown
Member

Fixes #3034.

The bug

getFloatingUIOptions pushed arrow() into options.middleware before merging the user's floatingUIOptions. deepmerge concatenates arrays, so any user-supplied middleware landed after the arrow:

[flip, shift, arrow, userShift, userOffset]

Floating UI runs middleware sequentially, threading x/y from one to the next, and arrow() computes its offset from the coordinates as they stand on its own turn. Anything running after it moves the tooltip again and silently invalidates middlewareData.arrow — which placeArrow() then writes straight to the DOM.

The arrow ends up off by exactly however far the trailing middleware shifted the element. Measured against the real Floating UI middleware chain:

scenario arrow error before after
[shift({padding: 32}), offset(16)] (the issue's repro) −32px 0
offset({ mainAxis: 0, crossAxis: 60 }) +60px 0
offset({ mainAxis: 0, crossAxis: 12 }) — our own cookbook recipe +12px 0

That third row is worth calling out: the Offsets recipe in our docs has been shipping this bug.

It presents as silent because middlewareData.arrow.centerOffset is also computed at arrow()'s turn, so the stale data looks perfectly healthy from the outside.

The fix

Merge the user's options first, then append arrow() — so it always runs last. If the user supplies their own arrow() middleware, theirs still wins and ours is not appended, preserving current behavior (previously theirs won by being last; now it wins by suppressing ours).

Tooltip coordinates and resolved placement are unchanged in every case — only the arrow moves.

What was deliberately not done

Two larger changes were prototyped and rejected after testing:

  • Hoisting offset() to the front. Unnecessary (arrow-last alone does 100% of the work) and actively harmful — it feeds offset into flip's overflow detection, flipping placement bottomtop and moving a tooltip 250px in one test.
  • Name-based middleware replacement in mergeTooltipConfig. test/unit/step.spec.js deliberately asserts that tour-level and step-level middleware concatenate (an offset({crossAxis: 32}) and an offset({crossAxis: -32}) summing to zero). That behavior is intentional and is left alone.

Testing

New test/unit/utils/floating-ui.spec.js, 7 tests asserting on actual middleware order, not length. Mutation-tested: 13 deliberately broken variants of the fix were built and run against the suite — including arrow-first, double-arrow, dropped user middleware, dropped falsy entries, missing centre guard, hardcoded padding, and wrong arrow element. All are caught.

  • 231/231 unit tests
  • 44/44 Cypress (including describe('arrow padding'))
  • lint, prettier, types:check, and build all clean

Note for whoever reviews both: this adds a file at the same path as #3441, so whichever lands second needs to merge the two describe bodies by hand. The source files merge cleanly.

Semver: patch. getFloatingUIOptions is not part of the published type surface, and the only behavior change is an arrow moving from a demonstrably wrong position to the correct one.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved tooltip positioning when custom Floating UI middleware is configured.
    • Preserved user-provided middleware while ensuring arrow positioning behaves correctly.
    • Prevented duplicate arrow handling and avoided arrows on centered steps.
    • Improved support for custom arrow settings, including padding.
    • Ignored empty middleware entries without affecting positioning defaults.

…ptions

`getFloatingUIOptions` pushed `arrow()` into `options.middleware` before
merging `floatingUIOptions`. Because `deepmerge` concatenates arrays, any
user supplied middleware landed *after* the arrow.

Floating UI runs middleware sequentially, threading `x`/`y` from one to the
next, and `arrow()` computes its offset from the coordinates as they stand on
its own turn. Anything running after it moves the tooltip again and silently
invalidates `middlewareData.arrow`, which `placeArrow()` writes straight to
the DOM. The arrow ends up off by exactly however far the trailing middleware
shifted the element -- 60px for an `offset({ crossAxis: 60 })`, 12px for the
`offset({ mainAxis: 0, crossAxis: 12 })` recipe in our own cookbook.

Merge the user options first, then append the arrow, so it always runs last.
When the user supplies their own `arrow()` middleware theirs still wins and
ours is not appended, preserving the previous behavior.

Tooltip coordinates and resolved placement are unchanged in every case; only
the arrow moves.

Fixes #3034

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
shepherd-docs Ready Ready Preview Aug 13, 2026 11:40am
shepherd-landing Ready Ready Preview Aug 13, 2026 11:40am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 124cc23a-98a0-49df-8677-76d92c16f8cc

📥 Commits

Reviewing files that changed from the base of the PR and between 1d9c664 and 2d45e87.

📒 Files selected for processing (2)
  • shepherd.js/src/utils/floating-ui.ts
  • shepherd.js/test/unit/utils/floating-ui.spec.js

📝 Walkthrough

Walkthrough

The Floating UI option builder now preserves user middleware, appends Shepherd’s arrow middleware last when needed, skips it for centered steps or existing arrows, and validates these cases with unit tests.

Changes

Floating UI middleware handling

Layer / File(s) Summary
Middleware option construction
shepherd.js/src/utils/floating-ui.ts
The builder merges user options before adding Shepherd’s arrow middleware. It detects existing arrow middleware and ignores falsy entries during inspection.
Middleware behavior tests
shepherd.js/test/unit/utils/floating-ui.spec.js
Tests cover middleware order, arrow configuration, placement and strategy defaults, centered steps, and falsy middleware entries.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Mergeability Score: ⚪ Minimal · up to 2d45e

This localized change keeps the arrow middleware last so custom middleware no longer leaves the tooltip arrow misaligned; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary fix: keeping custom Floating UI middleware before Shepherd’s arrow middleware.
Linked Issues check ✅ Passed The changes address issue #3034 by preserving custom middleware and ensuring Shepherd’s arrow middleware runs last when needed.
Out of Scope Changes check ✅ Passed The implementation and unit tests remain within the linked issue scope and support the middleware ordering fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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 claude/shepherd-issues-triage-722b8c

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

shepherd.js/test/unit/utils/floating-ui.spec.js

(node:2) ESLintIgnoreWarning: The ".eslintignore" file is no longer supported. Switch to using the "ignores" property in "eslint.config.js": https://eslint.org/docs/latest/use/configure/migration-guide#ignore-files
(Use node --trace-warnings ... to show where the warning was created)

Oops! Something went wrong! :(

ESLint: 10.8.1

A config object is using the "root" key, which is not supported in flat config system.

Flat configs always act as if they are the root config file, so this key can be safely removed.


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.

@qltysh

qltysh Bot commented Aug 13, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (1)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
shepherd.js/src/utils/floating-ui.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Sign up for free to 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.

Arrow position not working when using custom FloatingUI options

1 participant