@lucidaquarian/alert-queue
v1.0.0
Published
Serialized alert queue with source deduplication for Ionic, Capacitor, and Cordova
Maintainers
Readme
@lucidaquarian/alert-queue
Serialized alert queue with source deduplication for Ionic, Capacitor, and Cordova.
A lightweight, framework-agnostic queue and lifecycle manager for alert dialogs. It guarantees that only one alert is visible at a time, serializes pending alerts by priority, and deduplicates by source so the same feature never stacks duplicate dialogs.
It is not a UI library — rendering is fully delegated to the platform
(@ionic/core's alertController, Capacitor's Dialog, or Cordova's
navigator.notification). The plugin only owns the queue, the state machine,
and the lifecycle.
- ✅ Framework-agnostic core (Ionic Angular / React / Vue, Capacitor, Cordova)
- ✅ Priority queue + single-alert mutex
- ✅ Source deduplication (
drop-new/replace) - ✅ Per-alert and global lifecycle hooks
- ✅ Auto timeouts, programmatic dismissal, source-scoped dismissal
- ✅ Pluggable rendering — bring your own popup UI via a tiny adapter
- ✅ Full TypeScript types; ESM + CJS + IIFE builds
- ✅ Zero runtime dependencies
Installation
npm install @lucidaquarian/alert-queueRequirements: Node 16+ at runtime (zero runtime dependencies). The dev toolchain (Vitest) needs Node 18+; a dependency-free Node 16 runtime smoke test (
npm run smoke) runs in CI to guarantee the published bundle works on Node 16.
Then install whichever platform package you actually use (all are optional peer dependencies — install only what you need):
# Ionic (any framework)
npm install @ionic/core
# Capacitor native dialogs
npm install @capacitor/core @capacitor/dialog
# Cordova native dialogs
cordova plugin add cordova-plugin-dialogs
# Angular DI integration (optional)
npm install @angular/coreQuick start
import { AlertQueue } from '@lucidaquarian/alert-queue';
const result = await AlertQueue.show({
title: 'Session expired',
message: 'Please log in again.',
buttons: [
{ text: 'Cancel', role: 'cancel' },
{ text: 'Log in', role: 'confirm' },
],
});
if (result.status === 'confirmed') {
router.navigate(['/login']);
}The platform is auto-detected on first use — no configuration required.
Convenience helpers
For the common cases, skip the verbose show({...}):
await AlertQueue.alert('Saved', 'Your changes are live.');
const ok = await AlertQueue.confirm('Delete file?', 'This cannot be undone.');
if (ok) { /* ... */ }
const name = await AlertQueue.prompt('Your name?', undefined, { placeholder: 'Ada' });
if (name !== null) { /* entered text (may be '') */ }All three accept an options object with any AlertConfig field (sourceId,
priority, timeout, cooldown, hooks, …) plus okText / cancelText (and
placeholder / defaultValue / inputType for prompt). The same helpers are
available on the Angular AlertQueueService.
Screenshots
Real Ionic dialogs rendered by the library (captured by the Playwright e2e
suite in e2e/):
| Two-button alert | Destructive multi-button alert |
|---|---|
|
|
|
The plugin manages the queue and lifecycle; the visual styling comes from the platform (here,
@ionic/corein Material mode).
Platform support
| Capability | Ionic (@ionic/core) | Capacitor (@capacitor/dialog) | Cordova (cordova-plugin-dialogs) |
|---|:---:|:---:|:---:|
| Show / queue / priority / dedup | ✅ | ✅ | ✅ |
| Multi-button alerts | ✅ | ⚠️ confirm + cancel only | ✅ |
| Per-button handler fires on tap | ✅ | ✅ | ✅ |
| handler returns false to keep open | ✅ | ❌ (OS-closed) | ❌ (OS-closed) |
| Inputs / prompts | ✅ all input types | ⚠️ single text input | ⚠️ single text input |
| cssClass, backdrop dismiss | ✅ | ❌ (OS-styled) | ❌ (OS-styled) |
| confirmed / cancelled results | ✅ | ✅ | ✅ |
| dismissed (backdrop / programmatic) | ✅ | ❌ | ⚠️ back-button only |
| timeout auto-dismiss | ✅ | ❌ | ❌ |
| dismiss() / dismissAll() / dismissSource() (active alert) | ✅ | ❌ | ❌ |
| replace policy on the active alert | ✅ | ❌ | ❌ |
Why the ❌s on native? Capacitor's
Dialogand Cordova'snavigator.notificationare OS-managed and expose no programmatic dismiss API. So anything that needs to remove an already-visible dialog —timeout,replaceon the active alert, and thedismiss*()methods — cannot affect a native dialog that is already on screen. Queued (not-yet-shown) alerts are still fully controllable on every platform.AlertHandle.canDismissreflects this at runtime.
API reference
All methods are static on AlertQueue.
| Method | Signature | Description |
|---|---|---|
| configure | (options: GlobalConfig) => void | Set global defaults + hooks. Merges across calls (later wins). |
| init | () => void | Auto-detect platform & cache the adapter. Called lazily on first show(). |
| show | (config: AlertConfig) => Promise<AlertResult> | Enqueue an alert; resolves when it finally settles. |
| alert | (title, message?, options?) => Promise<AlertResult> | Shortcut: single-OK alert. See Convenience helpers. |
| confirm | (title, message?, options?) => Promise<boolean> | Shortcut: Cancel/OK; resolves true when confirmed. |
| prompt | (title, message?, options?) => Promise<string \| null> | Shortcut: single input; resolves the value or null. |
| dismiss | (id?: string) => Promise<void> | Dismiss a specific queued/active alert, or the current one. |
| dismissAll | () => Promise<void> | Clear the queue and dismiss the active alert. |
| dismissSource | (sourceId: string) => Promise<void> | Dismiss every alert from a source. |
| hasPendingSource | (sourceId: string) => boolean | Whether a source is currently active or queued. |
| onDismiss | (handler: (r: AlertResult) => void) => () => void | Subscribe to every dismissal. Returns an unsubscribe fn. |
| useAdapter | (adapter: IAlertAdapter) => void | Replace the renderer with your own popup UI. See Custom UI. |
| reset | () => void | Testing: clear all state. |
AlertConfig
| Field | Type | Default | Notes |
|---|---|---|---|
| title | string | — | Required. |
| message | string | — | |
| buttons | AlertButton[] | [{ text: 'OK', role: 'confirm' }] | |
| inputs | AlertInput[] | — | Prompt-style input fields. Ionic: all types; Capacitor/Cordova: single text input. |
| priority | number | 0 | Higher is shown sooner. |
| timeout | number | — | Auto-dismiss after N ms (Ionic only). |
| dismissible | boolean | true | Backdrop tap closes (Ionic only). |
| sourceId | string | — | Enables deduplication. |
| sourcePolicy | 'drop-new' \| 'replace' | 'drop-new' | Behaviour when the source is already active. |
| cooldown | number | — | After dismissal, suppress new same-sourceId alerts for N ms. |
| cssClass | string \| string[] | — | Ionic only. |
| id | string | auto UUID | |
| beforeShow / afterShow | (config) => void \| Promise | — | Per-alert hooks. |
| beforeDismiss / afterDismiss | (result) => void \| Promise | — | Per-alert hooks. |
| onDeduplicated | (existingId) => void | — | Fires when suppressed by drop-new. |
AlertResult
interface AlertResult {
status: 'confirmed' | 'cancelled' | 'timeout' | 'dismissed' | 'deduplicated';
action?: string; // tapped button text, when known
role?: string; // tapped button role, when known
existingId?: string; // only when status === 'deduplicated'
values?: Record<string, unknown>; // entered input values, when the alert had `inputs`
}Inputs & prompts
Add inputs to collect input. Entered values come back on AlertResult.values,
keyed by each input's name:
const r = await AlertQueue.show({
title: 'Rename project',
inputs: [
{ name: 'title', type: 'text', placeholder: 'New name', value: currentName },
],
buttons: [
{ text: 'Cancel', role: 'cancel' },
{ text: 'Save', role: 'confirm' },
],
});
if (r.status === 'confirmed') {
await rename(r.values?.title as string);
}Ionic supports every input type (text, number, password, textarea,
checkbox, radio, …). Capacitor and Cordova only have a native single-text
prompt, so they use the first input and ignore the rest (see the support
matrix).
Source deduplication
Each alert may carry a sourceId. While an alert from that source is active or
queued, the gate is closed for that source.
| Scenario | Behaviour |
|---|---|
| First alert from source X | Admitted; source registered. |
| Second alert from X (drop-new, default) | Suppressed → resolves { status: 'deduplicated', existingId }. |
| Second alert from X (replace) | The existing alert is dismissed; the new one is shown. |
| Alert from X dismissed (any way) | Source freed; the gate re-opens. |
| Alert with no sourceId | No dedup; queued normally. |
Cooldown — after a sourced alert is dismissed, cooldown keeps its gate
closed for a while so flaky errors can't immediately re-nag:
// A dismissed "network-error" won't reappear for 30s, even after it closes.
await AlertQueue.show({
title: 'Network error',
sourceId: 'network-error',
cooldown: 30_000,
});Suppressed alerts (active-source dedup or cooldown) resolve { status: 'deduplicated' }.
Set a fleet-wide default with configure({ defaultCooldown }).
// Fires repeatedly, but only one dialog ever appears:
await AlertQueue.show({
title: 'Payment failed',
message: 'Please check your card details.',
sourceId: 'payment-error',
priority: 5,
onDeduplicated: (existingId) => console.log(`suppressed; active: ${existingId}`),
});
// Always show the latest instead:
await AlertQueue.show({
title: 'Network error',
message: `Retry attempt ${attempt}`,
sourceId: 'network-error',
sourcePolicy: 'replace',
});
if (AlertQueue.hasPendingSource('checkout-flow')) {
await AlertQueue.dismissSource('checkout-flow');
}Button actions
Run code when a specific button is tapped in one of two ways.
Per-button handler — fires on tap, on all platforms:
await AlertQueue.show({
title: 'Delete file?',
buttons: [
{ text: 'Cancel', role: 'cancel', handler: () => console.log('cancelled') },
{ text: 'Delete', role: 'destructive', handler: async () => { await deleteFile(); } },
],
});Returning false from a handler keeps the dialog open (Ionic only — native
dialogs are already closed by the time their result arrives).
Branch on the awaited result — platform-agnostic; inspect which button won:
const r = await AlertQueue.show({ title: 'Save changes?', buttons: [
{ text: 'Discard', role: 'cancel' },
{ text: 'Save', role: 'confirm' },
]});
// r.status -> 'confirmed' | 'cancelled' | 'dismissed' | 'timeout' | ...
// r.role -> 'confirm' | 'cancel' | 'destructive'
// r.action -> tapped button text, e.g. 'Save'Lifecycle hooks
For each alert that runs the full flow, hooks fire in this order — global first, then per-alert:
beforeShow → [present] → afterShow → [user acts / timeout] → beforeDismiss → afterDismiss → onDismiss / onQueueChangeafterShowfires once the dialog is actually visible (the adapter resolvespresent()on the visible signal, not on dismissal).- If
beforeShowthrows, the alert is cancelled, itsshow()promise rejects, and the queue advances to the next alert. afterShow/beforeDismiss/afterDismissare observe-only and error-isolated — a throwing hook can never deadlock the queue.
⚠️ Note:
beforeDismisscannot block a dismissal. By the time it runs the dialog has already closed (native dialogs especially are gone the moment the user acts). Use it for cleanup/telemetry, not veto logic.
AlertQueue.configure({
defaultDismissible: true,
defaultSourcePolicy: 'drop-new',
maxQueue: 20, // cap pending alerts; overflow: 'drop-new' (default) or 'drop-oldest'
beforeShow: (config) => analytics.track('alert_shown', { title: config.title }),
afterDismiss: (result) => analytics.track('alert_dismissed', { status: result.status }),
onQueueChange: (length) => badgeService.update(length),
});Custom UI — bring your own popup
The library is UI-agnostic: the queue, deduplication, priority, timeouts,
and lifecycle hooks all live in a core that talks to a small adapter interface.
The built-in adapters render with Ionic / Capacitor / Cordova, but you can plug
in any popup UI (your own modal component, a design-system dialog, a toast,
etc.) by implementing IAlertAdapter and registering it with useAdapter().
interface IAlertAdapter {
// Resolve once YOUR popup is visible. Deliver the outcome via onResult.
present(config: PreparedAlert, onResult: AlertResultCallback): Promise<AlertHandle>;
}
interface AlertHandle {
id: string;
canDismiss: boolean; // can you close it programmatically?
dismiss(): Promise<void>; // close it (used by timeout / replace / dismiss*())
}PreparedAlert is your AlertConfig with defaults already applied and an id
assigned — read title, message, buttons, dismissible, cssClass, etc.
Three rules to honor
- Resolve
present()when the popup is on screen (soafterShowand timeouts fire at the right moment). - Call
onResultexactly once with the outcome (confirmed/cancelled/dismissed/ ...). dismiss()should close the popup and fireonResultsotimeout, thereplacepolicy, anddismiss*()work. If your UI can't be closed programmatically, setcanDismiss: falseand the queue adapts.
Example
import {
AlertQueue,
type IAlertAdapter,
type AlertHandle,
type PreparedAlert,
} from '@lucidaquarian/alert-queue';
const myAdapter: IAlertAdapter = {
async present(config: PreparedAlert, onResult): Promise<AlertHandle> {
const buttons = config.buttons ?? [{ text: 'OK', role: 'confirm' }];
const modal = MyModal.open({
title: config.title,
message: config.message,
buttons,
onButton: (btn) =>
onResult({
status: btn.role === 'cancel' ? 'cancelled' : 'confirmed',
action: btn.text,
role: btn.role,
}),
onBackdrop: () => onResult({ status: 'dismissed' }),
});
// present() resolves when the modal is visible:
return {
id: config.id,
canDismiss: true,
dismiss: async () => {
modal.close();
onResult({ status: 'dismissed' });
},
};
},
};
// Register BEFORE the first show() — this bypasses platform auto-detection.
AlertQueue.useAdapter(myAdapter);
// From here, every AlertQueue.show(...) renders your popup, fully queued,
// deduplicated, and lifecycle-managed.
await AlertQueue.show({ title: 'Saved', message: 'Your changes are live.' });Usage with Angular
Import from the @lucidaquarian/alert-queue/angular subpath so non-Angular consumers
never pull in @angular/core.
Standalone (Angular 15+):
import { bootstrapApplication } from '@angular/platform-browser';
import { provideAlertQueue } from '@lucidaquarian/alert-queue/angular';
bootstrapApplication(AppComponent, {
providers: [
provideAlertQueue({
defaultDismissible: true,
onQueueChange: (n) => console.log(`Queue length: ${n}`),
}),
],
});NgModule:
import { AlertQueueModule } from '@lucidaquarian/alert-queue/angular';
@NgModule({
imports: [AlertQueueModule.forRoot({ defaultPriority: 0 })],
})
export class AppModule {}Inject the service:
import { AlertQueueService } from '@lucidaquarian/alert-queue/angular';
@Component({ /* ... */ })
export class MyComponent {
constructor(private alerts: AlertQueueService) {}
async confirm() {
const r = await this.alerts.show({ title: 'Delete?', buttons: [
{ text: 'Cancel', role: 'cancel' },
{ text: 'Delete', role: 'destructive' },
]});
if (r.status === 'confirmed') { /* ... */ }
}
}Usage with Capacitor (no Angular)
import { AlertQueue } from '@lucidaquarian/alert-queue';
// Ensure @capacitor/core and @capacitor/dialog are installed.
await AlertQueue.show({
title: 'Update available',
message: 'A new version is ready.',
buttons: [
{ text: 'Later', role: 'cancel' },
{ text: 'Update', role: 'confirm' },
],
});On Capacitor web, isNativePlatform() is false, so the Ionic adapter is
used (requires @ionic/core). On native iOS/Android the Capacitor Dialog
plugin is used.
Usage with Cordova
Include the IIFE bundle and the dialogs plugin:
<script src="cordova.js"></script>
<script src="node_modules/@lucidaquarian/alert-queue/dist/umd/alert-queue.global.js"></script>
<script>
document.addEventListener('deviceready', async () => {
const result = await AlertQueueLib.AlertQueue.show({
title: 'Confirm',
message: 'Proceed?',
buttons: [
{ text: 'No', role: 'cancel' },
{ text: 'Yes', role: 'confirm' },
],
});
console.log(result.status);
});
</script>Migration from ad-hoc AlertController
// Before — alerts can stack, duplicates appear, no serialization:
const alert = await this.alertController.create({
header: 'Payment failed',
message: 'Try again.',
buttons: ['OK'],
});
await alert.present();
// After — queued, deduplicated, awaited:
const result = await AlertQueue.show({
title: 'Payment failed',
message: 'Try again.',
sourceId: 'payment-error',
});Map header → title, backdropDismiss → dismissible, and add a sourceId
to gain deduplication for free.
Builds
| Output | Path | For |
|---|---|---|
| ESM | dist/index.mjs | Bundlers, Ionic/Angular/Capacitor |
| CJS | dist/index.cjs | Node / CommonJS |
| IIFE | dist/umd/alert-queue.global.js | Cordova / <script> (global AlertQueueLib) |
| Types | dist/index.d.ts, dist/angular/index.d.ts | TypeScript |
Contributing
npm install
npm run typecheck # tsc --noEmit
npm test # vitest — 90 unit + integration tests
npm run test:coverage # vitest + v8 coverage report (~93%)
npm run test:e2e # Playwright — renders real Ionic dialogs in Chromium
npm run build # tsup -> dist/CI runs typecheck, test, build, and the Playwright e2e suite on every push
and PR across Node 18 / 20 / 22. A full verification snapshot lives in
docs/TEST-REPORT.md.
PRs welcome. Please keep the core framework-agnostic and add tests for new behaviour.
License
MIT © thelucidaquarian
