Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'fix: save offline edits to IDB to survive browser crash' (#94) from fix/offline-idb-save into main

scott 3af17ced 133842d7

+61 -9
+5
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.9.6] — 2026-03-23 11 + 12 + ### Fixed 13 + - **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) 14 + 10 15 ## [0.9.5] — 2026-03-23 11 16 12 17 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.5", 3 + "version": "0.9.6", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+31 -8
src/lib/provider.ts
··· 294 294 } 295 295 296 296 _debouncedSave(): void { 297 - if (!this.synced) return; // Don't save before sync completes 297 + // Don't save before initial sync (doc may be empty). But if we've already 298 + // loaded data (_hadSnapshot), allow saves even while disconnected — they'll 299 + // go to IDB only, protecting against browser crash during offline editing. 300 + if (!this.synced && !this._hadSnapshot) return; 298 301 const now = Date.now(); 299 302 if (!this._lastDebounceTrigger) this._lastDebounceTrigger = now; 300 303 clearTimeout(this._saveDebounce!); ··· 436 439 } 437 440 438 441 async _saveSnapshot(): Promise<void> { 439 - // Don't save before sync completes — we'd overwrite real data with empty state 440 - if (!this.synced) return; 442 + // Don't save before initial sync — doc may be empty/partial and we'd overwrite 443 + // real data. But if we've already loaded data (_hadSnapshot), allow saves while 444 + // disconnected — they go to IDB only, so offline edits survive browser crashes. 445 + if (!this.synced && !this._hadSnapshot) return; 441 446 442 447 // Prevent concurrent saves from racing 443 448 if (this._saveInProgress) return; ··· 470 475 return; 471 476 } 472 477 478 + const encrypted = await encrypt(state, this.cryptoKey); 479 + 480 + // Cache encrypted state for synchronous use in emergency save (sendBeacon) 481 + this._lastEncrypted = encrypted; 482 + 483 + // When disconnected (synced=false but hadSnapshot=true), skip server calls 484 + // and save to IDB only — protects offline edits against browser crash. 485 + if (!this.synced) { 486 + let idbSaved = false; 487 + try { 488 + await saveLocalBackup(this.roomId, encrypted); 489 + idbSaved = true; 490 + } catch { /* IDB backup is best-effort */ } 491 + 492 + if (idbSaved) { 493 + this._hasUnsavedChanges = false; 494 + this._setSaveStatus('saved'); 495 + } else { 496 + this._setSaveStatus('error'); 497 + } 498 + return; 499 + } 500 + 473 501 // Auto-create a version for recovery (throttled to max once per 5 minutes) 474 502 const VERSION_INTERVAL = 5 * 60 * 1000; 475 503 if (state.byteLength >= MIN_SNAPSHOT_BYTES && this._hadSnapshot && ··· 484 512 this._lastVersionTime = Date.now(); 485 513 } catch { /* version save is best-effort */ } 486 514 } 487 - 488 - const encrypted = await encrypt(state, this.cryptoKey); 489 - 490 - // Cache encrypted state for synchronous use in emergency save (sendBeacon) 491 - this._lastEncrypted = encrypted; 492 515 493 516 // Save to server with retry logic 494 517 let saved = false;
+24
tests/provider-save.test.ts
··· 162 162 }); 163 163 }); 164 164 165 + describe('offline IDB save when disconnected', () => { 166 + it('should allow saves to IDB when disconnected but data was previously loaded', async () => { 167 + const source = await import('fs').then(fs => 168 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 169 + ); 170 + // _saveSnapshot should NOT bail entirely on !this.synced — it should check _hadSnapshot 171 + // and save to IDB when disconnected but data exists 172 + const saveMethod = source.match(/async _saveSnapshot[\s\S]*?(?=\n \/\*\*|\n set|\n disconnect)/)?.[0] || ''; 173 + // The initial guard should be `!this.synced && !this._hadSnapshot`, not just `!this.synced` 174 + expect(saveMethod).toMatch(/!this\.synced\s*&&\s*!this\._hadSnapshot/); 175 + // There should be an IDB-only path when !this.synced 176 + expect(saveMethod).toContain('saveLocalBackup'); 177 + }); 178 + 179 + it('should allow debounced saves when disconnected but data was loaded', async () => { 180 + const source = await import('fs').then(fs => 181 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 182 + ); 183 + const debouncedMethod = source.match(/_debouncedSave\(\): void[\s\S]*?\n \}/)?.[0] || ''; 184 + // Should check _hadSnapshot, not bail on !synced alone 185 + expect(debouncedMethod).toMatch(/!this\.synced\s*&&\s*!this\._hadSnapshot/); 186 + }); 187 + }); 188 + 165 189 describe('periodic save interval', () => { 166 190 it('should use a save interval of 5 seconds or less', async () => { 167 191 const source = await import('fs').then(fs =>