From 594999ce0837cd837c84bc101e41e8720f47b56b Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Wed, 12 Aug 2026 10:29:19 +0200 Subject: [PATCH 1/2] feat: add waitForElement and skipMissingElement step options Steps whose attachTo target renders after the tour starts currently log an error and fall back to a centered tooltip. Two new step options handle that: - waitForElement (ms): watch the DOM with a MutationObserver until the selector resolves or the timeout expires, falling back to polling where MutationObserver is unavailable - skipMissingElement: skip the step instead of centering it when the target is still missing, reusing the same path as showOn returning false Both are JSON-serializable and can be set per step or in defaultStepOptions. Also adds an optional data passthrough on StepOptions for step metadata. --- docs-src/src/content/docs/guides/usage.md | 27 +- shepherd.js/src/step.ts | 41 ++ shepherd.js/src/tour.ts | 107 +++++- shepherd.js/src/utils/general.ts | 88 ++++- shepherd.js/test/unit/step.spec.js | 15 + shepherd.js/test/unit/tour.spec.js | 398 ++++++++++++++++++++ shepherd.js/test/unit/utils/general.spec.js | 264 ++++++++++++- 7 files changed, 918 insertions(+), 22 deletions(-) diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index f889eba67..ad8bc6978 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -212,7 +212,9 @@ const new Step(tour, { If you don’t specify an `attachTo` the element will appear in the middle of the screen. The same will happen if your `attachTo.element` callback returns `null`, -`undefined`, or a selector that does not exist in the DOM. +`undefined`, or a selector that does not exist in the DOM. The `waitForElement` +and `skipMissingElement` options described below let you change this behavior +for elements that are missing or rendered late. If you omit the `on` portion of `attachTo`, the element will still be highlighted, but the tooltip will appear in the middle of the screen, without an @@ -256,6 +258,10 @@ function will be called in the `before-show` phase. - `label` The label to add for `aria-label` - `classes`: A string of extra classes to add to the step's content element. +- `data`: Arbitrary, JSON-serializable data to associate with the step. Shepherd + does not use it internally; read it back from `step.options.data` in your + event handlers and button actions. Useful for analytics ids or other metadata, + e.g. on generated tour definitions. - `buttons`: An array of buttons to add to the step. These will be rendered in a footer below the main body text. Each button in the array is an object of the format: @@ -302,6 +308,25 @@ function will be called in the `before-show` phase. [Floating UI](https://floating-ui.com/docs/getting-started) - `showOn`: A function that, when it returns true, will show the step. If it returns false, the step will be skipped. +- `skipMissingElement`: A boolean. When true, a step whose `attachTo.element` + cannot be found in the DOM is skipped (like `showOn` returning false) instead + of being shown centered. If all remaining steps are skipped, the tour + completes going forward, or cancels going backward. It can also be set on + `defaultStepOptions` to apply to every step. Steps without an `attachTo` + element are never skipped, since they are intentionally centered. +- `waitForElement`: The maximum amount of time, in milliseconds, to wait for the + `attachTo.element` to appear in the DOM before showing the step. The DOM is + watched with a `MutationObserver`, so the step attaches as soon as the element + appears. If the timeout expires, the step falls back to its default behavior: + skipped when `skipMissingElement` is true, otherwise shown centered. Useful + for targets that are rendered asynchronously. + + Both options look the target up before the step's own `beforeShowPromise` and + `before-show` handlers run, so they cannot see an element that those handlers + create — keep using `beforeShowPromise` for targets the step itself renders. + Both are plain JSON values, so a tour definition using them stays + serializable. + - `scrollTo`: Should the element be scrolled to when this step is shown? If true, uses the default `scrollIntoView`, if an object, passes that object as the params to `scrollIntoView` i.e. `{behavior: 'smooth', block: 'center'}` diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index 9e1587a0f..cf5cc75c4 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -101,6 +101,15 @@ export interface StepOptions { */ classes?: string; + /** + * Arbitrary, JSON-serializable data to associate with the step. Shepherd + * does not use this value internally; it is a place to store your own + * metadata (for example analytics ids, or context produced by a tour + * generator) and read it back from `step.options.data` in event handlers + * and button actions. + */ + data?: Record; + /** * An array of extra element selectors to highlight when the overlay is shown * The tooltip won't be fixed to these elements, but they will be highlighted @@ -174,6 +183,24 @@ export interface StepOptions { */ showOn?: () => boolean; + /** + * When `true`, a step whose `attachTo.element` selector (or function + * locator) does not resolve to an element in the DOM is skipped, advancing + * to the next step (or the previous step when navigating backwards) instead + * of being shown centered. If all remaining steps are skipped, the tour + * completes (going forward) or cancels (going backward), mirroring the + * `showOn` semantics. Can be set on `defaultStepOptions` to apply to every + * step. Combine with `waitForElement` to give the element time to appear + * before skipping. Steps without an `attachTo` element are never skipped, + * since they are intentionally centered. + * + * Note that the target is looked up before the step's own `beforeShowPromise` + * and `before-show` handlers run, so an element that those handlers create is + * not visible to this check. Use `beforeShowPromise` on its own for targets + * the step itself renders. + */ + skipMissingElement?: boolean; + /** * The text in the body of the step. It can be one of four types: * ``` @@ -194,6 +221,20 @@ export interface StepOptions { */ title?: StringOrStringFunction; + /** + * The maximum amount of time, in milliseconds, to wait for the + * `attachTo.element` to appear in the DOM before showing the step. The DOM + * is watched with a `MutationObserver` (falling back to polling when it is + * unavailable), so the step attaches as soon as the element appears. If the + * timeout expires, the step falls back to its default behavior: skipped + * when `skipMissingElement` is `true`, otherwise shown centered. + * + * The wait starts before the step's own `beforeShowPromise` and `before-show` + * handlers run, so it cannot observe a target that those handlers create, and + * a function locator is re-evaluated on each DOM change until it resolves. + */ + waitForElement?: number; + /** * You can define `show`, `hide`, etc events inside `when`. For example: * ```js diff --git a/shepherd.js/src/tour.ts b/shepherd.js/src/tour.ts index 387b04e4e..c45c5caa8 100644 --- a/shepherd.js/src/tour.ts +++ b/shepherd.js/src/tour.ts @@ -8,7 +8,12 @@ import { isUndefined } from './utils/type-check.ts'; import { cleanupSteps } from './utils/cleanup.ts'; -import { normalizePrefix, uuid } from './utils/general.ts'; +import { + normalizePrefix, + resolveAttachToElement, + uuid, + waitForAttachToElement +} from './utils/general.ts'; import { createShepherdModal, type ShepherdModalAPI @@ -113,6 +118,13 @@ export class Tour extends Evented { options: TourOptions; steps: Array; + /** + * Monotonically increasing id of `show()` calls, used to invalidate pending + * `waitForElement` waits that were superseded by a newer `show()`. + * @private + */ + _showGeneration = 0; + constructor(options: TourOptions = {}) { super(); @@ -196,7 +208,7 @@ export class Tour extends Evented { */ back() { const index = this.steps.indexOf(this.currentStep as Step); - this.show(index - 1, false); + return this.show(index - 1, false); } /** @@ -279,7 +291,7 @@ export class Tour extends Evented { if (index === this.steps.length - 1) { this.complete(); } else { - this.show(index + 1, true); + return this.show(index + 1, true); } } @@ -316,29 +328,86 @@ export class Tour extends Evented { * Show a specific step in the tour * @param {number | string} key - The key to look up the step by * @param {boolean} forward - True if we are going forward, false if backward + * @returns A promise when the step's `waitForElement` option makes showing + * asynchronous, otherwise `undefined` */ - show(key: number | string = 0, forward = true) { + show(key: number | string = 0, forward = true): void | Promise { const step = isString(key) ? this.getById(key) : this.steps[key]; - if (step) { - this._updateStateBeforeShow(); + if (!step) { + return; + } - const shouldSkipStep = - isFunction(step.options.showOn) && !step.options.showOn(); + this._updateStateBeforeShow(); - // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal - if (shouldSkipStep) { - this._skipStep(step, forward); - } else { - this.currentStep = step; - this.trigger('show', { - step, - previous: this.currentStep + // Invalidate any pending `waitForElement` wait from a previous call. + const generation = ++this._showGeneration; + + const shouldSkipStep = + isFunction(step.options.showOn) && !step.options.showOn(); + + // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal + if (shouldSkipStep) { + this._skipStep(step, forward); + return; + } + + const { skipMissingElement, waitForElement } = step.options; + const attachToElement = + step.options.attachTo && step.options.attachTo.element; + // `waitForElement` and `skipMissingElement` only apply to steps that + // locate their target dynamically. Steps without an `attachTo` element + // are intentionally centered and never treated as missing. + const hasElementLocator = + isString(attachToElement) || isFunction(attachToElement); + const waitTimeout = + typeof waitForElement === 'number' && waitForElement > 0 + ? waitForElement + : 0; + + if ( + hasElementLocator && + (skipMissingElement || waitTimeout > 0) && + !resolveAttachToElement(step) + ) { + if (waitTimeout > 0) { + return waitForAttachToElement(step, waitTimeout).then((element) => { + // A newer `show()`, or cancelling/completing the tour, supersedes + // this pending wait. + if (generation !== this._showGeneration || !this.isActive()) { + return; + } + + if (!element && skipMissingElement) { + return this._skipStep(step, forward); + } + + this._showStep(step); }); + } - step.show(); + if (skipMissingElement) { + this._skipStep(step, forward); + return; } } + + this._showStep(step); + } + + /** + * Sets the given step as the current step and shows it + * @param {Step} step - The step to show + * @private + */ + _showStep(step: Step) { + this.currentStep = step; + this.trigger('show', { + step, + previous: this.currentStep + }); + + step.show(); } /** @@ -355,7 +424,7 @@ export class Tour extends Evented { this.setupModal(); this._setupActiveTour(); - this.next(); + return this.next(); } /** @@ -425,7 +494,7 @@ export class Tour extends Evented { } else if (nextIndex >= this.steps.length) { this.complete(); } else { - this.show(nextIndex, forward); + return this.show(nextIndex, forward); } } diff --git a/shepherd.js/src/utils/general.ts b/shepherd.js/src/utils/general.ts index 43e180699..f1f74b6c4 100644 --- a/shepherd.js/src/utils/general.ts +++ b/shepherd.js/src/utils/general.ts @@ -53,7 +53,7 @@ export function parseAttachTo(step: Step) { } catch (_e) { // TODO } - if (!returnOpts.element) { + if (!returnOpts.element && !step.options.skipMissingElement) { console.error( `The element for this Shepherd step was not found ${options.element}` ); @@ -63,6 +63,92 @@ export function parseAttachTo(step: Step) { return returnOpts; } +/** + * Resolves a step's `attachTo.element` to an HTMLElement, evaluating function + * locators and querying selector strings. Unlike `parseAttachTo`, this never + * logs and simply returns `null` when the element cannot be resolved. + * @param step - The step instance + * @returns The resolved element, or `null` if it could not be found + */ +export function resolveAttachToElement(step: Step): HTMLElement | null { + const attachTo = step.options.attachTo || {}; + let element = isFunction(attachTo.element) + ? attachTo.element.call(step) + : attachTo.element; + + if (isString(element)) { + try { + element = document.querySelector(element) as HTMLElement | null; + } catch (_e) { + element = null; + } + } + + return element || null; +} + +/** + * Waits for a step's `attachTo.element` to be resolvable, watching the DOM + * with a `MutationObserver` (falling back to polling when it is unavailable) + * until `timeout` milliseconds have elapsed. + * @param step - The step instance + * @param timeout - The maximum amount of time, in milliseconds, to wait + * @returns A promise resolving to the element, or `null` if it did not appear + * within `timeout` milliseconds + */ +export function waitForAttachToElement( + step: Step, + timeout: number +): Promise { + return new Promise((resolve) => { + const element = resolveAttachToElement(step); + + if (element || !(timeout > 0)) { + resolve(element); + return; + } + + let observer: MutationObserver | null = null; + let pollTimer: ReturnType | null = null; + let timeoutTimer: ReturnType | null = null; + + const finish = (result: HTMLElement | null) => { + observer?.disconnect(); + + if (pollTimer !== null) { + clearInterval(pollTimer); + } + + if (timeoutTimer !== null) { + clearTimeout(timeoutTimer); + } + + resolve(result); + }; + + const check = () => { + const found = resolveAttachToElement(step); + + if (found) { + finish(found); + } + }; + + timeoutTimer = setTimeout(() => finish(null), timeout); + + if (typeof MutationObserver !== 'undefined') { + observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true + }); + } else { + pollTimer = setInterval(check, 50); + } + }); +} + /* * Resolves the step's `extraHighlights` option, converting any locator values to HTMLElements. */ diff --git a/shepherd.js/test/unit/step.spec.js b/shepherd.js/test/unit/step.spec.js index 3a7ec9d9f..15222727f 100644 --- a/shepherd.js/test/unit/step.spec.js +++ b/shepherd.js/test/unit/step.spec.js @@ -917,4 +917,19 @@ describe('Tour | Step', () => { expect(testElement.getAttribute('tabindex')).toBe('3'); }); }); + + describe('data option', () => { + it('exposes the data option at step.options.data', () => { + const data = { foo: 'bar', nested: { a: 1 } }; + const step = tour.addStep({ id: 'data-step', data }); + + expect(step.options.data).toEqual(data); + }); + + it('leaves step.options.data undefined when data is not passed', () => { + const step = tour.addStep({ id: 'no-data-step' }); + + expect(step.options.data).toBeUndefined(); + }); + }); }); diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js index 0a31a5ca6..e388d693a 100644 --- a/shepherd.js/test/unit/tour.spec.js +++ b/shepherd.js/test/unit/tour.spec.js @@ -817,4 +817,402 @@ describe('Tour | Top-Level Class', function () { expect(modalContainer.contains(modalElement)).toBe(true); }); }); + + describe('skipMissingElement / waitForElement', () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + it('skips a step whose attachTo element is missing when skipMissingElement is true', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + expect( + instance.getCurrentStep().id, + 'missing step is skipped, advancing to the third step' + ).toBe('third'); + }); + + it('applies skipMissingElement from defaultStepOptions', () => { + instance = new Shepherd.Tour({ + defaultStepOptions: { skipMissingElement: true } + }); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' } + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + expect( + instance.getCurrentStep().id, + 'missing step is skipped via defaultStepOptions' + ).toBe('third'); + }); + + it('completes the tour when all trailing steps are skipped', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'missing-1', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'missing-2', + attachTo: { element: '.does-not-exist-abc', on: 'top' }, + skipMissingElement: true + }); + + let completeFired = false; + instance.on('complete', () => { + completeFired = true; + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + instance.next(); + + expect( + completeFired, + 'complete fires when all trailing steps are skipped' + ).toBe(true); + expect( + Shepherd.activeTour, + 'activeTour is null after the tour completes' + ).toBeNull(); + }); + + it('cancels when going back past a skipped first step', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'missing', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'second', + attachTo: { element: 'body', on: 'top' } + }); + + let cancelFired = false; + instance.on('cancel', () => { + cancelFired = true; + }); + + instance.start(); + expect( + instance.getCurrentStep().id, + 'missing first step is skipped on start' + ).toBe('second'); + + instance.back(); + + expect( + cancelFired, + 'cancel fires when going back past a skipped first step' + ).toBe(true); + expect(Shepherd.activeTour, 'activeTour is null after cancel').toBeNull(); + }); + + it('still shows centered steps that have no attachTo', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'centered', + skipMissingElement: true + }); + + instance.start(); + + expect( + instance.getCurrentStep().id, + 'a step with no attachTo is shown centered, not skipped' + ).toBe('centered'); + }); + + it('shows a step normally when its element exists even with skipMissingElement', () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'exists', + attachTo: { element: 'body', on: 'top' }, + skipMissingElement: true + }); + + instance.start(); + + expect( + instance.getCurrentStep().id, + 'a step whose element exists is shown normally' + ).toBe('exists'); + }); + + it('waitForElement waits for the element to appear and then attaches', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.appears-later-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + expect(instance.getCurrentStep().id, 'first step is shown').toBe('first'); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-later-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'second step is shown once its element appears' + ).toBe('second'); + expect( + instance.getCurrentStep().target, + 'resolved target is the element that appeared' + ).toBe(appearedElement); + + document.body.removeChild(appearedElement); + }); + + it('falls back to a centered step when waitForElement times out', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50 + }); + + instance.start(); + + await instance.next(); + + expect( + instance.getCurrentStep().id, + 'step is shown centered once the wait times out' + ).toBe('second'); + expect( + spy, + 'console.error is logged for the still-missing element' + ).toHaveBeenCalled(); + expect( + instance.getCurrentStep().target, + 'target is falsy since the element never resolved' + ).toBeFalsy(); + + spy.mockRestore(); + }); + + it('skips the step when waitForElement times out and skipMissingElement is true', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50, + skipMissingElement: true + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + + await instance.next(); + + expect( + instance.getCurrentStep().id, + 'second step is skipped once the wait times out' + ).toBe('third'); + }); + + it('completes after waiting when trailing steps are skipped', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 50, + skipMissingElement: true + }); + + let completeFired = false; + instance.on('complete', () => { + completeFired = true; + }); + + instance.start(); + + await instance.next(); + + expect( + completeFired, + 'complete fires once the trailing step finishes waiting and is skipped' + ).toBe(true); + }); + + it('a newer show() supersedes a pending waitForElement wait', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 200 + }); + + instance.addStep({ + id: 'third', + attachTo: { element: 'body', on: 'top' } + }); + + instance.start(); + + const shownStepIds = []; + instance.on('show', ({ step }) => { + shownStepIds.push(step.id); + }); + + const promise = instance.show(1); + instance.show(2); + + expect( + instance.getCurrentStep().id, + 'show(2) synchronously supersedes the pending wait from show(1)' + ).toBe('third'); + + await promise; + await sleep(250); + + expect( + instance.getCurrentStep().id, + 'currentStep is still the third step after the superseded wait settles' + ).toBe('third'); + expect( + shownStepIds.includes('second'), + 'the show event never fires for the superseded second step' + ).toBe(false); + }); + + it('cancelling during a waitForElement wait does not resurrect the tour', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'second', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + waitForElement: 100, + skipMissingElement: true + }); + + instance.start(); + + let showFiredAfterCancel = false; + let completeFiredAfterCancel = false; + instance.on('show', () => { + showFiredAfterCancel = true; + }); + instance.on('complete', () => { + completeFiredAfterCancel = true; + }); + + const promise = instance.next(); + instance.cancel(); + + await promise; + await sleep(150); + + expect( + Shepherd.activeTour, + 'activeTour stays null after cancel' + ).toBeNull(); + expect(showFiredAfterCancel, 'no show event fires after cancel').toBe( + false + ); + expect( + completeFiredAfterCancel, + 'no complete event fires after cancel' + ).toBe(false); + }); + }); }); diff --git a/shepherd.js/test/unit/utils/general.spec.js b/shepherd.js/test/unit/utils/general.spec.js index a2dcdfd23..397548e07 100644 --- a/shepherd.js/test/unit/utils/general.spec.js +++ b/shepherd.js/test/unit/utils/general.spec.js @@ -3,7 +3,9 @@ import { Step } from '../../../src/step'; import { parseAttachTo, shouldCenterStep, - parseExtraHighlights + parseExtraHighlights, + resolveAttachToElement, + waitForAttachToElement } from '../../../src/utils/general'; import { getFloatingUIOptions } from '../../../src/utils/floating-ui'; @@ -74,6 +76,266 @@ describe('General Utils', function () { parseAttachTo(step); }); + + it('logs a console.error when a selector does not resolve and skipMissingElement is not set', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' } + } + ); + + parseAttachTo(step); + + expect( + spy, + 'console.error is called when skipMissingElement is not set' + ).toHaveBeenCalled(); + + spy.mockRestore(); + }); + + it('does not log a console.error when skipMissingElement is true', function () { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' }, + skipMissingElement: true + } + ); + + const { element } = parseAttachTo(step); + + expect(element, 'resolved element is still falsy').toBeFalsy(); + expect( + spy, + 'console.error is not called when skipMissingElement is true' + ).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + }); + + describe('resolveAttachToElement()', function () { + it('resolves a selector string to the element', function () { + const step = new Step( + {}, + { + attachTo: { element: '.options-test', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('returns null for a selector matching nothing', function () { + const step = new Step( + {}, + { + attachTo: { element: '.element-does-not-exist', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('resolves a function returning an element', function () { + const step = new Step( + {}, + { + attachTo: { element: () => optionsElement, on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('resolves a function returning a selector string', function () { + const step = new Step( + {}, + { + attachTo: { element: () => '.options-test', on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBe(optionsElement); + }); + + it('returns null when a function returns null', function () { + const step = new Step( + {}, + { + attachTo: { element: () => null, on: 'center' } + } + ); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('returns null when the step has no attachTo', function () { + const step = new Step({}, {}); + + const element = resolveAttachToElement(step); + expect(element).toBeNull(); + }); + + it('returns null for an invalid selector without throwing', function () { + const step = new Step( + {}, + { + attachTo: { element: ':::', on: 'center' } + } + ); + + expect(() => resolveAttachToElement(step)).not.toThrow(); + expect(resolveAttachToElement(step)).toBeNull(); + }); + }); + + describe('waitForAttachToElement()', function () { + it('resolves immediately with the element when it already exists', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.options-test', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 1000); + expect(element).toBe(optionsElement); + }); + + it('resolves with the element when appended later', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.added-later', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + let addedElement; + + setTimeout(() => { + addedElement = document.createElement('div'); + addedElement.classList.add('added-later'); + document.body.appendChild(addedElement); + }, 20); + + const element = await promise; + + expect( + element, + 'resolves with the element appended after the wait started' + ).toBe(addedElement); + + document.body.removeChild(addedElement); + }); + + it('resolves with the element when an attribute change makes the selector match', async function () { + const div = document.createElement('div'); + document.body.appendChild(div); + + const step = new Step( + {}, + { + attachTo: { element: '.added-later-class', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + + setTimeout(() => { + div.classList.add('added-later-class'); + }, 20); + + const element = await promise; + + expect( + element, + 'resolves with the element once its class matches the selector' + ).toBe(div); + + document.body.removeChild(div); + }); + + it('resolves with null after timeout when the element never appears', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 50); + expect(element).toBeNull(); + }); + + it('resolves with null immediately when missing and timeout is 0', async function () { + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 0); + expect(element).toBeNull(); + }); + + it('falls back to polling when MutationObserver is unavailable', async function () { + vi.stubGlobal('MutationObserver', undefined); + + const step = new Step( + {}, + { + attachTo: { element: '.polled-for-xyz', on: 'center' } + } + ); + + const promise = waitForAttachToElement(step, 1000); + let addedElement; + + setTimeout(() => { + addedElement = document.createElement('div'); + addedElement.classList.add('polled-for-xyz'); + document.body.appendChild(addedElement); + }, 20); + + const element = await promise; + + expect( + element, + 'polling resolves with the element without a MutationObserver' + ).toBe(addedElement); + + document.body.removeChild(addedElement); + vi.unstubAllGlobals(); + }); + + it('resolves with null after timeout when polling and the element never appears', async function () { + vi.stubGlobal('MutationObserver', undefined); + + const step = new Step( + {}, + { + attachTo: { element: '.never-appears-xyz', on: 'center' } + } + ); + + const element = await waitForAttachToElement(step, 80); + + expect(element, 'polling gives up once the timeout expires').toBeNull(); + + vi.unstubAllGlobals(); + }); }); describe('parseExtraHighlights()', function () { From f8d2ccbf1d6d8382fe5514f9d42bb1c5fb9b9650 Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Wed, 12 Aug 2026 10:57:41 +0200 Subject: [PATCH 2/2] fix: propagate the skipped-to step's promise through _skipStep _skipStep returns show(), which is now a promise while a step waits on its element, but the two synchronous skip paths dropped it. That made await tour.next() resolve before the step we skipped to was on screen. Both regression tests fail without the return. Also documents the polling fallback and defaultStepOptions support for waitForElement, and the promise-returning navigation methods. --- docs-src/src/content/docs/guides/usage.md | 13 +++- shepherd.js/src/tour.ts | 7 +- shepherd.js/test/unit/tour.spec.js | 85 +++++++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index ad8bc6978..928356222 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -172,6 +172,11 @@ const myTour = new Shepherd.Tour(options); - `off(eventName, [handler])`: Unbind an event - `once(eventName, handler, [context])`: Bind just the next instance of an event +`start()` always returns a promise, and `show()`, `next()`, and `back()` return +one while a step is waiting on its `attachTo.element` (see `waitForElement` +below). Await them if you need to know the step is on screen — otherwise they +can be called and ignored, as before. + ##### Tour Events - `complete`: Triggered when the last step is advanced @@ -316,10 +321,12 @@ function will be called in the `before-show` phase. element are never skipped, since they are intentionally centered. - `waitForElement`: The maximum amount of time, in milliseconds, to wait for the `attachTo.element` to appear in the DOM before showing the step. The DOM is - watched with a `MutationObserver`, so the step attaches as soon as the element + watched with a `MutationObserver`, falling back to polling where + `MutationObserver` is unavailable, so the step attaches as soon as the element appears. If the timeout expires, the step falls back to its default behavior: - skipped when `skipMissingElement` is true, otherwise shown centered. Useful - for targets that are rendered asynchronously. + skipped when `skipMissingElement` is true, otherwise shown centered. It can + also be set on `defaultStepOptions` to apply to every step. Useful for targets + that are rendered asynchronously. Both options look the target up before the step's own `beforeShowPromise` and `before-show` handlers run, so they cannot see an element that those handlers diff --git a/shepherd.js/src/tour.ts b/shepherd.js/src/tour.ts index c45c5caa8..6255ea691 100644 --- a/shepherd.js/src/tour.ts +++ b/shepherd.js/src/tour.ts @@ -347,9 +347,9 @@ export class Tour extends Evented { isFunction(step.options.showOn) && !step.options.showOn(); // If `showOn` returns false, we want to skip the step, otherwise, show the step like normal + // Return the result, since the step we skip to may itself be waiting on an element. if (shouldSkipStep) { - this._skipStep(step, forward); - return; + return this._skipStep(step, forward); } const { skipMissingElement, waitForElement } = step.options; @@ -387,8 +387,7 @@ export class Tour extends Evented { } if (skipMissingElement) { - this._skipStep(step, forward); - return; + return this._skipStep(step, forward); } } diff --git a/shepherd.js/test/unit/tour.spec.js b/shepherd.js/test/unit/tour.spec.js index e388d693a..28f670b60 100644 --- a/shepherd.js/test/unit/tour.spec.js +++ b/shepherd.js/test/unit/tour.spec.js @@ -1214,5 +1214,90 @@ describe('Tour | Top-Level Class', function () { 'no complete event fires after cancel' ).toBe(false); }); + + it('awaits a wait on the step skipped to by skipMissingElement', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'skipped', + attachTo: { element: '.does-not-exist-xyz', on: 'top' }, + skipMissingElement: true + }); + + instance.addStep({ + id: 'waits', + attachTo: { element: '.appears-after-skip-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-after-skip-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'next() resolves only once the step skipped to has finished waiting' + ).toBe('waits'); + expect( + instance.getCurrentStep().target, + 'the waited-for element is the resolved target' + ).toBe(appearedElement); + + document.body.removeChild(appearedElement); + }); + + it('awaits a wait on the step skipped to by showOn', async () => { + instance = new Shepherd.Tour(); + + instance.addStep({ + id: 'first', + attachTo: { element: 'body', on: 'top' } + }); + + instance.addStep({ + id: 'skipped', + showOn: () => false + }); + + instance.addStep({ + id: 'waits', + attachTo: { element: '.appears-after-show-on-xyz', on: 'top' }, + waitForElement: 1000 + }); + + instance.start(); + + const promise = instance.next(); + + let appearedElement; + setTimeout(() => { + appearedElement = document.createElement('div'); + appearedElement.classList.add('appears-after-show-on-xyz'); + document.body.appendChild(appearedElement); + }, 30); + + await promise; + + expect( + instance.getCurrentStep().id, + 'next() resolves only once the step skipped to has finished waiting' + ).toBe('waits'); + + document.body.removeChild(appearedElement); + }); }); });