Skip to content

fix: cancel DismissibleLayer afterSleep timer on destroy (#2080) - #2087

Open
dieison-depra wants to merge 2 commits into
huntabyte:mainfrom
dieison-depra:fix/dismissible-layer-derived-inert-aftersleep
Open

fix: cancel DismissibleLayer afterSleep timer on destroy (#2080)#2087
dieison-depra wants to merge 2 commits into
huntabyte:mainfrom
dieison-depra:fix/dismissible-layer-derived-inert-aftersleep

Conversation

@dieison-depra

Copy link
Copy Markdown

Context

Hi maintainers 👋

Thank you for Bits UI — we rely on it heavily for Dialog / Sheet / Popover surfaces in a Svelte 5 app, and we are grateful for the care that goes into the headless layer.

While working through browser-test noise on our side, we kept hitting Svelte’s derived_inert warning when floating layers unmounted quickly. That led us to #2080, which already describes the race clearly. We would like to offer a small, focused patch as a proposal — happy to iterate if you prefer a different shape.

Root cause (what we validated)

In DismissibleLayerState (use-dismissable-layer.svelte.ts), setup schedules:

afterSleep(1, () => {
  if (!this.opts.ref.current) return;
  globalThis.bitsDismissableLayers.set(this, this.#behaviorType);
  unsubEvents = this.#addEventListeners();
});

What we observed when a layer is destroyed within that ~1 ms window (common under vitest-browser cleanup, rapid navigation, or CPU contention):

  1. onDestroyEffect runs while unsubEvents is still a no-op (the real subscribe has not happened yet).
  2. The pending afterSleep timer is not cancelled.
  3. ~1 ms later the callback still runs on a destroyed instance and reads this.opts.ref.current → Svelte emits derived_inert.
  4. If that read is still truthy, document-level pointerdown listeners can be attached to a dead layer and never cleaned up — aligning with the comment on DismissibleLayer: derived_inert warning — afterSleep(1) timer reads ref.current after the layer is destroyed #2080 about reopened dialogs self-closing ~10 ms later (stale onInteractOutside + debounce).

We confirmed stacks in a real SvelteKit + vitest-browser suite (bits-ui@2.18.1) converging on:

  • get current (svelte-toolbelt Box / ref derived)
  • #markResponsibleLayer / the deferred attach path
  • document-level pointerdown

This is the same family of teardown race already documented in #2080; we are not claiming a new root cause, only re-validating it end-to-end before suggesting a fix.

Proposed change

Minimal lifecycle hygiene, aligned with the suggested fix in #2080 and with patterns already used elsewhere in this repo (e.g. clearing timers in Select / Popover / Menu):

  1. Keep a handle to the afterSleep timer (pendingTimer).
  2. Cancel it in both cleanup() and onDestroyEffect.
  3. Set a destroyed flag on teardown and short-circuit on destroyed before any this.opts.ref.current read (so JS never touches the destroyed derived).

No public API changes. No behavior change for the happy path (layer lives longer than 1 ms).

Tests

Added two browser regressions under tests/src/tests/dialog/dialog.browser.test.ts:

  • unmount while open → assert console.warn does not contain derived_inert
  • close → immediate pointer reopen → dialog stays open ≥ 50 ms and no derived_inert

Local verification (after pnpm build:packages):

cd tests
pnpm exec vitest run --project=browser --browser=chromium   src/tests/dialog/dialog.browser.test.ts

Result: 43 passed | 1 todo (full dialog browser file, Chromium).

Note: this environment cannot run WebKit (macOS 12 + Playwright limitation), so CI coverage on WebKit will need your pipeline — happy to adjust if anything fails there.

We also exercised the same patch against our app’s LookupModal / ResponsiveSheet vitest-browser suite (open → unmount, open → close → open) with zero derived_inert warnings after the change.

Invitation / next steps

This is offered as a collaborative starting point, not a demand that the fix must land exactly this way. If you would rather:

  • extract a shared “cancel deferred work on destroy” helper,
  • guard additional call sites in the same family (Avatar / floating / link-preview were mentioned in related issues), or
  • reshape the tests,

we are very happy to follow your guidance and update the PR.

Closes #2080

Thank you for considering this, and for the excellent library 🙏

Prevents derived_inert warnings and stale document pointerdown
listeners when a layer is torn down within the 1ms attach window
(afterSleep). Closes the race described in huntabyte#2080.
@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 99c2d8a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
bits-ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
bits-ui ✅ Ready (View Log) Visit Preview 99c2d8a

@leemthai

leemthai commented Aug 1, 2026

Copy link
Copy Markdown

Hit this too — confirmed independently in a Svelte 5 game project using Dialog. One interesting extra detail: the warnings genuinely compound over a long session — each subsequent click/focus event anywhere in the app seems to re-trigger every stale listener accumulated so far, so a single click later on can produce 15-20 warnings at once, not just one. Would be great to see #2087 merged - thanks for the fix!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DismissibleLayer: derived_inert warning — afterSleep(1) timer reads ref.current after the layer is destroyed

2 participants