From bf2bcb1534891205342029eff3edf070eb0d58bc Mon Sep 17 00:00:00 2001 From: Dennis Ridder Date: Tue, 22 Sep 2026 13:09:15 +0200 Subject: [PATCH] feat: add autoUpdateOptions to avoid a Floating UI RangeError layoutShift tracking can loop until the browser throws RangeError: Maximum call stack size exceeded. Passing { layoutShift: false } through autoUpdateOptions stops that, and the option is left off steps that never set it. Co-authored-by: Cursor --- shepherd.js/src/step.ts | 26 +++++- shepherd.js/src/utils/floating-ui.ts | 48 ++++++++--- .../test/unit/utils/floating-ui.spec.js | 80 ++++++++++++++++++- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 88e5596a6..5b9f993bc 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -19,7 +19,10 @@ import { type ShepherdElementResult } from './components/shepherd-element.ts'; import { type Tour } from './tour.ts'; -import type { ComputePositionConfig } from '@floating-ui/dom'; +import type { + AutoUpdateOptions, + ComputePositionConfig +} from '@floating-ui/dom'; export type StepText = | string @@ -69,6 +72,27 @@ export interface StepOptions { */ arrow?: boolean | StepOptionsArrow; + /** + * Extra [options to pass to `autoUpdate`]{@link https://floating-ui.com/docs/autoUpdate}, + * which keeps the step attached to its target while the step is open. + * + * A notable use case is `{ layoutShift: false }`, which disables the + * `IntersectionObserver`-based tracking of targets that move for reasons + * other than scrolling or resizing. That machinery re-creates its observer + * every time the target moves, and when the observed intersection ratio + * never settles at the expected threshold (fractional bounding rects at + * non-integer browser zoom, pinch-zoom, or a target animating while + * observed) it can loop unboundedly -- up to + * `RangeError: Maximum call stack size exceeded` in browsers that deliver + * the initial observation synchronously. Scroll and resize tracking are + * unaffected, as they are covered by `ancestorScroll`, `ancestorResize` and + * `elementResize`. + * + * Can be set on `defaultStepOptions` to apply to every step, and is + * deep-merged with the step-level value. + */ + autoUpdateOptions?: AutoUpdateOptions; + /** * A function that returns a promise. * When the promise resolves, the rest of the `show` code for the step will execute. diff --git a/shepherd.js/src/utils/floating-ui.ts b/shepherd.js/src/utils/floating-ui.ts index fc22d29f6..56d271057 100644 --- a/shepherd.js/src/utils/floating-ui.ts +++ b/shepherd.js/src/utils/floating-ui.ts @@ -8,6 +8,7 @@ import { autoPlacement, limitShift, shift, + type AutoUpdateOptions, type ComputePositionConfig, type Middleware, type MiddlewareData, @@ -40,15 +41,20 @@ export function setupTooltip(step: Step): ComputePositionConfig { content?.classList.add('shepherd-centered'); } - step.cleanup = autoUpdate(target, step.el as HTMLElement, () => { - // The element might have already been removed by the end of the tour. - if (!step.el) { - step.cleanup?.(); - return; - } - - setPosition(target, step, floatingUIOptions, shouldCenter); - }); + step.cleanup = autoUpdate( + target, + step.el as HTMLElement, + () => { + // The element might have already been removed by the end of the tour. + if (!step.el) { + step.cleanup?.(); + return; + } + + setPosition(target, step, floatingUIOptions, shouldCenter); + }, + step.options.autoUpdateOptions + ); step.target = attachToOptions.element as HTMLElement; @@ -61,18 +67,36 @@ export function setupTooltip(step: Step): ComputePositionConfig { * @param tourOptions - The default tour options. * @param options - Step specific options. * - * @return {floatingUIOptions: FloatingUIOptions} + * @return {floatingUIOptions: FloatingUIOptions, autoUpdateOptions?: AutoUpdateOptions} */ export function mergeTooltipConfig( tourOptions: StepOptions, options: StepOptions -): { floatingUIOptions: ComputePositionConfig } { - return { +): { + floatingUIOptions: ComputePositionConfig; + autoUpdateOptions?: AutoUpdateOptions; +} { + const config: { + floatingUIOptions: ComputePositionConfig; + autoUpdateOptions?: AutoUpdateOptions; + } = { floatingUIOptions: deepmerge( tourOptions.floatingUIOptions || {}, options.floatingUIOptions || {} ) }; + + // Omit the key when neither side set it. `_setOptions` copies this object + // onto `step.options`, and an empty `autoUpdateOptions` would show up on + // every step that never opted in. + if (tourOptions.autoUpdateOptions || options.autoUpdateOptions) { + config.autoUpdateOptions = deepmerge( + tourOptions.autoUpdateOptions || {}, + options.autoUpdateOptions || {} + ); + } + + return config; } /** diff --git a/shepherd.js/test/unit/utils/floating-ui.spec.js b/shepherd.js/test/unit/utils/floating-ui.spec.js index d110df2df..f05224432 100644 --- a/shepherd.js/test/unit/utils/floating-ui.spec.js +++ b/shepherd.js/test/unit/utils/floating-ui.spec.js @@ -1,7 +1,19 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { arrow, offset, shift } from '@floating-ui/dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { arrow, autoUpdate, offset, shift } from '@floating-ui/dom'; import { Step } from '../../../src/step'; -import { getFloatingUIOptions } from '../../../src/utils/floating-ui'; +import { + getFloatingUIOptions, + mergeTooltipConfig, + setupTooltip +} from '../../../src/utils/floating-ui'; + +vi.mock('@floating-ui/dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + autoUpdate: vi.fn(() => vi.fn()) + }; +}); describe('Floating UI Utils', function () { let targetElement; @@ -181,4 +193,66 @@ describe('Floating UI Utils', function () { ]); }); }); + + describe('autoUpdateOptions', function () { + beforeEach(() => { + autoUpdate.mockClear(); + }); + + it('forwards `autoUpdateOptions` to `autoUpdate`', function () { + const step = createStep({ + attachTo: { element: '.floating-ui-test', on: 'right' }, + autoUpdateOptions: { layoutShift: false } + }); + + setupTooltip(step); + + expect(autoUpdate).toHaveBeenCalledTimes(1); + expect(autoUpdate).toHaveBeenCalledWith( + targetElement, + stepElement, + expect.any(Function), + { layoutShift: false } + ); + }); + + it('applies `autoUpdateOptions` from `defaultStepOptions`, overridable per step', function () { + const tour = { + options: { + defaultStepOptions: { + autoUpdateOptions: { layoutShift: false, elementResize: false } + } + } + }; + const step = new Step(tour, { + arrow: true, + attachTo: { element: '.floating-ui-test', on: 'right' }, + autoUpdateOptions: { elementResize: true } + }); + step.el = stepElement; + + setupTooltip(step); + + expect(autoUpdate).toHaveBeenCalledWith( + targetElement, + stepElement, + expect.any(Function), + { layoutShift: false, elementResize: true } + ); + }); + }); + + describe('mergeTooltipConfig()', function () { + it('deep merges `autoUpdateOptions` from tour and step options', function () { + const { autoUpdateOptions } = mergeTooltipConfig( + { autoUpdateOptions: { layoutShift: false, ancestorScroll: false } }, + { autoUpdateOptions: { ancestorScroll: true } } + ); + + expect(autoUpdateOptions).toEqual({ + layoutShift: false, + ancestorScroll: true + }); + }); + }); });