Skip to content

feat: add waitForElement and skipMissingElement step options - #3471

Merged
chuckcarpenter merged 2 commits into
mainfrom
missing-element-options
Aug 12, 2026
Merged

feat: add waitForElement and skipMissingElement step options#3471
chuckcarpenter merged 2 commits into
mainfrom
missing-element-options

Conversation

@chuckcarpenter

@chuckcarpenter chuckcarpenter commented Aug 12, 2026

Copy link
Copy Markdown
Member

Adds two step options for dealing with targets that aren't in the DOM when a step comes up, plus a small data passthrough. driver.js has had equivalents of the first two for a while and they solve a real problem for us.

waitForElement: number

How long, in ms, to wait for attachTo.element to show up before giving up. Watches the DOM with a MutationObserver (childList + subtree + attributes, so it also catches a class being added to a node that already exists) and falls back to polling if MutationObserver isn't available. It resolves as soon as the element appears rather than burning the whole timeout.

skipMissingElement: boolean

When 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 _skipStep path, so it behaves like showOn returning false: trailing skipped steps complete the tour going forward, and cancel going backward.

data

Optional 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 defaultStepOptions as 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

  • The check runs in Tour.show() before the step is committed, so a skipped step never becomes currentStep and never fires a show event.
  • show()/next()/back()/start() now return the pending promise while a wait is in flight. They returned undefined before, so it's additive, but it makes the waiting path awaitable.
  • A pending wait is invalidated by a newer show() (generation counter) or by the tour going inactive, so cancelling mid-wait can't resurrect the tour.
  • parseAttachTo no longer logs "element not found" when skipMissingElement is set, since that's the expected case there.
  • One sharp edge: both options resolve the target before the step's own beforeShowPromise/before-show handlers run, so they can't see an element those handlers create. It's called out in the option docs and in the usage guide — beforeShowPromise is still the answer for a target the step renders itself.

Behavior is unchanged if you don't set either option.

Tests

pnpm test:unit:ci is 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 from defaultStepOptions, 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

    • Added support for custom data in step event handlers and button actions.
    • Steps can wait for dynamically rendered target elements before displaying.
    • Added options to skip steps with unavailable targets, including centered fallback behavior after timeout.
    • Improved navigation handling for delayed, skipped, superseded, or canceled steps.
    • Tour navigation methods now provide promise-based completion when waiting is required.
  • Documentation

    • Documented missing-target behavior, timing, fallback handling, and step data 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.
@vercel

vercel Bot commented Aug 12, 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 12, 2026 8:58am
shepherd-landing Ready Ready Preview Aug 12, 2026 8:58am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 12, 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: 1bf24c09-285b-42c6-990d-05f2b7e747c9

📥 Commits

Reviewing files that changed from the base of the PR and between 594999c and f8d2ccb.

📒 Files selected for processing (3)
  • docs-src/src/content/docs/guides/usage.md
  • shepherd.js/src/tour.ts
  • shepherd.js/test/unit/tour.spec.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs-src/src/content/docs/guides/usage.md

📝 Walkthrough

Walkthrough

The 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.

Changes

Missing target handling

Layer / File(s) Summary
Step options and documentation
shepherd.js/src/step.ts, docs-src/src/content/docs/guides/usage.md, shepherd.js/test/unit/step.spec.js
Step options now support data, skipMissingElement, and waitForElement. Documentation describes their behavior and serialization.
Attachment resolution and waiting
shepherd.js/src/utils/general.ts, shepherd.js/test/unit/utils/general.spec.js
New utilities resolve attachment locators and wait for targets through MutationObserver or polling. Missing-target logging respects skipMissingElement.
Asynchronous tour navigation
shepherd.js/src/tour.ts, shepherd.js/test/unit/tour.spec.js
Tour.show() waits for delayed targets, skips unresolved steps, handles centered fallback, invalidates stale waits, and propagates navigation results. Tests cover timeout, completion, cancellation, and superseded calls.

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
Loading

Suggested reviewers: robbiethewagner

🚥 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 and concisely identifies the two primary step options added by the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 missing-element-options

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/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
(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 12, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 1.1%.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
shepherd.js/src/tour.ts100.0%
Coverage rating: A Coverage rating: A
shepherd.js/src/utils/general.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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
shepherd.js/src/utils/general.ts (2)

73-88: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider reusing one resolution path.

resolveAttachToElement repeats the locator logic in parseAttachTo (lines 41-61). The two implementations can drift. parseAttachTo can call resolveAttachToElement and 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 parseAttachTo only 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 win

Add a cancellation path for superseded waits.

waitForAttachToElement runs until the element appears or the timeout expires. Tour.show() invalidates a superseded wait only after it settles (shepherd.js/src/tour.ts lines 374-379). Until then, a subtree MutationObserver on document.documentElement, or a 50 ms poll timer, stays active. With a large waitForElement value, several superseded waits can observe every DOM mutation at the same time.

An optional AbortSignal parameter lets the tour release the observer as soon as a newer show() 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 win

Restore 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: move vi.unstubAllGlobals() into an afterEach hook for the waitForAttachToElement() describe block, and remove the two trailing calls.
  • shepherd.js/test/unit/tour.spec.js#L1030-L1064: move the console.error spy restore into an afterEach hook, or call vi.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

📥 Commits

Reviewing files that changed from the base of the PR and between 1991f23 and 594999c.

📒 Files selected for processing (7)
  • docs-src/src/content/docs/guides/usage.md
  • shepherd.js/src/step.ts
  • shepherd.js/src/tour.ts
  • shepherd.js/src/utils/general.ts
  • shepherd.js/test/unit/step.spec.js
  • shepherd.js/test/unit/tour.spec.js
  • shepherd.js/test/unit/utils/general.spec.js

Comment thread docs-src/src/content/docs/guides/usage.md
Comment thread shepherd.js/src/tour.ts
Comment thread shepherd.js/src/tour.ts
Comment thread shepherd.js/src/tour.ts
_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.
@chuckcarpenter
chuckcarpenter merged commit 72e26cc into main Aug 12, 2026
8 checks passed
@chuckcarpenter
chuckcarpenter deleted the missing-element-options branch August 12, 2026 12:39
@github-actions github-actions Bot mentioned this pull request Aug 12, 2026
chuckcarpenter added a commit that referenced this pull request Aug 12, 2026
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()`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant