feat: add waitForElement and skipMissingElement step options - #3471
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe PR adds serializable step data, delayed target waiting, missing-target skipping, and asynchronous navigation handling. It adds target-resolution utilities, tour lifecycle safeguards, documentation, and unit coverage for resolution, timeout, fallback, cancellation, and superseded navigation. ChangesMissing target handling
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Tour
participant waitForAttachToElement
participant DOM
participant Step
Tour->>waitForAttachToElement: wait for configured target timeout
waitForAttachToElement->>DOM: observe mutations or poll
DOM-->>waitForAttachToElement: target becomes available
waitForAttachToElement-->>Tour: return resolved HTMLElement
Tour->>Step: assign target and render
Tour->>Tour: suppress stale or inactive wait
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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
shepherd.js/test/unit/tour.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 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. Comment |
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (2)
🛟 Help
|
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
shepherd.js/src/utils/general.ts (2)
73-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider reusing one resolution path.
resolveAttachToElementrepeats the locator logic inparseAttachTo(lines 41-61). The two implementations can drift.parseAttachTocan callresolveAttachToElementand keep only the logging and option copying.♻️ Proposed refactor
export function parseAttachTo(step: Step) { const options = step.options.attachTo || {}; const returnOpts = Object.assign({}, options); - if (isFunction(returnOpts.element)) { - // Bind the callback to step so that it has access to the object, to enable running additional logic - returnOpts.element = returnOpts.element.call(step); - } - - if (isString(returnOpts.element)) { - // Can't override the element in user opts reference because we can't - // guarantee that the element will exist in the future. - try { - returnOpts.element = document.querySelector( - returnOpts.element - ) as HTMLElement; - } catch (_e) { - // TODO - } - if (!returnOpts.element && !step.options.skipMissingElement) { - console.error( - `The element for this Shepherd step was not found ${options.element}` - ); - } - } + const needsLookup = + isFunction(returnOpts.element) || isString(returnOpts.element); + + if (needsLookup) { + // Can't override the element in user opts reference because we can't + // guarantee that the element will exist in the future. + returnOpts.element = resolveAttachToElement(step) ?? undefined; + + if (!returnOpts.element && !step.options.skipMissingElement) { + console.error( + `The element for this Shepherd step was not found ${options.element}` + ); + } + } return returnOpts; }Note one behavior difference: the current
parseAttachToonly logs when the locator is a string. The refactor above also logs when a function locator returns nothing. Confirm the wanted behavior before you apply it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@shepherd.js/src/utils/general.ts` around lines 73 - 88, Refactor parseAttachTo to reuse resolveAttachToElement for locator resolution instead of duplicating its element/function/string handling. Preserve parseAttachTo’s existing logging and option-copying behavior, and retain the current restriction that missing-element logging occurs only when the original locator is a string; do not introduce logging for function locators that return nothing.
99-150: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAdd a cancellation path for superseded waits.
waitForAttachToElementruns until the element appears or the timeout expires.Tour.show()invalidates a superseded wait only after it settles (shepherd.js/src/tour.tslines 374-379). Until then, a subtreeMutationObserverondocument.documentElement, or a 50 ms poll timer, stays active. With a largewaitForElementvalue, several superseded waits can observe every DOM mutation at the same time.An optional
AbortSignalparameter lets the tour release the observer as soon as a newershow()starts.♻️ Proposed change
export function waitForAttachToElement( step: Step, - timeout: number + timeout: number, + signal?: AbortSignal ): Promise<HTMLElement | null> { return new Promise((resolve) => { const element = resolveAttachToElement(step); - if (element || !(timeout > 0)) { + if (element || !(timeout > 0) || signal?.aborted) { resolve(element); return; } @@ const finish = (result: HTMLElement | null) => { observer?.disconnect(); + signal?.removeEventListener('abort', onAbort); if (pollTimer !== null) { clearInterval(pollTimer); } @@ resolve(result); }; + + const onAbort = () => finish(null); + signal?.addEventListener('abort', onAbort, { once: true });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@shepherd.js/src/utils/general.ts` around lines 99 - 150, Update waitForAttachToElement to accept an optional AbortSignal and add an abort cancellation path that calls finish without resolving a found element, ensuring the MutationObserver, polling interval, and timeout are all released immediately when the signal is already aborted or becomes aborted. Update the Tour.show() call site to pass the signal for the active wait so superseded waits are cancelled as soon as a newer show starts.shepherd.js/test/unit/utils/general.spec.js (1)
293-338: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRestore stubbed globals and mocks in hooks, not in test bodies. Both new test blocks undo global state as the last statement of the test body. If an assertion fails first, that statement never runs, the state leaks into later tests, and the resulting cascading failures hide the original one.
shepherd.js/test/unit/utils/general.spec.js#L293-L338: movevi.unstubAllGlobals()into anafterEachhook for thewaitForAttachToElement()describe block, and remove the two trailing calls.shepherd.js/test/unit/tour.spec.js#L1030-L1064: move theconsole.errorspy restore into anafterEachhook, or callvi.restoreAllMocks()there.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@shepherd.js/test/unit/utils/general.spec.js` around lines 293 - 338, Restore test global state through hooks rather than test-body cleanup: in shepherd.js/test/unit/utils/general.spec.js:293-338, add an afterEach hook for the waitForAttachToElement() describe block that calls vi.unstubAllGlobals(), and remove both trailing calls; in shepherd.js/test/unit/tour.spec.js:1030-1064, move the console.error spy restoration into an afterEach hook or use vi.restoreAllMocks() there.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs-src/src/content/docs/guides/usage.md`:
- Around line 317-329: Update the waitForElement documentation in the usage
guide to state that the option may be inherited through defaultStepOptions. Also
document that target detection uses MutationObserver when available and falls
back to polling when MutationObserver is unavailable, while preserving the
existing timeout and fallback behavior.
In `@shepherd.js/src/tour.ts`:
- Around line 346-396: Return the result of _skipStep(step, forward) from both
synchronous skip branches: the showOn-based branch near shouldSkipStep and the
skipMissingElement branch when no wait is configured. Preserve the existing
asynchronous wait path, which already returns its promise, so show() propagates
the promise returned by _skipStep().
- Line 334: Update the usage guide documentation for the navigation methods
start(), show(), back(), and next() to state their return types: start() always
returns Promise<void>, while show(), back(), and next() may return a promise
when element waiting is enabled.
- Around line 403-410: Update _showStep so it stores the existing currentStep
before assigning the new step, then use that saved value for the show event’s
previous payload while retaining the new step as currentStep and step.
---
Nitpick comments:
In `@shepherd.js/src/utils/general.ts`:
- Around line 73-88: Refactor parseAttachTo to reuse resolveAttachToElement for
locator resolution instead of duplicating its element/function/string handling.
Preserve parseAttachTo’s existing logging and option-copying behavior, and
retain the current restriction that missing-element logging occurs only when the
original locator is a string; do not introduce logging for function locators
that return nothing.
- Around line 99-150: Update waitForAttachToElement to accept an optional
AbortSignal and add an abort cancellation path that calls finish without
resolving a found element, ensuring the MutationObserver, polling interval, and
timeout are all released immediately when the signal is already aborted or
becomes aborted. Update the Tour.show() call site to pass the signal for the
active wait so superseded waits are cancelled as soon as a newer show starts.
In `@shepherd.js/test/unit/utils/general.spec.js`:
- Around line 293-338: Restore test global state through hooks rather than
test-body cleanup: in shepherd.js/test/unit/utils/general.spec.js:293-338, add
an afterEach hook for the waitForAttachToElement() describe block that calls
vi.unstubAllGlobals(), and remove both trailing calls; in
shepherd.js/test/unit/tour.spec.js:1030-1064, move the console.error spy
restoration into an afterEach hook or use vi.restoreAllMocks() there.
🪄 Autofix
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: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 91c942c4-ec4f-4855-97f4-361f995b95a5
📒 Files selected for processing (7)
docs-src/src/content/docs/guides/usage.mdshepherd.js/src/step.tsshepherd.js/src/tour.tsshepherd.js/src/utils/general.tsshepherd.js/test/unit/step.spec.jsshepherd.js/test/unit/tour.spec.jsshepherd.js/test/unit/utils/general.spec.js
_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.
Conflict was in step.spec.js only, where #3471's `data option` tests and the new `step lifecycle` tests were both appended to the end of the file. Kept both. `waitForElement`/`skipMissingElement` from #3471 resolve the element in `Tour.show()` before `step.show()` runs, so they don't interact with the teardown change in `_setupElements()`.

Adds two step options for dealing with targets that aren't in the DOM when a step comes up, plus a small
datapassthrough. driver.js has had equivalents of the first two for a while and they solve a real problem for us.waitForElement: numberHow long, in ms, to wait for
attachTo.elementto show up before giving up. Watches the DOM with aMutationObserver(childList + subtree + attributes, so it also catches a class being added to a node that already exists) and falls back to polling ifMutationObserverisn't available. It resolves as soon as the element appears rather than burning the whole timeout.skipMissingElement: booleanWhen the target still isn't there, skip the step instead of parking a tooltip in the middle of the screen pointing at nothing. Reuses the existing
_skipSteppath, so it behaves likeshowOnreturning false: trailing skipped steps complete the tour going forward, and cancel going backward.dataOptional
Record<string, unknown>that we never touch, so you can hang analytics ids or similar metadata off a step and read it back in handlers and button actions.All three are plain JSON values, and the first two work in
defaultStepOptionsas well as per step. The motivation is tours whose definitions come from somewhere other than hand-written JS — a stale selector shouldn't strand someone on a centered tooltip pointing at nothing.Implementation notes
Tour.show()before the step is committed, so a skipped step never becomescurrentStepand never fires ashowevent.show()/next()/back()/start()now return the pending promise while a wait is in flight. They returnedundefinedbefore, so it's additive, but it makes the waiting path awaitable.show()(generation counter) or by the tour going inactive, so cancelling mid-wait can't resurrect the tour.parseAttachTono longer logs "element not found" whenskipMissingElementis set, since that's the expected case there.beforeShowPromise/before-showhandlers run, so they can't see an element those handlers create. It's called out in the option docs and in the usage guide —beforeShowPromiseis still the answer for a target the step renders itself.Behavior is unchanged if you don't set either option.
Tests
pnpm test:unit:ciis green at 212. New coverage for the wait helper (observer path, attribute-change path, timeout, polling fallback) and for the tour behavior: skipping mid-tour, inheriting fromdefaultStepOptions, completing when trailing steps are skipped, cancelling backward past a skipped first step, timing out into a centered step, and supersede/cancel while a wait is pending. I didn't run Cypress locally, so let CI have that one.Summary by CodeRabbit
New Features
Documentation