Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Repository Guidelines

## Project Structure & Module Organization
- `src/`: TypeScript sources for the library (e.g., `index.ts`, `createConfirmation.ts`, `mounter/`).
- `__tests__/`: Jest tests, including TypeScript-specific tests in `__tests__/typescript/`.
- `dist/`: Compiled output published to npm (`index.js`, `index.d.ts`). Do not edit.
- Root configs: `tsconfig*.json`, `jest.config.js`, `package.json`.

## Build, Test, and Development Commands
- `npm run build`: Compile TypeScript to `dist/` using `tsc -p tsconfig.build.json`.
- `npm test`: Run Jest unit/integration tests in Node + JSDOM.
- `npm run test:types`: Run type-focused tests under `__tests__/typescript/`.
- `npm run typecheck`: Type-check the codebase without emitting files.
- `npm run clean`: Remove `dist/` before a fresh build.

Examples:
- Clean build: `npm run clean && npm run build`
- Pre-publish check (what CI does): `npm run clean && npm run build && npm test`

## Coding Style & Naming Conventions
- Language: TypeScript + React 18.
- Indentation: 2 spaces; prefer named exports; keep files small and focused.
- Naming: `camelCase` for variables/functions, `PascalCase` for React components and types (e.g., `ConfirmDialogProps`).
- Imports: relative within `src/`; the Jest alias maps `^src$` to `src/index.ts` for tests.

## Testing Guidelines
- Framework: Jest with `jest-environment-jsdom` and `ts-jest`.
- Location: Place tests in `__tests__/` mirroring `src/` structure; use `*.test.(ts|tsx|js)`.
- Coverage: Config collects from `src/**/*.{js,ts,tsx}`; aim to exercise both success and rejection paths for confirmations.
- Type tests: Add new cases under `__tests__/typescript/` when changing public types or generics.

## Commit & Pull Request Guidelines
- Commit style: Conventional Commits (e.g., `feat:`, `fix:`, `refactor:`, `chore:`). Keep messages imperative and scoped.
- PRs: Include a concise description, linked issue (if any), test coverage for behavior/typing, and screenshots or code snippets when UI behavior changes.
- CI expectations: PRs should pass build, unit tests, and type checks.

