npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

close-watcher

v0.1.0

Published

Make the Android Back button close your modal instead of leaving the page. Wraps the CloseWatcher API, with a correct history fallback everywhere else.

Readme

close-watcher

Make the Android Back button close your modal instead of leaving the page.

npm version minzipped size license MIT types TypeScript GitHub

// Before: Android Back closes the tab, not the sheet. The user loses the page.
openSheet();

// After: Back, Esc and iOS swipe-back all close the sheet, and nothing else moves.
import { create } from 'close-watcher';
const watcher = create({ onClose: closeSheet });   // watcher.close() to close it yourself

Why this exists

Every web app with a bottom sheet, drawer or lightbox hits this on Android. The usual fixes are a pushState hack that traps the user on the page, or nothing at all. close-watcher uses the platform's own CloseWatcher API where it exists and a careful history fallback where it does not. Concretely, it handles:

  • The hardware Back button on Android, which otherwise navigates away from your app entirely.
  • iOS Safari's edge swipe-back, which arrives as the same popstate and gets the same treatment.
  • Escape on desktop — free on the native path, implemented on the fallback so both behave the same.
  • The double-fire bug. A programmatic close has to remove the history entry it added, and the resulting popstate then looks exactly like a Back press. Nearly every hand-rolled version fires onClose twice. This one guards the unwind and fires exactly once.
  • The back-trap bug. Re-pushing a history entry from inside popstate is how hand-rolled versions leave users unable to leave the page at all. This package never calls pushState from a popstate handler.
  • Forward navigation. Press Back to close the modal, then Forward, and a naive implementation either reopens a dead modal or closes the one underneath. Entries are stamped, so a forward traversal is a no-op.
  • Nested modals, via a real LIFO stack. One Back press closes one layer, and closing a modal in the middle of the stack removes only that one.
  • Unmount while open. destroy() releases the history entry without running onClose, so a React component that unmounts mid-animation does not write to state that is already gone.
  • Unsaved-changes guards, via onCancel returning false.
  • SSR. Importing the package in Node touches nothing; create() returns an inert watcher.
  • Cleanup. Listeners are bound once for the whole page and removed the moment the last watcher closes. Nothing is left behind — no nodes, no attributes, no styles, no timers.

No dependencies, no DOM ownership: the package decides when your close handler runs and never touches your markup.

Install

npm install close-watcher

Or straight from a CDN, which exposes the global CloseWatch:

<script src="https://unpkg.com/close-watcher"></script>
<script>
  const watcher = CloseWatch.create({ onClose: () => { sheet.hidden = true; } });
</script>

The global is CloseWatch, deliberately not CloseWatcher — that name belongs to the browser, and shadowing it would break the very API this package builds on.

Quick start

<button id="open">Open</button>

<div id="sheet" hidden>
  <p>Press Back or Esc.</p>
  <button id="done">Close</button>
</div>

<script src="https://unpkg.com/close-watcher"></script>
<script>
  const { create } = CloseWatch;   // or: import { create } from 'close-watcher';

  const sheet = document.getElementById('sheet');
  let watcher = null;

  document.getElementById('open').addEventListener('click', () => {
    sheet.hidden = false;
    // Created inside the click handler on purpose — see "One free watcher" below.
    watcher = create({
      onClose: () => {
        sheet.hidden = true;
        watcher = null;
      },
    });
  });

  document.getElementById('done').addEventListener('click', () => watcher?.close());
</script>

That is the whole integration. Back, Esc, swipe-back and your own Close button all end up in the same onClose.

React

import { useCloseWatcher } from 'close-watcher/react';

function Sheet({ open, onDismiss }: { open: boolean; onDismiss: () => void }) {
  useCloseWatcher({ enabled: open, onClose: onDismiss });

  if (!open) return null;
  return (
    <div className="sheet" role="dialog" aria-modal="true">
      <p>Press Back or Esc.</p>
      <button onClick={onDismiss}>Close</button>
    </div>
  );
}

The watcher is created when enabled flips to true and destroyed — silently, without running onClose — when it flips back or the component unmounts. onClose and onCancel are held in refs, so passing a fresh inline arrow on every render costs nothing and never churns a history entry.

With an unsaved-changes guard:

useCloseWatcher({
  enabled: open,
  onClose: () => setOpen(false),
  onCancel: () => (dirty ? window.confirm('Discard your changes?') : undefined),
});

API

create(options)

Creates one watcher — one modal, sheet, drawer, popover or lightbox. Returns a Watcher.

| Option | Type | Default | Description | | --- | --- | --- | --- | | onClose | () => void | required | Runs when the watcher closes: Back, Escape, closeTop(), or watcher.close(). Fires at most once per activation. | | onCancel | () => boolean \| void | – | Runs before a user-initiated close. Return false to veto it and keep the modal open. See the caveats in Gotchas. | | enabled | boolean | true | Pass false to create the watcher inert, then arm it later with watcher.enable(). |

Watcher

| Member | Type | Description | | --- | --- | --- | | close() | () => void | Close immediately and unconditionally. Runs onClose exactly once and unwinds the history entry. onCancel is not consulted. Safe to call twice. | | destroy() | () => void | Tear down without running onClose, releasing the history entry silently. Use this when the component unmounts while open. Permanent and idempotent. | | enable() | () => void | Arm a watcher created with enabled: false, or re-arm one that has closed. No-op once destroyed. | | disable() | () => void | Disarm silently, keeping the watcher reusable. | | active | boolean | true while armed and not yet closed. | | id | number | Process-unique, handed out in creation order. Useful in logs. |

Module functions

| Function | Returns | Description | | --- | --- | --- | | isSupported() | boolean | Whether the browser has the native CloseWatcher API. false means the history fallback is in use, not that the package is inert. false under SSR. | | isNative() | boolean | Alias of isSupported(), named for how it reads in a log line. | | closeTop() | boolean | Close the top-most open watcher the way Back or Escape would — onCancel is consulted and may veto. Returns whether one actually closed. | | activeCount() | number | How many watchers are armed and open across the page. | | createStack() | Stack | A fresh LIFO stack — the same pure structure the module uses internally to order nested modals. Exported so the nesting logic is testable with no DOM. |

Stack

| Member | Type | Description | | --- | --- | --- | | push(entry) | (entry: T) => void | Put an entry on top. Re-pushing an existing id moves it rather than duplicating it. | | pop() | () => T \| undefined | Remove and return the top entry. undefined when empty. | | remove(id) | (id: number) => T \| undefined | Remove an entry from anywhere in the stack; survivors keep their order. | | top() | () => T \| undefined | Peek at the top entry. | | size() | () => number | Entry count. | | toArray() | () => T[] | A copy of the entries, bottom-first. |

Exported types

WatcherOptions, Watcher, Stack, StackEntry, and from close-watcher/react, UseCloseWatcherOptions.

Default export

A default export object carries the same members, so the CDN global reads naturally: CloseWatch.create({ … }), CloseWatch.closeTop().

Browser support

| Browser | Strategy | Hardware Back / swipe-back | Escape | onCancel veto | | --- | --- | --- | --- | --- | | Chrome / Edge 120+ (desktop & Android) | native CloseWatcher | yes | yes, from the browser | yes, while the page has user activation | | Chrome 89–119 | history fallback | yes | yes, handled by this package | on Escape and closeTop() only | | Safari 15+ (macOS & iOS) | history fallback | yes, including edge swipe-back | yes, handled by this package | on Escape and closeTop() only | | Firefox | history fallback | yes | yes, handled by this package | on Escape and closeTop() only | | Node / SSR | none | n/a | n/a | n/a |

Under SSR the module imports cleanly, isSupported() is false, and create() hands back an inert watcher whose methods are safe no-ops. Arm it after hydration with enable() if you need to.

