Skip to content

Commit f37167b

Browse files
committed
feat: connectivityProbe + Playwright e2e across Chromium/Firefox/WebKit
Round 3 audit caught two false claims in the README plus a public API leak. Rather than rewrite the README to be more modest, make the claims real. connectivityProbe — captive-portal detection: - New FormDraftOptions.connectivityProbe?: () => Promise<boolean> - syncQueue calls it before each sync attempt. Falsy/throwing → defer without incrementing attempt count. Retry triggers on next online/visibilitychange event or user save(). - Read via ref so the user's probe identity can change without recreating the queue. - README "actual fetch determines success" rewritten as concrete docs + a working pattern using fetch('/api/ping', { method:'HEAD' }). Playwright e2e suite across 3 browser engines: - playwright.config.ts with chromium / firefox / webkit projects. - tests/e2e/headline.spec.ts: 7 headline scenarios × 3 browsers = 21 e2e tests, all green locally. Covers persist/restore, password excludeFields, discard race, offline queue + online flush, rapid set+discard+set, submit clears storage, multi-tab submit broadcast. - The "iOS Safari multi-tab and offline tested" README claim is now actually true — WebKit is the iOS Safari engine. - CI gains a parallel e2e job with --with-deps browser install and failure-only artifact upload. _resetIndexedDBForTests no longer leaks into public API: - Refactored indexedDBAdapter to hold dbPromise inside the closure instead of at module scope. Each call yields an independent adapter; tests get a clean slate by just calling indexedDBAdapter() again. No more publicly-exported test escape hatch. README factual sweep: - "74 unit tests" → "94 unit tests + 21 Playwright e2e" - "~3.4 KB brotli" → "~3.85 KB brotli" - "v0.1.0-rc.0" → "v0.1.0-rc.1" (status bullet) - "iOS Safari: multi-tab and offline tested" → concrete WebKit e2e list - Documented useFormDraftStatus (previously exported but absent from docs). - Added Captive portal section with the recommended probe pattern. Tests: 92 → 94 unit, +21 e2e. Bundle 3.94 KB brotli (8 KB gate).
1 parent 2284ce4 commit f37167b

13 files changed

Lines changed: 367 additions & 47 deletions

File tree

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,22 @@ jobs:
1717
- run: npm test -- --reporter=verbose
1818
- run: npm run build
1919
- run: npx size-limit
20+
21+
e2e:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: actions/setup-node@v4
26+
with: { node-version: '20', cache: 'npm' }
27+
- run: npm ci
28+
- run: cd example && npm ci
29+
- run: npm run build
30+
- name: Install Playwright browsers
31+
run: npx playwright install --with-deps chromium firefox webkit
32+
- run: npm run test:e2e
33+
- uses: actions/upload-artifact@v4
34+
if: failure()
35+
with:
36+
name: playwright-report
37+
path: playwright-report/
38+
retention-days: 7

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ coverage/
88
example/node_modules/
99
example/dist/
1010
.size-limit-cache/
11+
playwright-report/
12+
test-results/
13+
playwright/.cache/

