Troubleshooting
Symptoms, what causes them, and what to change. Grouped by where the problem actually lives, not by what it looks like from the outside.
Start by watching the events
Almost every silent failure announces itself through onEvent. Wire this up before guessing:
<TourProvider
tours={tours}
onEvent={(name, event) => {
if (process.env.NODE_ENV === "development") console.log("tourkit", name, event);
}}
>target:timeout is the one that matters most. It fires whenever a gate did not resolve in time,
and it is always followed by whatever onGateTimeout did next.
On web, the overlay also states what it is doing in the DOM:
[data-tourkit="root"][data-tourkit-state="resolving"] waiting on a gate
[data-tourkit="root"][data-tourkit-state="active"] the step is showing
[data-tourkit="root"][data-tourkit-interaction="block"] which interaction mode is in forceIf root is absent entirely, no tour is running.
Nothing happens when I call start()
The id does not match a registered tour. start(id) looks the id up in tours and returns
silently when it finds nothing. Check the string against config.id, not the file name.
The provider is not an ancestor. A hook outside the provider throws
tourkit: this hook must be used inside <TourProvider>. rather than failing quietly, so if you
are not seeing that error, the provider is there.
The tour started and every step was skipped. A tour whose steps all fail their gates
completes in milliseconds. The event log shows tour:start, then target:timeout and
step:skip per step, then tour:complete.
A step is skipped and I see target:timeout
The target never resolved within gateTimeoutMs, which defaults to 5000.
On web, target is tried in three ways, in this order:
- An id registered through
useTourTarget("post-ride") - A
data-tour-id="post-ride"attribute - The string as a CSS selector
Work through the causes in order of how often they are the answer:
| Cause | How to confirm | Fix |
|---|---|---|
| Typo in the id | document.querySelector('[data-tour-id="post-ride"]') returns null |
Fix the string. |
| The element mounts after the gate expires | It appears in the DOM a second later | Raise gateTimeoutMs, or write a gate that waits on your data. |
| The element is on another route | The step has no route |
Add route and a nav adapter. See Navigation. |
| It is inside a modal that is closed | The modal opens on click | Open it from onEnter, which is awaited before the gate runs. |
| A refactor broke the selector | The class name is hashed or renamed | Give the element a data-tour-id, or add a fingerprint. |
when removed the step |
The step never appears in snapshot.steps |
Check the predicate against the context you actually pass. |
A gate that is about your data rather than the DOM should say so:
{
id: "chart",
target: "revenue-chart",
gate: async ({ context }) => context.reportsLoaded,
gateTimeoutMs: 10000,
}An invalid CSS selector resolves to null rather than throwing, so a malformed target looks
exactly like a missing element.
I see a warning about a fingerprint
tourkit: step "cancel" could not find ".danger-btn" and matched it by fingerprint instead.
Update the target before it stops matching.The tour is working. The selector is not. Healing is a safety net, and a healed step is a step
whose target is already wrong. Give the element a data-tour-id and point the step at that.
Details in When a target breaks.
If a step has a fingerprint and is still skipped, the fingerprint refused to guess. It only matches when the best candidate scores at least 4 and strictly beats the runner-up, so two buttons that both say "Save" resolve to nothing on purpose.
The tour only ever shows once
Completion is persisted under tour:{id}:v{version}. A completed tour starts again from step one,
but a first-run trigger that checks the record will not fire.
import { clearRecord } from "@tourkit/core";
await clearRecord(storage, onboarding);
start("onboarding");Use the provider's own adapter, not a fresh one, or you will clear a key nobody reads:
const { storage } = useTourContext();The tour resumes onto the wrong step
You changed the steps without bumping version. The saved position is a step id, and an id that
still exists in a reordered tour resumes at the new position of that id.
Bump version whenever the steps change. A saved position from an older version is discarded
rather than resumed.
The tour restarts from the beginning every time
On React Native, nothing is persisted until you pass a storage adapter. There is no
localStorage to fall back to.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createStorageAdapter } from "@tourkit/native";
const storage = createStorageAdapter(AsyncStorage);On web, check that the provider is not remounting. The engine is created once with lazy
useState, so a provider that unmounts loses everything in flight. A provider inside a
route-level component, or one whose key changes on login, remounts. Put it in the root layout.
The overlay renders behind a modal
Raise theme.zIndex. It defaults to 10000, which clears most stacking contexts, but a modal
library that sets a higher value wins.
The reverse is also a setting: lower it to put the tour under a fixed header.
A z-index cannot escape a parent stacking context. If your modal creates one and the tour is
portalled inside it, pass container to portal the overlay somewhere else:
<TourProvider tours={tours} container={document.body}>The card sits in the wrong place
placement is a preference, not a guarantee. Floating UI flips and shifts the card to keep it on
screen, and placement.side in the card props reports where it actually landed.
On React Native, placement: "left" and "right" fall back to auto, because a 320px card does
not fit beside anything on a 390px screen.
A step with target: null centres the card and cuts no hole. That is the intended shape for a
final step, not a bug.
Escape does not close the tour
dismissible: false on the step or the tour. Escape is ignored, and a custom card is told to
hide its skip control. It defaults to true.
Clicking the highlighted element does nothing
That is interaction: "block", the default. A full-screen shield absorbs every press, including
inside the hole, so the highlight is purely visual.
| You want | Set |
|---|---|
| The real element to work | interaction: "passthrough" |
| Pressing the hole to advance the tour | interaction: "advance-on-press" |
advance-on-press does not press the underlying element. That is deliberate and identical on
both platforms. See Interaction.
The scroll does not happen
scrollIntoViewIfNeeded is a no-op when the element is already fully inside the viewport, so a
target that is only slightly clipped by a sticky header will not scroll. Give it an explicit
block:
{ id: "row", target: "third-row", scroll: { block: "start" } }On React Native, scrolling needs the container's ref:
<TourProvider tours={tours} scrollRef={scrollRef}>Use useRef<ComponentRef<typeof ScrollView> | null>(null). useRef<ScrollView> does not
typecheck in current React Native types, because the imported name is the component rather than
the instance.
React Native: the hole is offset from the element
TourTarget wraps a component that forwards neither onLayout nor a ref, so the spotlight falls
back to the wrapper, whose box includes the child's margins. Development logs this by name.
Forward onLayout to that component's root view:
function PlannerCard({ onLayout }: { onLayout?: (event: LayoutChangeEvent) => void }) {
return <View onLayout={onLayout} style={styles.card}>{/* ... */}</View>;
}React Native: the padding is doubled
TourTarget renders a wrapping View, and its style prop goes on that wrapper. Give it layout
only: flex, alignSelf, margin. Padding on the wrapper is added to the padding the child
already applies.
React Native: a strip along the bottom stays lit
A custom Backdrop that sizes itself from useWindowDimensions(). Under Android edge-to-edge the
window it reports is shorter than the screen by the system bars.
The Backdrop slot receives size, the measured box the overlay actually covers. Use that.
React Native: two elements fight over one id
tourkit: duplicate TourTarget id "post-ride". The last one mounted wins.Two TourTargets with the same id are mounted at once. Usually a target inside a list that was
not given a per-row id.
The blur does nothing on React Native
blur.enabled alone is not enough. Install @react-native-masked-view/masked-view and
expo-blur, rebuild, then hand the modules to createBlurBackdrop and pass it as the Backdrop
slot. Without that, the dim scrim stays and development logs one warning naming what to install.
The package does not import those modules itself on purpose. The reasoning is in Customization.
A theme token does nothing
Four tokens are not what they look like:
| Token | The surprise |
|---|---|
motion.easing |
React Native only. The web host uses a fixed curve. |
spotlight.radius: "auto" |
Resolves to 8 on web. There is no element radius to read. |
blur.enabled |
Needs a mounted blur backdrop on React Native. |
text.action.color |
The default card renders accent instead. Set accent, or read text.action in a custom card. |
All of them are in the Theme reference.
A hint ignores my provider theme
TourHint merges the defaults with its own theme prop and nothing else. The provider theme and
the tour theme do not reach it.
<TourHint id="filters" target="filters" theme={{ accent: "#ff0066" }} />A dismissed hint will not come back
Dismissal writes dismissed to tourkit:hint:{id} and the hint never renders again on that
device.
await storage.remove("tourkit:hint:filters");A hint whose target does not resolve renders nothing at all: no dot, no error, no warning. It appears as soon as the element mounts.
The recorder panel never appears
The panel polls GET /status and renders only while tourkit record is answering. Check, in
order:
tourkit recordis running in a second terminal.<TourRecorder />is mounted, inside the provider, in a development build.- Nothing is blocking
http://127.0.0.1:5178.
Pass autoShow={false} to render it unconditionally, which is the clipboard flow with no server.
npx tourkit only works when npm wrote the shim it looks for. Bun on Windows writes
tourkit.exe and tourkit.bunx instead, and npx then tries to fetch a package called tourkit
from the registry and fails with a 404. Use the runner that matches your install.
The native recorder captures nothing
It can only capture taps on mounted TourTargets. A production build has no queryable view tree
to resolve anything else against, so you wrap first and record second. The web recorder has no
such limit and needs nothing prepared.
Asking a question returns 422
The model named a target that is not in the manifest. status becomes failed with
reason: "unknown-target", and nothing renders. This is the check working: a partial tour
pointing confidently at empty space is worse than no tour.
The usual cause is a thin manifest. A target id of btn-4 with no label tells the model nothing.
<button data-tour-id="cancel-ride" data-tour-label="Cancel a posted ride">×</button>Other reasons: malformed covers a network error, a non-2xx response and unparseable JSON.
empty means the model returned no usable steps.
Everything re-renders more than it should
tours built inline. The provider pushes tours into the engine from an effect keyed on its
identity. A fresh array on every parent render re-runs that effect and re-renders the overlay.
Hoist it to module scope, or memoize it.
The same applies to theme, storage, nav and onEvent. All five can change mid-tour without
restarting anything, which is what makes a dark mode toggle safe, and which is also why a new
object every render is not free.
useTourSnapshot where useTour would do. The snapshot is a new object on every engine
change. useTour() selects a boolean, so it re-renders only when the tour starts or ends.
An inline selector. useTourSelector reads through the callback you pass, so an inline arrow
re-subscribes on every render. Define it outside the component or wrap it in useCallback.
TypeScript rejects my when or gate
<TourProvider<AppContext> tours={tours} context={{ plan: "free" }}>Without the annotation, TypeScript infers the context from the object literal and widens "free"
to string, so a predicate typed against AppContext no longer matches. The full explanation is
in the Provider reference.
Still stuck
Steps that never resolve, tours that never start, and cards in the wrong place are all visible in the snapshot. Log the whole thing:
const snapshot = useTourSnapshot();
console.log(snapshot.status, snapshot.step?.id, snapshot.activeTarget, snapshot.rects);status is idle, resolving or active. A step stuck on resolving is waiting for a gate.
rects is empty for a target that was never measured.