Problem
`navigator.onLine === true` lies in two common scenarios:
- Captive portals (coffee shop wifi, airport, hotel) — connected to wifi, no internet
- Airplane mode partial disable — wifi shows connected but data blocked
formdraft currently consults `navigator.onLine` for sync gating. When it lies, sync retries fire and fail, exhausting the retry budget without the user understanding why.
Proposed
A pluggable network detector option. Library default stays `navigator.onLine`; consumers can plug in a heartbeat-based detector.
API sketch
```tsx
import { createHeartbeatDetector } from 'formdraft';
const detector = createHeartbeatDetector({
url: '/api/health',
intervalMs: 30000,
timeoutMs: 5000,
});
useFormDraft({
// ...
onlineDetector: detector,
});
```
Or simpler — just a function:
```tsx
useFormDraft({
isOnline: async () => {
const r = await fetch('/health', { signal: AbortSignal.timeout(5000) }).catch(() => null);
return r?.ok ?? false;
},
});
```
Implementation notes
- `createHeartbeatDetector` returns an object that wraps internal interval + cached online state
- Sync queue consults the detector instead of `navigator.onLine` directly
- Default behavior unchanged when no detector provided
- Should respect `visibilitychange` (don't heartbeat in background tabs)
Acceptance
- New `createHeartbeatDetector` exported from main
- `useFormDraft` accepts `onlineDetector` option
- Sync queue uses detector when provided
- 3+ unit tests: heartbeat probes, detects captive portal failure, recovery on real-online
- README new section "Reliable online detection"
Problem
`navigator.onLine === true` lies in two common scenarios:
formdraft currently consults `navigator.onLine` for sync gating. When it lies, sync retries fire and fail, exhausting the retry budget without the user understanding why.
Proposed
A pluggable network detector option. Library default stays `navigator.onLine`; consumers can plug in a heartbeat-based detector.
API sketch
```tsx
import { createHeartbeatDetector } from 'formdraft';
const detector = createHeartbeatDetector({
url: '/api/health',
intervalMs: 30000,
timeoutMs: 5000,
});
useFormDraft({
// ...
onlineDetector: detector,
});
```
Or simpler — just a function:
```tsx
useFormDraft({
isOnline: async () => {
const r = await fetch('/health', { signal: AbortSignal.timeout(5000) }).catch(() => null);
return r?.ok ?? false;
},
});
```
Implementation notes
Acceptance