The one thing the fallback genuinely cannot do is veto a hardware Back press: by the time your code runs the navigation has already happened, and the only way to undo it is to push a replacement entry — which is the back-trap this package exists to avoid. On Chrome, where CloseWatcher is native, the veto works for Back too.

Recipes

Confirm before discarding

const watcher = create({
  onClose: () => closeEditor(),
  onCancel: () => (isDirty() ? confirm('Discard your changes?') : undefined),
});

Returning false keeps the modal open. confirm() already returns a boolean, so this reads as "close only if they said yes".

Nested sheets, closed one layer at a time

const outer = create({ onClose: () => hide(outerSheet) });
const inner = create({ onClose: () => hide(innerSheet) });

activeCount();  // 2
closeTop();     // closes `inner` only, returns true
closeTop();     // closes `outer`, returns true
closeTop();     // nothing left, returns false

Closing a modal in the middle of the stack removes only that one — the ordering of the rest survives.

An Escape-and-Back-aware <dialog>

<dialog> already handles Escape itself, so the package deliberately ignores an Escape whose target is inside an open dialog. Wire the Back button to the dialog's own close instead:

dialog.showModal();
const watcher = create({ onClose: () => dialog.close() });
dialog.addEventListener('close', () => watcher.destroy(), { once: true });

destroy() rather than close() in the listener, so a close that came from the watcher does not loop back through onClose.

Closing everything on route change

router.beforeEach(() => {
  while (closeTop()) {
    /* unwind the modal stack before navigating */
  }
});

Gotchas

Read this section. Every item is something the platform does, not something the package chose.

  • One free watcher per page. Without transient user activation a page gets exactly one standalone close watcher; every additional one is grouped by the platform, so a single Back press closes all of them at once. Create the watcher inside the click handler that opens the modal — the activation is spent, and each layer stays independent. This is a platform rule and the package does not try to defeat it. It only affects the native path.
  • A Back press cannot be vetoed on the fallback path. onCancel applies to Escape and closeTop() everywhere, and to Back as well on Chrome's native path. On Safari and Firefox the navigation has already happened by the time your code runs. If you need a hard guard there, confirm before the modal can be dismissed rather than after.
  • history.length does not shrink. Closing a modal traverses back over the disposable entry, but no web API deletes a forward entry. The next pushState overwrites it, so the ceiling is one spare entry no matter how many modals you open — but history.length will read one higher than it did before the first modal, and that is expected.
  • close() ignores onCancel by design. It is the unconditional escape hatch for your own "Close" button. Use closeTop() when you want the cancelable behaviour.
  • The URL never changes. The fallback calls pushState with no URL argument, so the address bar stays exactly as it was. Any state your router had on the entry is preserved and carried forward; the package only adds a __cw key alongside it.
  • destroy() is not close(). destroy() never runs onClose. If you call it from inside your own close routine, remember the modal will not hide itself.
  • A single Back press closes one layer. If the user long-presses Back and jumps several entries at once, every watcher above the landing point is closed, top-most first.
  • Safari throttles pushState. Roughly 100 calls per 30 seconds. Past that the entry is not claimed, the failure is surfaced as an uncaught error rather than swallowed, and Escape still works while Back does not. You will not hit this opening modals at human speed.

Links

Contributing

git clone https://github.com/vishalmeena2211/close-watcher.git
cd close-watcher
npm install
npm run dev      # tsup in watch mode
npm test         # builds, then runs the node:test suite

demo/index.html is both the demo and the end-to-end test: serve the package directory over a local HTTP server (npx serve .) and open it. It runs its own checks on load, renders them on the page, and publishes them on window.__E2E__ — including a section that temporarily removes window.CloseWatcher so the history fallback is exercised for real in a browser that has the native API. Anything needing a real gesture is reported as an honest skip, never a fake pass.

License

MIT © Vishal Meena