## Security & Compatibility
- Peer deps: This library targets React 18 (`react`, `react-dom`). Verify compatibility when upgrading.
- Public API: Changes to exported types or entries in `src/index.ts` are breaking; document in release notes.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,33 @@ const handleDelete = async (): Promise<void> => {
<button onClick={handleDelete}>Delete Item</button>
```

## Cancellation (Abort)

You can cancel a pending confirmation in two ways:

- Via AbortController (recommended)
```ts
import { abort, ContextAwareConfirmation } from 'react-confirm';

const confirmX = ContextAwareConfirmation.createConfirmation(confirmable(MyDialog));
const ac = new AbortController();
const p = confirmX({ message: 'Delete?' }, { signal: ac.signal });
// Later
ac.abort(); // p rejects with AbortError and dialog closes
```

- Via utility functions
```ts
import { abort, abortAll } from 'react-confirm';
const p = confirm({ message: 'Delete?' });
abort(p); // cancel one
abortAll(); // cancel all pending
```

Notes:
- `createConfirmation` runner supports an optional second argument `{ signal?: AbortSignal }`.
- Caught errors have `name === 'AbortError'` when cancelled.

## Using with React Context

If your dialog needs to access React Context (themes, authentication, etc.), use the context-aware approach:
Expand Down
45 changes: 45 additions & 0 deletions __tests__/abort.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import { confirmable, createConfirmation, abort, abortAll } from 'src';

describe('Cancellation (abort) behavior', () => {
const HangingDialog = ({ show }) => (show ? React.createElement('div', { 'data-testid': 'hanging' }) : null);
const ConfirmableHanging = confirmable(HangingDialog);

it('abort(promise) rejects with AbortError and unmounts', async () => {
const confirm = createConfirmation(ConfirmableHanging);
const p = confirm({});

const res = await Promise.race([
p.then(() => ({ status: 'fulfilled' })).catch((e) => ({ status: 'rejected', error: e })),
new Promise((resolve) => setTimeout(resolve, 0)).then(() => 'tick'),
]);
// ensure promise is pending before abort
expect(res).toBe('tick');

abort(p);

await expect(p).rejects.toMatchObject({ name: 'AbortError' });
});

it('abortAll() rejects all pending promises', async () => {
const confirm = createConfirmation(ConfirmableHanging);
const p1 = confirm({});
const p2 = confirm({});

abortAll();

const [r1, r2] = await Promise.allSettled([p1, p2]);
expect(r1.status).toBe('rejected');
expect(r2.status).toBe('rejected');
expect(r1).toMatchObject({ reason: expect.objectContaining({ name: 'AbortError' }) });
expect(r2).toMatchObject({ reason: expect.objectContaining({ name: 'AbortError' }) });
});

it('supports AbortSignal via control param', async () => {
const confirm = createConfirmation(ConfirmableHanging);
const ac = new AbortController();
const p = confirm({}, { signal: ac.signal });
ac.abort();
await expect(p).rejects.toMatchObject({ name: 'AbortError' });
});
});
73 changes: 73 additions & 0 deletions src/controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Lightweight registry to control pending confirmations
// Keeps only control handles (reject/dispose), not UI state

export type ConfirmationHandle = {
reject: (reason?: any) => void;
dispose: () => void;
settled?: boolean;
};

const active = new Map<Promise<unknown>, ConfirmationHandle>();

function createAbortError(reason?: any): any {
if (reason !== undefined) return reason;
try {
// Prefer DOMException if available (AbortError)
// eslint-disable-next-line no-new
return new (globalThis as any).DOMException('Aborted', 'AbortError');
} catch (_) {
const err = new Error('Aborted');
(err as any).name = 'AbortError';
return err;
}
}

export function register(promise: Promise<unknown>, handle: ConfirmationHandle): void {
active.set(promise, handle);
// Ensure cleanup after settlement
promise.finally(() => {
const h = active.get(promise);
if (h) h.settled = true;
active.delete(promise);
}).catch(() => { /* noop: handled by finally */ });
}

export function abort(promise: Promise<unknown>, reason?: any): boolean {
const handle = active.get(promise);
if (!handle || handle.settled) return false;
try {
handle.reject(createAbortError(reason));
} finally {
try { handle.dispose(); } catch (_) { /* ignore */ }
active.delete(promise);
}
return true;
}

export function abortAll(reason?: any): number {
const items = Array.from(active.entries());
let count = 0;
for (const [p, h] of items) {
if (h.settled) { active.delete(p); continue; }
try {
h.reject(createAbortError(reason));
} finally {
try { h.dispose(); } catch (_) { /* ignore */ }
active.delete(p);
count++;
}
}
return count;
}

export function attachAbortSignal(signal: AbortSignal, promise: Promise<unknown>): () => void {
const onAbort = () => { abort(promise, (signal as any).reason); };
if ((signal as any).aborted) {
// Fire synchronously if already aborted
onAbort();
return () => {};
}
signal.addEventListener('abort', onAbort, { once: true } as any);
return () => signal.removeEventListener('abort', onAbort);
}

46 changes: 31 additions & 15 deletions src/createConfirmation.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,52 @@
import * as React from 'react';
import { createDomTreeMounter } from './mounter/domTree';
import type { ConfirmableDialog, Mounter } from './types';
import { register, attachAbortSignal } from './controls';

export const createConfirmationCreater = (mounter: Mounter) => <P, R>(
Component: ConfirmableDialog<P, R>,
unmountDelay: number = 1000,
mountingNode?: HTMLElement
) => {
return (props: P): Promise<R> => {
return (props: P, control?: { signal?: AbortSignal }): Promise<R> => {
let mountId: string;
const promise = new Promise<R>((resolve, reject) => {
try {
mountId = mounter.mount(Component as React.ComponentType, { reject, resolve, dispose, ...props}, mountingNode)
} catch (e) {
console.error(e);
throw e;
}
})
let rejectRef: (reason?: any) => void = () => {};

function dispose() {
setTimeout(() => {
mounter.unmount(mountId);
}, unmountDelay);
}

return promise.then((result) => {
dispose();
return result;
}, (result) => {
dispose();
return Promise.reject(result);
const inner = new Promise<R>((resolve, reject) => {
rejectRef = reject;
try {
mountId = mounter.mount(
Component as React.ComponentType,
{ reject, resolve, dispose, ...props },
mountingNode
);
} catch (e) {
console.error(e);
throw e;
}
});

const wrapped = inner.then(
(result) => { dispose(); return result; },
(err) => { dispose(); return Promise.reject(err); }
);

// register for external cancellation
register(wrapped, { reject: rejectRef, dispose });

// Attach AbortSignal if provided
if (control?.signal) {
const detach = attachAbortSignal(control.signal, wrapped);
wrapped.finally(detach).catch(() => {});
}

return wrapped;
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import confirmable from './confirmable';
import createConfirmation, { createConfirmationCreater } from './createConfirmation';
import { createDomTreeMounter } from './mounter/domTree';
import { createReactTreeMounter, createMountPoint } from './mounter/reactTree';
import { abort as abortConfirmation, abortAll as abortAllConfirmations } from './controls';
import {
createConfirmationContext,
ContextAwareConfirmation
Expand All @@ -24,6 +25,8 @@ export {
createDomTreeMounter,
createReactTreeMounter,
createMountPoint,
abortConfirmation as abort,
abortAllConfirmations as abortAll,
createConfirmationContext,
ContextAwareConfirmation
};
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ export interface ConfirmationContext {
createConfirmation: <P, R>(
component: ConfirmableDialog<P, R>,
unmountDelay?: number
) => (props: P) => Promise<R>;
) => (props: P, control?: { signal?: AbortSignal }) => Promise<R>;

/**
* React component that must be rendered in your app to display confirmations
* Place this component at the root level of your app or where you want confirmations to appear
*/
ConfirmationRoot: React.ComponentType;
}
}