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.
Maintainers
Readme
close-watcher
Make the Android Back button close your modal instead of leaving the page.
// 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 yourselfWhy 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
popstateand gets the same treatment. Escapeon 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
popstatethen looks exactly like a Back press. Nearly every hand-rolled version firesonClosetwice. This one guards the unwind and fires exactly once. - The back-trap bug. Re-pushing a history entry from inside
popstateis how hand-rolled versions leave users unable to leave the page at all. This package never callspushStatefrom apopstatehandler. - 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 runningonClose, so a React component that unmounts mid-animation does not write to state that is already gone. - Unsaved-changes guards, via
onCancelreturningfalse. - 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-watcherOr 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 notCloseWatcher— 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 falseClosing 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.
onCancelapplies toEscapeandcloseTop()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.lengthdoes not shrink. Closing a modal traverses back over the disposable entry, but no web API deletes a forward entry. The nextpushStateoverwrites it, so the ceiling is one spare entry no matter how many modals you open — buthistory.lengthwill read one higher than it did before the first modal, and that is expected.close()ignoresonCancelby design. It is the unconditional escape hatch for your own "Close" button. UsecloseTop()when you want the cancelable behaviour.- The URL never changes. The fallback calls
pushStatewith 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__cwkey alongside it. destroy()is notclose().destroy()never runsonClose. 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, andEscapestill 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 suitedemo/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
