fix: only trigger the step destroy event on real teardown - #3474
Conversation
`_setupElements()` rebuilds a step's element on every show, and it tore down
the previous element by calling the public `destroy()`. So re-showing a step
emitted `before-show` -> `destroy` -> `show`, which made `destroy` useless as
a teardown hook: anything a step set up in `beforeShowPromise` got torn back
down in the middle of the next show.
Use `_teardownElements()` instead. It already exists to do exactly this
teardown without emitting the public event, and `updateStepOptions()` was
already using it. `destroy` now fires once, and only when the step is really
being thrown away.
Two things had to come along with it:
- `advanceOn`'s only unbind path was `step.on('destroy', ...)`, so dropping
the per-show event would have leaked a DOM listener on every show.
`bindAdvance` now returns a cleanup function that teardown calls, which
also closes out an old TODO about binding/unbinding on show/hide.
- `_teardownElements()` wasn't idempotent. It clears the stored tabindex map
but never resets `target`, so running it twice permanently dropped a
target's original `tabindex`. Guarding on `isHTMLElement(this.el)` covers
all three `el` states at once and fixes it. That was already reachable
through `updateStepOptions()`.
Fixes #3443
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
|
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 (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughStep element rebuilding now uses internal teardown instead of public destruction. Advance listeners have explicit cleanup. Documentation and tests define and verify the updated lifecycle event timing. ChangesStep lifecycle
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Step
participant bindAdvance
participant DOM
Step->>Step: _setupElements()
Step->>DOM: Tear down existing elements
Step->>bindAdvance: bindAdvance(step)
bindAdvance->>DOM: Install advance listener
Step->>DOM: Invoke stored cleanup during teardown
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 368-372: Update the documentation around Step.destroy to scope the
once-only claim to element recreation: state that re-showing does not emit
destroy, while repeated explicit destroy() calls may emit it each time. Do not
claim that destroy fires only once unless the implementation is also changed to
make destroy() idempotent.
🪄 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: 60e00895-c4b8-4bdc-8eec-90809d87be1a
📒 Files selected for processing (5)
docs-src/src/content/docs/guides/usage.mdshepherd.js/src/step.tsshepherd.js/src/utils/bind.tsshepherd.js/test/unit/step.spec.jsshepherd.js/test/unit/utils/bind.spec.js
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()`.
`Step.destroy()` has no destroyed-state guard, so calling it explicitly and then completing the tour emits `destroy` twice. Say what is actually guaranteed: recreating the element on show does not emit the event.
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (2)
🛟 Help
|

Fixes #3443.
destroyhas been firing every time an already-shown step is shown again, which makes it unusable as a teardown hook. @remiHau hit this trying to open a three-dots menu inbeforeShowPromiseand close it inwhen: { destroy }— going back and then forward closed the menu right after opening it.What was happening
_setupElements()rebuilds a step's element on every show, and it tore down the previous element by calling the publicdestroy():So the second show of any step emitted
before-show→destroy→show.We already have
_teardownElements(), which exists to do that teardown without emitting the public event, andupdateStepOptions()was already using it._setupElements()should have been too.destroynow means one thing — the step is gone for good — and fires fromstep.destroy(),tour.removeStep(id), and once per step from_done()on complete/cancel.Note for the record: the guard predates 0fff410 (#583) by a couple of months (#430). What #583 changed was making
_show()call_setupElements()unconditionally, which is what started exercising it on every show.Two things that had to come along
advanceOnwas unbinding via thedestroyevent.bindAdvanceregisteredstep.on('destroy', () => removeEventListener(...))as its only cleanup path, so dropping the per-show event would have leaked a DOM listener on every show. It now returns a cleanup function that_teardownElements()calls, which also closes out the oldTODO: this should also bind/unbind on show/hide._teardownElements()wasn't idempotent. It clears_originalTabIndexesbut never resetstarget, and_restoreOriginalTabIndexes()removes the attribute outright on a map miss — so running teardown twice permanently dropped a target's originaltabindex. This was already reachable throughupdateStepOptions(), independent of this issue. Guarding onisHTMLElement(this.el)covers all threeelstates (undefined/null/ element) in one predicate and fixes it.Behavior change
Anyone counting
destroyinvocations will now see fewer of them. I'm treating this as a bug rather than a break: the per-show emission was documented nowhere, and it contradicts whatdestroy()is documented to mean. Labelingbug.Supersedes #3455
Thanks to @remiHau for the diagnosis and for taking a run at it. #3455 adds an
isNullcheck to the same guard, but it doesn't fix the reported symptom —hide()never nullsel, it only setshidden = true, so on a plain back → nextelis still anHTMLElementanddestroy()fires exactly as before. It only suppresses the seconddestroyonce you've already calleddestroy()yourself from ahidehandler, which makes the workaround viable rather than removing the need for it. Going with_teardownElements()instead of loosening the guard, since the real problem is a public event firing from a private code path.Tests
destroy;destroyfires exactly once per step on completebeforeShowPromise, cleanup inwhen: { destroy }, asserted across back → next and on both complete and canceladvanceOnlisteners don't accumulate across shows (adds == removes)tabindexsurvivesupdateStepOptions()_setupElementsone andbind.spec.js's "callsremoveEventListenerwhen destroyed", which now tests the returned cleanupAll five new tests fail against the old code, so they're actually guarding something. Lint,
types:check, 190 unit tests and 44 Cypress specs pass.Also documented the step lifecycle in the usage guide, with a table of which events fire when — that clearly wasn't discoverable.
Summary by CodeRabbit
Documentation
Bug Fixes