Handling tour errors and missing targets
A tour fails when a step’s target element is missing or missing for too long. What happens next depends on which channel you listen on, and what strategy the step declares upfront.
Missing target strategies
Section titled “Missing target strategies”Every step can declare how to handle a missing target:
.step({ id: "settings", target: "#settings-panel", title: "Settings", content: "Configure your preferences.", behavior: { missingTarget: { strategy: "error", timeout: 3000 } },})| Strategy | Timeout applies | Behavior |
|---|---|---|
"error" |
No | Throw immediately, halting the tour. The error is reported on all three channels. |
"wait" |
Yes, default 3000ms | Poll for the target, waiting up to missingTarget.timeout before falling back to "error" |
"skip" |
No | Pass over the step without showing it, in the direction of the navigation. No error is thrown. |
"detached" |
No | Show the step anyway, with the popover centered in the viewport. No error is thrown. |
The default is "error" because missing targets are usually bugs: the app changed, the selector is stale, or a dynamic element never rendered. Catching them loudly keeps tours working.
"wait" suits async scenarios where a target might appear after navigation or a fetch. Set missingTarget.timeout to match your app’s worst case, or leave it at 3000ms and override per step if needed.
"skip" is for optional steps that some users may never see. A skipped step is passed over in the direction of the navigation, and a step:skip event is emitted for it. If skipping forward goes past the last step, the tour finishes. If previous() or goTo() skips backward past the first step, the tour stays on the current step.
When the target of the step on screen disappears and does not come back, the tour moves on from that "skip" step in the direction of the last navigation. If that goes backward past the first step, there is no step left to show: the tour is cancelled.
Three channels
Section titled “Three channels”A tour error is reported through three separate channels. They are not equivalent - each one solves a different job.
1. onEvent monitoring
Section titled “1. onEvent monitoring”onEvent is the monitoring callback wired to analytics:
const tour = createGlowTour({ onEvent: (event) => { if (event.type === "tour:error") { analytics.track("tour_failed", { step: event.stepId, error: event.error?.message, }); } },});The callback receives a tour:error event with:
error: theErrorobject (Missing target at steps[N]: <selector>)stepId,stepIndex,stepCount: where the tour wasdurationMs: how long the tour was running before the failure- The event carries the same fields as other events -
workflowName,source,direction,timestamp.
onEvent is the right channel for analytics and observability.
Note: tour:error is not preceded by step:leave. The step was not left - the tour died on it - and the event still names that step, so the pair reconciles in a funnel. See Monitoring a tour for the full event order.
2. Tour state
Section titled “2. Tour state”The tour state holds the error in a reactive channel:
tour.state.subscribe((state) => { if (state.status === "error" && state.error) { showErrorBanner(state.error.message); }});The state carries:
status: becomes"error"on failureerror: theErrorobject, ornullif the tour is not in an error state
This is the channel for UI - show a message, disable buttons, or log internally. Unlike onEvent, you can read the error synchronously:
const state = tour.state.get();if (state.status === "error") { console.error("Tour failed:", state.error);}In a component, useGlowTour() (Angular: injectGlowTour()) exposes the same fields as reactive state, including status and error.
3. Rejected promise
Section titled “3. Rejected promise”The promise from the method that triggered the failure is rejected:
try { await tour.start(workflow);} catch (error) { console.error("Tour failed on first step:", error);}This is a local convenience for simple patterns, but it has a hard limit.
The promise trap
Section titled “The promise trap”start() resolves once the first step is on screen, not after the whole tour:
await tour.start(workflow);// ← The first step is now visible. Any other step may still fail later.So a try/catch around start() only catches failures on that first step. A target missing on step 2, 3, or later is not caught there - it rejects the advance(), previous(), or goTo() call that caused it:
try { await tour.advance(); // ← Only failures triggered by *this call* are caught here.} catch (error) { console.error("Failed advancing to the next step:", error);}A third case: if a target disappears after the step was shown - the overlay is already on screen and the target unmounts - the failure is routed through the driver’s recovery path and reported via onEvent and state, but the promise is not rejected. There is nothing to catch, so you must listen on onEvent or state to handle it.
Conclusion: Use onEvent and state for complete, reliable error handling. Promises are a convenience for synchronous paths, not a complete channel.
What is not a tour error
Section titled “What is not a tour error”An exception thrown inside onEvent or a state subscriber does not become the tour’s own error:
tour.state.subscribe((state) => { throw new Error("oops"); // ← This does not halt the tour or become state.error.});Subscriber errors are routed to GlowTourOptions.onSubscriberError, or to an unhandled error reporter if none is set. The tour carries on. This isolation keeps tours resilient to bugs in your own listeners.
The lifecycle hooks are the opposite: a throw in onStart, onCancel or onFinish is a tour failure like any other, and it lands on all three channels. Use context.abort() when you want to stop a transition without failing the tour.
Example: recovery UI
Section titled “Example: recovery UI”Combine state and missingTarget.strategy to build a transparent recovery path:
const tour = createGlowTour({ onEvent: (event) => { if (event.type === "tour:error") { // Log the failure for monitoring. console.log( `Tour failed at step ${event.stepIndex + 1}/${event.stepCount}:`, event.error?.message ); } },});
const workflow = tour .create("checkout") .step({ id: "cart", target: "#cart", title: "Your cart", content: "Review your items.", // If the cart element hasn't loaded yet, wait a bit. behavior: { missingTarget: { strategy: "wait", timeout: 5000 } }, }) .step({ id: "checkout", target: "#checkout", title: "Proceed to checkout", content: "Click here to complete your order.", // If checkout is removed or hidden (app error), skip it silently. behavior: { missingTarget: { strategy: "skip" } }, }) .build();
// Show error state in the UI.tour.state.subscribe((state) => { if (state.status === "error") { const message = state.error?.message ?? "Tour stopped unexpectedly"; console.error(message); // Re-enable the page or show a retry button. }});
await tour.start(workflow);Here, the cart step waits up to 5 seconds for its element - useful if you navigate to it from another page. The checkout step is marked skip, so even if the element is gone when we reach it, the tour continues or finishes gracefully without noise.
When a step’s message still matters without its element, use detached instead of skip: the step is shown anyway, with the popover centered in the viewport and a backdrop that covers the whole screen.
.step({ id: "promo", target: "#promo-banner", title: "New this month", content: "Discounts are back on every plan.", // The banner is hidden on some plans: show the message on its own. behavior: { missingTarget: { strategy: "detached" } },})A detached step is shown as soon as its target is not found (timeout only applies to wait). It has no cutout and no pointer, doesn’t scroll, and keeps the page blocked even when allowInteraction is true. targetEvents are not bound, and context.target in its actions and hooks is the document’s <body>. A target that disappears for good while its step is on screen detaches the same way, and focus moves back into the popover. A detached step stays detached until it is left, even if its target appears in the meantime.