README.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![license](https://img.shields.io/npm/l/formdraft)](LICENSE)
88
[![zero deps](https://img.shields.io/badge/runtime%20deps-0-success)](#zero-runtime-dependencies)
99

10-
> ⚠️ **v0.1.0-rc.1 (release candidate).** Code-complete with 74 unit tests. Looking for production feedback before v0.1.0 stable. Try it, report bugs at https://github.com/mayrang/formdraft/issues.
10+
> ⚠️ **v0.1.0-rc.2 (release candidate).** Code-complete with 94 unit tests + 21 Playwright e2e tests (Chromium / Firefox / WebKit). Looking for production feedback before v0.1.0 stable. Try it, report bugs at https://github.com/mayrang/formdraft/issues.
1111
1212
![demo](docs/assets/demo.gif)
1313

@@ -76,7 +76,7 @@ return <form>{/* form.register, etc. */}<span>{status}</span></form>;
7676
| Refresh loses 20 minutes of typing | localStorage persist + mount restore |
7777
| Server save fails silently | retry queue with exponential backoff |
7878
| Offline write then reconnect | `online` + `visibilitychange` flush |
79-
| Captive portal (`onLine=true` but no internet) | actual fetch determines success |
79+
| Captive portal (`onLine=true` but no internet) | pluggable `connectivityProbe` HEAD-checks a known URL before each sync |
8080
| Two tabs editing same draft | BroadcastChannel + `multiTab='warn'` |
8181
| Tab A submits, Tab B keeps stale draft | submit broadcast → all tabs discard |
8282
| Component unmounts mid-sync | guarded; no setState-on-unmounted warnings |
@@ -113,7 +113,23 @@ import { localStorageAdapter, sessionStorageAdapter, indexedDBAdapter } from 'fo
113113
useFormDraft({ ..., storage: indexedDBAdapter() }); // for big forms
114114
```
115115

116-
Or write your own:
116+
## Captive portal handling
117+
118+
`navigator.onLine === true` lies on captive portals (hotel WiFi, etc.). Pass a probe to HEAD-check a known reachable URL before each sync:
119+
120+
```tsx
121+
useFormDraft({
122+
// ...
123+
connectivityProbe: () =>
124+
fetch('/api/ping', { method: 'HEAD' })
125+
.then((r) => r.ok)
126+
.catch(() => false),
127+
});
128+
```
129+
130+
When the probe returns `false`, the sync is deferred (not counted as a retry). It re-attempts on the next `online`/`visibilitychange` event, or when `save()` is called.
131+
132+
## Custom storage adapter
117133

118134
```tsx
119135
const customAdapter: StorageAdapter = {
@@ -124,6 +140,21 @@ const customAdapter: StorageAdapter = {
124140
};
125141
```
126142

143+
## Reading status from anywhere in the tree
144+
145+
You don't always want to drill the `draft` object down to a deep child just to render a "Saving…" pill in a corner. Subscribe to any active `useFormDraft` instance by its `key`:
146+
147+
```tsx
148+
import { useFormDraftStatus } from 'formdraft';
149+
150+
function SavingIndicator() {
151+
const { status, lastSavedAt } = useFormDraftStatus('profile-form');
152+
return <span>{status === 'saved' ? `Saved ${lastSavedAt?.toLocaleTimeString()}` : status}</span>;
153+
}
154+
```
155+
156+
Backed by `useSyncExternalStore`; SSR-safe (renders `idle` on the server).
157+
127158
## Multi-tab strategies
128159

129160
| Strategy | What happens on remote change |
@@ -189,11 +220,11 @@ A: No. formdraft uses BroadcastChannel, navigator.onLine, IndexedDB — all brow
189220

190221
## Status
191222

192-
- **v0.1.0-rc.0** on [npm](https://www.npmjs.com/package/formdraft) (RC — looking for production feedback)
193-
- 74 unit tests across 14 modules
194-
- **~3.4 KB brotli** (8 KB CI gate)
223+
- **v0.1.0-rc.1** on [npm](https://www.npmjs.com/package/formdraft) (RC — looking for production feedback)
224+
- 94 unit tests + 21 Playwright e2e (7 headline scenarios × Chromium / Firefox / WebKit)
225+
- **~3.85 KB brotli** (8 KB CI gate)
195226
- React 18+; Browser support Chrome/Edge 88+, Firefox 78+, Safari 15.4+
196-
- iOS Safari: multi-tab and offline tested
227+
- WebKit (iOS Safari engine): persist, restore, discard race, offline queue, submit broadcast all verified e2e
197228
- 0 runtime dependencies
198229

199230
## License

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"typecheck": "tsc --noEmit",
4040
"lint": "eslint src --ext .ts,.tsx",
4141
"size": "size-limit",
42+
"test:e2e": "playwright test",
4243
"prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build && npm run size"
4344
},
4445
"peerDependencies": {
@@ -55,6 +56,7 @@
5556
}
5657
},
5758
"devDependencies": {
59+
"@playwright/test": "^1.60.0",
5860
"@size-limit/preset-small-lib": "^11.0.0",
5961
"@testing-library/react": "^16.0.0",
6062
"@types/node": "^20.19.41",

playwright.config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/e2e',
5+
fullyParallel: false,
6+
retries: process.env.CI ? 1 : 0,
7+
workers: 1,
8+
reporter: process.env.CI ? [['github'], ['list']] : 'list',
9+
use: {
10+
baseURL: 'http://localhost:5173',
11+
trace: 'on-first-retry',
12+
headless: true,
13+
},
14+
projects: [
15+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
16+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
17+
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
18+
],
19+
webServer: {
20+
command: 'cd example && npx vite --port 5173',
21+
url: 'http://localhost:5173',
22+
reuseExistingServer: !process.env.CI,
23+
timeout: 60_000,
24+
},
25+
});

src/internal/__tests__/syncQueue.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,41 @@ describe('createSyncQueue', () => {
164164
expect(onAbandoned).toHaveBeenCalledWith(expect.any(Error), { x: 1 });
165165
});
166166

167+
it('connectivityProbe returning false defers sync without counting as a failure', async () => {
168+
// Captive portal: navigator.onLine is true but real reachability is dead.
169+
// Probe returns false → attempt is skipped, attempt counter not incremented,
170+
// pendingValues stays so a later online/visibility event can retry.
171+
const sync = vi.fn().mockResolvedValue(undefined);
172+
let reachable = false;
173+
const q = createSyncQueue({
174+
sync,
175+
retry: { maxAttempts: 2, initialBackoffMs: 100, multiplier: 2, maxBackoffMs: 1000 },
176+
connectivityProbe: () => Promise.resolve(reachable),
177+
});
178+
q.enqueue({ x: 1 });
179+
await vi.runAllTimersAsync();
180+
expect(sync).not.toHaveBeenCalled();
181+
expect(q.pending()).toBe(true); // value still queued
182+
// Reachability returns — next online event triggers schedule(0)
183+
reachable = true;
184+
window.dispatchEvent(new Event('online'));
185+
await vi.runAllTimersAsync();
186+
expect(sync).toHaveBeenCalledWith({ x: 1 });
187+
});
188+
189+
it('connectivityProbe throwing is treated as not-reachable', async () => {
190+
const sync = vi.fn().mockResolvedValue(undefined);
191+
const q = createSyncQueue({
192+
sync,
193+
retry: { maxAttempts: 2, initialBackoffMs: 100, multiplier: 2, maxBackoffMs: 1000 },
194+
connectivityProbe: () => Promise.reject(new Error('probe blew up')),
195+
});
196+
q.enqueue({ x: 1 });
197+
await vi.runAllTimersAsync();
198+
expect(sync).not.toHaveBeenCalled();
199+
expect(q.pending()).toBe(true);
200+
});
201+
167202
it('exposes pending() to inspect whether something is queued', async () => {
168203
setOnline(false);
169204
const sync = vi.fn().mockResolvedValue(undefined);

src/internal/syncQueue.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export type SyncQueueOptions<T> = {
99
// pending value is being dropped. Caller's last chance to surface the
1010
// failure (status pill, toast, manual retry button, etc.).
1111
onAbandoned?: (lastError: Error, values: T) => void;
12+
// Optional probe to detect captive portals and other lying-network states
13+
// where navigator.onLine === true but actual reachability fails. Called
14+
// before each sync attempt. Return false to defer; the queue retries on
15+
// the next online event or via the user's own save() call. Throwing is
16+
// treated the same as returning false.
17+
connectivityProbe?: () => Promise<boolean>;
1218
};
1319

1420
export type SyncQueue<T> = {
@@ -53,6 +59,20 @@ export function createSyncQueue<T>(opts: SyncQueueOptions<T>): SyncQueue<T> {
5359
if (pendingValues === null) return;
5460
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
5561

62+
// Captive portal / lying-network probe. Caller's responsibility to make
63+
// it cheap (HEAD a small endpoint, ~50ms). Skip attempt but DON'T count
64+
// as a failure — it's a reachability question, not a sync failure.
65+
if (opts.connectivityProbe) {
66+
let reachable = false;
67+
try {
68+
reachable = await opts.connectivityProbe();
69+
} catch {
70+
reachable = false;
71+
}
72+
if (cancelled) return;
73+
if (!reachable) return;
74+
}
75+
5676
const values = pendingValues;
5777
inFlight = true;
5878
attempt += 1;

src/storage/__tests__/indexedDB.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { beforeEach, describe, expect, it } from 'vitest';
2-
import { indexedDBAdapter, _resetIndexedDBForTests } from '../indexedDB';
2+
import { indexedDBAdapter } from '../indexedDB';
33

44
describe('indexedDBAdapter', () => {
55
beforeEach(async () => {
6-
_resetIndexedDBForTests();
76
const a = indexedDBAdapter();
87
if (a.clear) await a.clear();
98
});

src/storage/indexedDB.ts

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,52 @@ const DB_NAME = 'formdraft';
44
const STORE_NAME = 'drafts';
55
const DB_VERSION = 1;
66

7-
let dbPromise: Promise<IDBDatabase> | null = null;
7+
/**
8+
* Returns a fresh IndexedDB adapter. The DB connection promise is held inside
9+
* the closure, not at module scope — so each call yields an independent
10+
* adapter and tests get a clean slate without needing a public reset helper.
11+
* In typical use, callers create one adapter per form and pass it via
12+
* `storage: indexedDBAdapter()`.
13+
*/
14+
export function indexedDBAdapter(): StorageAdapter {
15+
let dbPromise: Promise<IDBDatabase> | null = null;
816

9-
function getDb(): Promise<IDBDatabase> {
10-
if (dbPromise) return dbPromise;
11-
const p = new Promise<IDBDatabase>((resolve, reject) => {
12-
const req = indexedDB.open(DB_NAME, DB_VERSION);
13-
req.onupgradeneeded = () => {
14-
const db = req.result;
15-
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
16-
};
17-
req.onsuccess = () => resolve(req.result);
18-
req.onerror = () => reject(req.error);
19-
});
20-
// If open fails (Firefox private mode, version mismatch), don't cache the
21-
// rejection forever — next call should re-attempt.
22-
p.catch(() => {
23-
if (dbPromise === p) dbPromise = null;
24-
});
25-
dbPromise = p;
26-
return p;
27-
}
17+
const getDb = (): Promise<IDBDatabase> => {
18+
if (dbPromise) return dbPromise;
19+
const p = new Promise<IDBDatabase>((resolve, reject) => {
20+
const req = indexedDB.open(DB_NAME, DB_VERSION);
21+
req.onupgradeneeded = () => {
22+
const db = req.result;
23+
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
24+
};
25+
req.onsuccess = () => resolve(req.result);
26+
req.onerror = () => reject(req.error);
27+
});
28+
// If open fails (Firefox private mode, version mismatch), don't cache the
29+
// rejection forever — next call should re-attempt.
30+
p.catch(() => {
31+
if (dbPromise === p) dbPromise = null;
32+
});
33+
dbPromise = p;
34+
return p;
35+
};
2836

29-
function tx<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest<T> | void): Promise<T | void> {
30-
return getDb().then(
31-
(db) =>
32-
new Promise<T | void>((resolve, reject) => {
33-
const transaction = db.transaction(STORE_NAME, mode);
34-
const store = transaction.objectStore(STORE_NAME);
35-
const req = fn(store);
36-
transaction.oncomplete = () => resolve(req?.result as T | undefined);
37-
transaction.onerror = () => reject(transaction.error);
38-
transaction.onabort = () => reject(transaction.error);
39-
}),
40-
);
41-
}
37+
const tx = <T>(
38+
mode: IDBTransactionMode,
39+
fn: (store: IDBObjectStore) => IDBRequest<T> | void,
40+
): Promise<T | void> =>
41+
getDb().then(
42+
(db) =>
43+
new Promise<T | void>((resolve, reject) => {
44+
const transaction = db.transaction(STORE_NAME, mode);
45+
const store = transaction.objectStore(STORE_NAME);
46+
const req = fn(store);
47+
transaction.oncomplete = () => resolve(req?.result as T | undefined);
48+
transaction.onerror = () => reject(transaction.error);
49+
transaction.onabort = () => reject(transaction.error);
50+
}),
51+
);
4252

43-
export function indexedDBAdapter(): StorageAdapter {
4453
return {
4554
name: 'indexedDB',
4655
async read(key) {
@@ -58,7 +67,3 @@ export function indexedDBAdapter(): StorageAdapter {
5867
},
5968
};
6069
}
61-
62-
export function _resetIndexedDBForTests(): void {
63-
dbPromise = null;
64-
}

0 commit comments

Comments
 (0)