···7788## [Unreleased]
991010+## [0.9.6] — 2026-03-23
1111+1212+### Fixed
1313+- **Offline edits lost on browser crash**: `_saveSnapshot` and `_debouncedSave` previously bailed entirely when `!this.synced`, meaning edits during a WebSocket disconnect were only in JS memory — a browser crash would lose them. Now saves to IDB when disconnected (server PUT skipped), so offline edits survive crashes (#204)
1414+1015## [0.9.5] — 2026-03-23
11161217### Fixed
···294294 }
295295296296 _debouncedSave(): void {
297297- if (!this.synced) return; // Don't save before sync completes
297297+ // Don't save before initial sync (doc may be empty). But if we've already
298298+ // loaded data (_hadSnapshot), allow saves even while disconnected — they'll
299299+ // go to IDB only, protecting against browser crash during offline editing.
300300+ if (!this.synced && !this._hadSnapshot) return;
298301 const now = Date.now();
299302 if (!this._lastDebounceTrigger) this._lastDebounceTrigger = now;
300303 clearTimeout(this._saveDebounce!);
···436439 }
437440438441 async _saveSnapshot(): Promise<void> {
439439- // Don't save before sync completes — we'd overwrite real data with empty state
440440- if (!this.synced) return;
442442+ // Don't save before initial sync — doc may be empty/partial and we'd overwrite
443443+ // real data. But if we've already loaded data (_hadSnapshot), allow saves while
444444+ // disconnected — they go to IDB only, so offline edits survive browser crashes.
445445+ if (!this.synced && !this._hadSnapshot) return;
441446442447 // Prevent concurrent saves from racing
443448 if (this._saveInProgress) return;
···470475 return;
471476 }
472477478478+ const encrypted = await encrypt(state, this.cryptoKey);
479479+480480+ // Cache encrypted state for synchronous use in emergency save (sendBeacon)
481481+ this._lastEncrypted = encrypted;
482482+483483+ // When disconnected (synced=false but hadSnapshot=true), skip server calls
484484+ // and save to IDB only — protects offline edits against browser crash.
485485+ if (!this.synced) {
486486+ let idbSaved = false;
487487+ try {
488488+ await saveLocalBackup(this.roomId, encrypted);
489489+ idbSaved = true;
490490+ } catch { /* IDB backup is best-effort */ }
491491+492492+ if (idbSaved) {
493493+ this._hasUnsavedChanges = false;
494494+ this._setSaveStatus('saved');
495495+ } else {
496496+ this._setSaveStatus('error');
497497+ }
498498+ return;
499499+ }
500500+473501 // Auto-create a version for recovery (throttled to max once per 5 minutes)
474502 const VERSION_INTERVAL = 5 * 60 * 1000;
475503 if (state.byteLength >= MIN_SNAPSHOT_BYTES && this._hadSnapshot &&
···484512 this._lastVersionTime = Date.now();
485513 } catch { /* version save is best-effort */ }
486514 }
487487-488488- const encrypted = await encrypt(state, this.cryptoKey);
489489-490490- // Cache encrypted state for synchronous use in emergency save (sendBeacon)
491491- this._lastEncrypted = encrypted;
492515493516 // Save to server with retry logic
494517 let saved = false;
+24
tests/provider-save.test.ts
···162162 });
163163 });
164164165165+ describe('offline IDB save when disconnected', () => {
166166+ it('should allow saves to IDB when disconnected but data was previously loaded', async () => {
167167+ const source = await import('fs').then(fs =>
168168+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
169169+ );
170170+ // _saveSnapshot should NOT bail entirely on !this.synced — it should check _hadSnapshot
171171+ // and save to IDB when disconnected but data exists
172172+ const saveMethod = source.match(/async _saveSnapshot[\s\S]*?(?=\n \/\*\*|\n set|\n disconnect)/)?.[0] || '';
173173+ // The initial guard should be `!this.synced && !this._hadSnapshot`, not just `!this.synced`
174174+ expect(saveMethod).toMatch(/!this\.synced\s*&&\s*!this\._hadSnapshot/);
175175+ // There should be an IDB-only path when !this.synced
176176+ expect(saveMethod).toContain('saveLocalBackup');
177177+ });
178178+179179+ it('should allow debounced saves when disconnected but data was loaded', async () => {
180180+ const source = await import('fs').then(fs =>
181181+ fs.readFileSync('src/lib/provider.ts', 'utf-8')
182182+ );
183183+ const debouncedMethod = source.match(/_debouncedSave\(\): void[\s\S]*?\n \}/)?.[0] || '';
184184+ // Should check _hadSnapshot, not bail on !synced alone
185185+ expect(debouncedMethod).toMatch(/!this\.synced\s*&&\s*!this\._hadSnapshot/);
186186+ });
187187+ });
188188+165189 describe('periodic save interval', () => {
166190 it('should use a save interval of 5 seconds or less', async () => {
167191 const source = await import('fs').then(fs =>