A local-first note taking app
0

Configure Feed

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

Refactor DocSessionRegistry + fix Vitest config for Automerge

Extract docSession module-level state (ydocRegistry, titleListeners,
visibilityListenerInstalled) into a DocSessionRegistry class. Tests now
instantiate a fresh registry per test instead of calling a test-only
reset function exported from production code. The module-level
acquireDocSession/subscribeEntryTitleChanges wrappers delegate to a
default singleton so no callers change.

Also fix Vitest picking @automerge/automerge's browser entry (base64
WASM, OOMs) by externalising the package and passing --conditions=node
to forked workers so Node picks the disk-loading entry instead.
Remove importOriginal() from habitatDocumentsProvider.test.ts to avoid
pulling in libp2p's native addon during mock setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Ethan Graf (Jun 18, 2026, 6:52 PM EDT) 4fe1affc 9b8cc432

+340 -344
+21
package-lock.json
··· 17858 17858 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 17859 17859 "license": "0BSD" 17860 17860 }, 17861 + "node_modules/tsx": { 17862 + "version": "4.22.4", 17863 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", 17864 + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", 17865 + "dev": true, 17866 + "license": "MIT", 17867 + "optional": true, 17868 + "peer": true, 17869 + "dependencies": { 17870 + "esbuild": "~0.28.0" 17871 + }, 17872 + "bin": { 17873 + "tsx": "dist/cli.mjs" 17874 + }, 17875 + "engines": { 17876 + "node": ">=18.0.0" 17877 + }, 17878 + "optionalDependencies": { 17879 + "fsevents": "~2.3.3" 17880 + } 17881 + }, 17861 17882 "node_modules/tsyringe": { 17862 17883 "version": "4.10.0", 17863 17884 "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
+19 -26
src/documents/providers/habitatDocumentsProvider.test.ts
··· 2 2 import { afterEach, describe, expect, it, vi } from 'vitest'; 3 3 4 4 import { getYjsPayload } from '../../editors/yjs/yjsEditorBinding'; 5 - import { __resetDocSessionRegistryForTests } from '../../habitat/docSession'; 6 5 import { HabitatDocumentsProvider } from './habitatDocumentsProvider'; 7 6 8 7 const URI = 9 8 'habitat://did:plc:rtzf5y356funa3tgp6fzmkjn/network.habitat.docs/test-rkey'; 10 9 11 - vi.mock('../../habitat/docSession', async (importOriginal) => { 12 - const actual = 13 - await importOriginal<typeof import('../../habitat/docSession')>(); 14 - return { 15 - ...actual, 16 - acquireDocSession: vi.fn((uri: string) => { 17 - const ydoc = new Y.Doc(); 18 - return { 10 + vi.mock('../../habitat/docSession', () => ({ 11 + acquireDocSession: vi.fn((uri: string) => { 12 + const ydoc = new Y.Doc(); 13 + return { 14 + uri, 15 + ydoc, 16 + provider: null, 17 + node: null, 18 + ownerRecord: { 19 19 uri, 20 - ydoc, 21 - provider: null, 22 - node: null, 23 - ownerRecord: { 24 - uri, 25 - cid: 'bafy', 26 - value: { name: 'Test doc', blob: null }, 27 - }, 28 - loadPromise: Promise.resolve(), 29 - getTitle: () => 'Test doc', 30 - resync: async () => undefined, 31 - release: () => undefined, 32 - }; 33 - }), 34 - }; 35 - }); 20 + cid: 'bafy', 21 + value: { name: 'Test doc', blob: null }, 22 + }, 23 + loadPromise: Promise.resolve(), 24 + getTitle: () => 'Test doc', 25 + resync: async () => undefined, 26 + release: () => undefined, 27 + }; 28 + }), 29 + })); 36 30 37 31 describe('HabitatDocumentsProvider', () => { 38 32 afterEach(() => { 39 - __resetDocSessionRegistryForTests(); 40 33 vi.clearAllMocks(); 41 34 }); 42 35
+13 -14
src/habitat/docSession.test.ts
··· 44 44 habitatAuth: { getAuthInfo: () => ({ did: 'did:plc:me' }) }, 45 45 })); 46 46 47 - import { 48 - acquireDocSession, 49 - __resetDocSessionRegistryForTests, 50 - } from './docSession'; 47 + import { DocSessionRegistry } from './docSession'; 51 48 52 49 const URI = 'at://did:plc:alice/network.habitat.docs/3kvxyz'; 50 + 51 + let registry: DocSessionRegistry; 53 52 54 53 beforeEach(() => { 55 - __resetDocSessionRegistryForTests(); 54 + registry = new DocSessionRegistry(); 56 55 mocks.stop.mockClear(); 57 56 mocks.providerDestroy.mockClear(); 58 57 mocks.dial.mockClear(); 59 58 }); 60 59 61 60 afterEach(() => { 62 - __resetDocSessionRegistryForTests(); 61 + // No teardown needed — each test gets a fresh registry instance. 63 62 }); 64 63 65 64 async function flush() { ··· 71 70 72 71 describe('acquireDocSession', () => { 73 72 it('returns the same ydoc instance for the same uri', () => { 74 - const a = acquireDocSession(URI); 75 - const b = acquireDocSession(URI); 73 + const a = registry.acquire(URI); 74 + const b = registry.acquire(URI); 76 75 expect(a.ydoc).toBe(b.ydoc); 77 76 a.release(); 78 77 b.release(); 79 78 }); 80 79 81 80 it('acquire twice + release once keeps libp2p alive', async () => { 82 - const a = acquireDocSession(URI); 83 - const b = acquireDocSession(URI); 81 + const a = registry.acquire(URI); 82 + const b = registry.acquire(URI); 84 83 await flush(); 85 84 expect(mocks.stop).not.toHaveBeenCalled(); 86 85 ··· 96 95 }); 97 96 98 97 it('re-acquiring after teardown reuses the existing ydoc', async () => { 99 - const a = acquireDocSession(URI); 98 + const a = registry.acquire(URI); 100 99 await flush(); 101 100 const text = a.ydoc.getText('persistent'); 102 101 text.insert(0, 'hello'); ··· 104 103 await flush(); 105 104 expect(mocks.stop).toHaveBeenCalledTimes(1); 106 105 107 - const b = acquireDocSession(URI); 106 + const b = registry.acquire(URI); 108 107 await flush(); 109 108 expect(b.ydoc).toBe(a.ydoc); 110 109 expect(b.ydoc.getText('persistent').toString()).toBe('hello'); ··· 114 113 }); 115 114 116 115 it('release is idempotent on a single handle', async () => { 117 - const a = acquireDocSession(URI); 118 - const b = acquireDocSession(URI); 116 + const a = registry.acquire(URI); 117 + const b = registry.acquire(URI); 119 118 await flush(); 120 119 121 120 a.release();
+273 -304
src/habitat/docSession.ts
··· 25 25 * 26 26 * Anti-flash trick: when refCount drops to 0 we tear down libp2p + the 27 27 * connection provider, but keep the `Y.Doc` in the registry. The next 28 - * `acquireDocSession(uri)` reuses the same in-memory state so re-opening a 29 - * doc shows instantly, then refreshes from the PDS in the background. 28 + * `acquire(uri)` reuses the same in-memory state so re-opening a doc shows 29 + * instantly, then refreshes from the PDS in the background. 30 30 */ 31 31 32 32 export type DocSession = { ··· 75 75 lastEmittedTitle: string | null; 76 76 }; 77 77 78 - const ydocRegistry = new Map<string, RegistryEntry>(); 78 + const SAVE_DEBOUNCE_MS = 1000; 79 + 80 + export class DocSessionRegistry { 81 + private entries = new Map<string, RegistryEntry>(); 82 + private titleListeners = new Set<(uri: string, name: string) => void>(); 83 + private visibilityListenerInstalled = false; 79 84 80 - const entryTitleListeners = new Set<(uri: string, name: string) => void>(); 85 + subscribeToTitleChanges( 86 + listener: (uri: string, name: string) => void, 87 + ): () => void { 88 + this.titleListeners.add(listener); 89 + return () => { 90 + this.titleListeners.delete(listener); 91 + }; 92 + } 81 93 82 - /** Subscribe to live title changes for open habitat docs (sidebar labels). */ 83 - export function subscribeEntryTitleChanges( 84 - listener: (uri: string, name: string) => void, 85 - ): () => void { 86 - entryTitleListeners.add(listener); 87 - return () => { 88 - entryTitleListeners.delete(listener); 89 - }; 90 - } 94 + /** 95 + * Acquire (or re-acquire) a session for `uri`. Always returns immediately 96 + * with a `DocSession` whose `ydoc` is the canonical instance for `uri`; the 97 + * first load resolves in the background and updates the ydoc in place. 98 + */ 99 + acquire(uri: string, options?: { initialYdoc?: Y.Doc }): DocSession { 100 + this.ensureVisibilityListener(); 101 + let entry = this.entries.get(uri); 102 + if (!entry) { 103 + const ydoc = options?.initialYdoc ?? new Y.Doc(); 104 + entry = { 105 + uri, 106 + ydoc, 107 + loadPromise: Promise.resolve({} as LoadDocResult), 108 + ownerRecord: null, 109 + node: null, 110 + provider: null, 111 + refCount: 0, 112 + saveHandler: null, 113 + saveTimer: null, 114 + pendingSave: null, 115 + teardownPromise: null, 116 + titleSyncHandler: null, 117 + lastEmittedTitle: null, 118 + }; 119 + this.entries.set(uri, entry); 120 + this.attachTitleSyncHandler(entry); 121 + this.assignLoadPromise(entry, this.initSession(entry)); 122 + } else if (entry.teardownPromise || !entry.node) { 123 + this.assignLoadPromise(entry, this.reinitSession(entry)); 124 + } 91 125 92 - function emitEntryTitle(uri: string, name: string): void { 93 - for (const listener of entryTitleListeners) { 94 - listener(uri, name); 126 + entry.refCount += 1; 127 + return this.makeHandle(entry); 95 128 } 96 - } 97 129 98 - const SAVE_DEBOUNCE_MS = 1000; 130 + private emitTitle(uri: string, name: string): void { 131 + for (const listener of this.titleListeners) { 132 + listener(uri, name); 133 + } 134 + } 99 135 100 - /** 101 - * Atomically swap in a new load promise and immediately attach a noop catch 102 - * so it never surfaces as an unhandled rejection. Workspaces still observe 103 - * load errors through `handle.loadPromise`; this just prevents the global 104 - * "Uncaught (in promise)" overlay Vite/Electron pops up on dev when the 105 - * registry-level promise has no .then() chained to it. 106 - */ 107 - function assignLoadPromise( 108 - entry: RegistryEntry, 109 - promise: Promise<LoadDocResult>, 110 - ): void { 111 - entry.loadPromise = promise; 112 - promise.catch(() => undefined); 113 - } 136 + private assignLoadPromise( 137 + entry: RegistryEntry, 138 + promise: Promise<LoadDocResult>, 139 + ): void { 140 + entry.loadPromise = promise; 141 + promise.catch(() => undefined); 142 + } 114 143 115 - let visibilityListenerInstalled = false; 144 + private ensureVisibilityListener(): void { 145 + if (this.visibilityListenerInstalled) return; 146 + if (typeof document === 'undefined') return; 147 + this.visibilityListenerInstalled = true; 148 + document.addEventListener('visibilitychange', () => { 149 + if (document.visibilityState !== 'visible') return; 150 + for (const entry of this.entries.values()) { 151 + if (!entry.node) continue; 152 + void this.resyncEntry(entry); 153 + } 154 + }); 155 + } 116 156 117 - /** 118 - * Install one global `visibilitychange` listener that runs `resyncEntry` on 119 - * every active entry whenever the page becomes visible. Mirrors upstream's 120 - * per-route hook but at the registry level, so it Just Works for every 121 - * workspace that opens a habitat doc. 122 - */ 123 - function ensureVisibilityListener() { 124 - if (visibilityListenerInstalled) return; 125 - if (typeof document === 'undefined') return; 126 - visibilityListenerInstalled = true; 127 - document.addEventListener('visibilitychange', () => { 128 - if (document.visibilityState !== 'visible') return; 129 - for (const entry of ydocRegistry.values()) { 130 - // Skip torn-down entries — they'll resync next time someone acquires. 131 - if (!entry.node) continue; 132 - void resyncEntry(entry); 157 + private async resyncEntry(entry: RegistryEntry): Promise<void> { 158 + if (entry.ownerRecord) { 159 + try { 160 + await mergeAllEdits(entry.ownerRecord, entry.ydoc); 161 + } catch (e) { 162 + console.warn('[habitat] visibility resync merge failed', e); 163 + } 164 + } else { 165 + try { 166 + const result = await loadHabitatDoc(entry.uri, entry.ydoc); 167 + entry.ownerRecord = result.ownerRecord; 168 + } catch (e) { 169 + console.warn('[habitat] visibility resync load failed', e); 170 + } 133 171 } 134 - }); 135 - } 136 - 137 - async function resyncEntry(entry: RegistryEntry) { 138 - if (entry.ownerRecord) { 139 - try { 140 - await mergeAllEdits(entry.ownerRecord, entry.ydoc); 141 - } catch (e) { 142 - console.warn('[habitat] visibility resync merge failed', e); 143 - } 144 - } else { 145 - try { 146 - const result = await loadHabitatDoc(entry.uri, entry.ydoc); 147 - entry.ownerRecord = result.ownerRecord; 148 - } catch (e) { 149 - console.warn('[habitat] visibility resync load failed', e); 172 + if (entry.node) { 173 + await dialRelayAndStartPeerDiscovery(entry.uri, entry.node); 150 174 } 151 175 } 152 - if (entry.node) { 153 - await dialRelayAndStartPeerDiscovery(entry.uri, entry.node); 154 - } 155 - } 156 176 157 - function scheduleSave(entry: RegistryEntry) { 158 - if (entry.saveTimer) clearTimeout(entry.saveTimer); 159 - entry.saveTimer = setTimeout(() => { 160 - entry.saveTimer = null; 161 - void runSave(entry); 162 - }, SAVE_DEBOUNCE_MS); 163 - } 177 + private scheduleSave(entry: RegistryEntry): void { 178 + if (entry.saveTimer) clearTimeout(entry.saveTimer); 179 + entry.saveTimer = setTimeout(() => { 180 + entry.saveTimer = null; 181 + void this.runSave(entry); 182 + }, SAVE_DEBOUNCE_MS); 183 + } 164 184 165 - async function runSave(entry: RegistryEntry) { 166 - const myDid = habitatAuth.getAuthInfo()?.did; 167 - if (!myDid || !entry.ownerRecord) return; 168 - const ownerRecord = entry.ownerRecord; 169 - entry.pendingSave = saveHabitatDoc({ 170 - ownerRecord, 171 - ydoc: entry.ydoc, 172 - myDid, 173 - }) 174 - .then(() => { 175 - const name = resolveDocDisplayTitle(entry.ydoc, ownerRecord.value.name); 176 - entry.ownerRecord = { 177 - ...ownerRecord, 178 - value: { ...ownerRecord.value, name }, 179 - }; 180 - syncEntryTitle(entry, name); 181 - }) 182 - .catch((e) => { 183 - console.error('[habitat] save failed', e); 185 + private async runSave(entry: RegistryEntry): Promise<void> { 186 + const myDid = habitatAuth.getAuthInfo()?.did; 187 + if (!myDid || !entry.ownerRecord) return; 188 + const ownerRecord = entry.ownerRecord; 189 + entry.pendingSave = saveHabitatDoc({ 190 + ownerRecord, 191 + ydoc: entry.ydoc, 192 + myDid, 184 193 }) 185 - .finally(() => { 186 - entry.pendingSave = null; 187 - }); 188 - await entry.pendingSave; 189 - } 190 - 191 - function syncEntryTitle(entry: RegistryEntry, name?: string): void { 192 - if (!entry.ownerRecord) return; 193 - const next = 194 - name ?? resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); 195 - if (next === entry.lastEmittedTitle) return; 196 - entry.lastEmittedTitle = next; 197 - emitEntryTitle(entry.uri, next); 198 - } 199 - 200 - function attachTitleSyncHandler(entry: RegistryEntry): void { 201 - if (entry.titleSyncHandler) return; 202 - entry.titleSyncHandler = () => syncEntryTitle(entry); 203 - entry.ydoc.on('update', entry.titleSyncHandler); 204 - syncEntryTitle(entry); 205 - } 194 + .then(() => { 195 + const name = resolveDocDisplayTitle(entry.ydoc, ownerRecord.value.name); 196 + entry.ownerRecord = { 197 + ...ownerRecord, 198 + value: { ...ownerRecord.value, name }, 199 + }; 200 + this.syncEntryTitle(entry, name); 201 + }) 202 + .catch((e) => { 203 + console.error('[habitat] save failed', e); 204 + }) 205 + .finally(() => { 206 + entry.pendingSave = null; 207 + }); 208 + await entry.pendingSave; 209 + } 206 210 207 - function detachTitleSyncHandler(entry: RegistryEntry): void { 208 - if (!entry.titleSyncHandler) return; 209 - entry.ydoc.off('update', entry.titleSyncHandler); 210 - entry.titleSyncHandler = null; 211 - } 211 + private syncEntryTitle(entry: RegistryEntry, name?: string): void { 212 + if (!entry.ownerRecord) return; 213 + const next = 214 + name ?? resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); 215 + if (next === entry.lastEmittedTitle) return; 216 + entry.lastEmittedTitle = next; 217 + this.emitTitle(entry.uri, next); 218 + } 212 219 213 - function attachSaveHandler(entry: RegistryEntry) { 214 - if (entry.saveHandler) return; 215 - entry.saveHandler = (_update, origin) => { 216 - // Skip remote-origin updates: those come from gossipsub and are already 217 - // persisted by the peer who wrote them. Saving here would echo their 218 - // edits back into our PDS unnecessarily. 219 - if (origin === entry.provider) return; 220 - scheduleSave(entry); 221 - }; 222 - entry.ydoc.on('updateV2', entry.saveHandler); 223 - } 220 + private attachTitleSyncHandler(entry: RegistryEntry): void { 221 + if (entry.titleSyncHandler) return; 222 + entry.titleSyncHandler = () => this.syncEntryTitle(entry); 223 + entry.ydoc.on('update', entry.titleSyncHandler); 224 + this.syncEntryTitle(entry); 225 + } 224 226 225 - function detachSaveHandler(entry: RegistryEntry) { 226 - if (!entry.saveHandler) return; 227 - entry.ydoc.off('updateV2', entry.saveHandler); 228 - entry.saveHandler = null; 229 - } 227 + private detachTitleSyncHandler(entry: RegistryEntry): void { 228 + if (!entry.titleSyncHandler) return; 229 + entry.ydoc.off('update', entry.titleSyncHandler); 230 + entry.titleSyncHandler = null; 231 + } 230 232 231 - /** 232 - * Acquire (or re-acquire) a session for `uri`. Always returns immediately 233 - * with a `DocSession` whose `ydoc` is the canonical instance for `uri`; the 234 - * first load resolves in the background and updates the ydoc in place. 235 - */ 236 - export function acquireDocSession( 237 - uri: string, 238 - options?: { initialYdoc?: Y.Doc }, 239 - ): DocSession { 240 - ensureVisibilityListener(); 241 - let entry = ydocRegistry.get(uri); 242 - if (!entry) { 243 - const ydoc = options?.initialYdoc ?? new Y.Doc(); 244 - entry = { 245 - uri, 246 - ydoc, 247 - loadPromise: Promise.resolve({} as LoadDocResult), 248 - ownerRecord: null, 249 - node: null, 250 - provider: null, 251 - refCount: 0, 252 - saveHandler: null, 253 - saveTimer: null, 254 - pendingSave: null, 255 - teardownPromise: null, 256 - titleSyncHandler: null, 257 - lastEmittedTitle: null, 233 + private attachSaveHandler(entry: RegistryEntry): void { 234 + if (entry.saveHandler) return; 235 + entry.saveHandler = (_update, origin) => { 236 + if (origin === entry.provider) return; 237 + this.scheduleSave(entry); 258 238 }; 259 - ydocRegistry.set(uri, entry); 260 - attachTitleSyncHandler(entry); 261 - // First-time init: load from PDS, then stand up libp2p. 262 - assignLoadPromise(entry, initSession(entry)); 263 - } else if (entry.teardownPromise || !entry.node) { 264 - // Re-acquire after refcount hit zero — re-stand-up live editing while 265 - // keeping the existing ydoc. Wait for any in-flight teardown to finish 266 - // first so we don't race a `node.stop()` against the new node, then 267 - // re-fetch in the background. 268 - assignLoadPromise(entry, reinitSession(entry)); 239 + entry.ydoc.on('updateV2', entry.saveHandler); 269 240 } 270 241 271 - entry.refCount += 1; 272 - const captured = entry; 273 - return makeHandle(captured); 274 - } 275 - 276 - async function initSession(entry: RegistryEntry): Promise<LoadDocResult> { 277 - try { 278 - const result = await loadHabitatDoc(entry.uri, entry.ydoc); 279 - entry.ownerRecord = result.ownerRecord; 280 - attachSaveHandler(entry); 281 - syncEntryTitle(entry); 282 - await ensureLiveEditing(entry); 283 - return result; 284 - } catch (e) { 285 - console.error('[habitat] initial load failed for', entry.uri, e); 286 - throw e; 242 + private detachSaveHandler(entry: RegistryEntry): void { 243 + if (!entry.saveHandler) return; 244 + entry.ydoc.off('updateV2', entry.saveHandler); 245 + entry.saveHandler = null; 287 246 } 288 - } 289 247 290 - async function reinitSession(entry: RegistryEntry): Promise<LoadDocResult> { 291 - if (entry.teardownPromise) { 248 + private async initSession(entry: RegistryEntry): Promise<LoadDocResult> { 292 249 try { 293 - await entry.teardownPromise; 294 - } catch { 295 - // teardown errors are non-fatal; we'll proceed to re-init regardless 250 + const result = await loadHabitatDoc(entry.uri, entry.ydoc); 251 + entry.ownerRecord = result.ownerRecord; 252 + this.attachSaveHandler(entry); 253 + this.syncEntryTitle(entry); 254 + await this.ensureLiveEditing(entry); 255 + return result; 256 + } catch (e) { 257 + console.error('[habitat] initial load failed for', entry.uri, e); 258 + throw e; 296 259 } 297 260 } 298 - try { 299 - const result = await loadHabitatDoc(entry.uri, entry.ydoc); 300 - entry.ownerRecord = result.ownerRecord; 301 - attachSaveHandler(entry); 302 - syncEntryTitle(entry); 303 - await ensureLiveEditing(entry); 304 - return result; 305 - } catch (e) { 306 - console.error('[habitat] reload failed for', entry.uri, e); 307 - throw e; 308 - } 309 - } 310 261 311 - async function ensureLiveEditing(entry: RegistryEntry) { 312 - if (entry.node) return; 313 - try { 314 - const node = await createHabitatLibp2pNode(); 315 - entry.node = node; 316 - entry.provider = new Libp2pConnectionProvider(node, entry.ydoc, entry.uri); 317 - await dialRelayAndStartPeerDiscovery(entry.uri, node); 318 - } catch (e) { 319 - console.error( 320 - '[habitat] could not start libp2p; doc is read-write but offline', 321 - e, 322 - ); 323 - } 324 - } 325 - 326 - function makeHandle(entry: RegistryEntry): DocSession { 327 - let released = false; 328 - // Capture the current loadPromise at handle-construction time. Re-acquires 329 - // may swap entry.loadPromise out from under us; each handle should reflect 330 - // the load that was in flight when *it* was created. 331 - const loadPromise = entry.loadPromise.then(() => undefined); 332 - const handle: DocSession = { 333 - uri: entry.uri, 334 - ydoc: entry.ydoc, 335 - get provider() { 336 - return entry.provider; 337 - }, 338 - get node() { 339 - return entry.node; 340 - }, 341 - get ownerRecord() { 342 - // ownerRecord may briefly be null between acquire and the first 343 - // loadPromise resolution; callers that need it should await 344 - // handle.loadPromise (or handle.resync()) first. 345 - return entry.ownerRecord!; 346 - }, 347 - loadPromise, 348 - getTitle() { 349 - if (!entry.ownerRecord) return ''; 350 - return resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); 351 - }, 352 - resync: () => resyncEntry(entry), 353 - release: () => { 354 - if (released) return; 355 - released = true; 356 - entry.refCount -= 1; 357 - if (entry.refCount === 0) { 358 - entry.teardownPromise = teardownEntry(entry).finally(() => { 359 - entry.teardownPromise = null; 360 - }); 262 + private async reinitSession(entry: RegistryEntry): Promise<LoadDocResult> { 263 + if (entry.teardownPromise) { 264 + try { 265 + await entry.teardownPromise; 266 + } catch { 267 + // teardown errors are non-fatal 361 268 } 362 - }, 363 - }; 364 - return handle; 365 - } 366 - 367 - async function teardownEntry(entry: RegistryEntry) { 368 - if (entry.saveTimer) { 369 - // Flush a pending save so the very last edit isn't dropped on close. 370 - clearTimeout(entry.saveTimer); 371 - entry.saveTimer = null; 372 - await runSave(entry).catch(() => undefined); 373 - } 374 - if (entry.pendingSave) { 375 - try { 376 - await entry.pendingSave; 377 - } catch { 378 - // already logged in runSave 379 269 } 380 - } 381 - if (entry.provider) { 382 270 try { 383 - entry.provider.destroy(); 384 - } catch { 385 - // best-effort 271 + const result = await loadHabitatDoc(entry.uri, entry.ydoc); 272 + entry.ownerRecord = result.ownerRecord; 273 + this.attachSaveHandler(entry); 274 + this.syncEntryTitle(entry); 275 + await this.ensureLiveEditing(entry); 276 + return result; 277 + } catch (e) { 278 + console.error('[habitat] reload failed for', entry.uri, e); 279 + throw e; 386 280 } 387 - entry.provider = null; 388 281 } 389 - if (entry.node) { 282 + 283 + private async ensureLiveEditing(entry: RegistryEntry): Promise<void> { 284 + if (entry.node) return; 390 285 try { 391 - await entry.node.stop(); 392 - } catch { 393 - // best-effort 286 + const node = await createHabitatLibp2pNode(); 287 + entry.node = node; 288 + entry.provider = new Libp2pConnectionProvider(node, entry.ydoc, entry.uri); 289 + await dialRelayAndStartPeerDiscovery(entry.uri, node); 290 + } catch (e) { 291 + console.error( 292 + '[habitat] could not start libp2p; doc is read-write but offline', 293 + e, 294 + ); 394 295 } 395 - entry.node = null; 396 296 } 397 - detachSaveHandler(entry); 398 - // Intentionally keep `entry.ydoc` and `entry.ownerRecord` alive in the 399 - // registry. The next `acquireDocSession(uri)` reuses them for a 400 - // flash-free re-open and refreshes asynchronously. 401 - } 402 297 403 - /** Test-only: clear the registry between tests. */ 404 - export function __resetDocSessionRegistryForTests() { 405 - for (const entry of ydocRegistry.values()) { 406 - if (entry.saveTimer) clearTimeout(entry.saveTimer); 407 - detachTitleSyncHandler(entry); 408 - try { 409 - entry.provider?.destroy(); 410 - } catch { 411 - // ignore 298 + private makeHandle(entry: RegistryEntry): DocSession { 299 + let released = false; 300 + const loadPromise = entry.loadPromise.then(() => undefined); 301 + const handle: DocSession = { 302 + uri: entry.uri, 303 + ydoc: entry.ydoc, 304 + get provider() { 305 + return entry.provider; 306 + }, 307 + get node() { 308 + return entry.node; 309 + }, 310 + get ownerRecord() { 311 + return entry.ownerRecord!; 312 + }, 313 + loadPromise, 314 + getTitle() { 315 + if (!entry.ownerRecord) return ''; 316 + return resolveDocDisplayTitle(entry.ydoc, entry.ownerRecord.value.name); 317 + }, 318 + resync: () => this.resyncEntry(entry), 319 + release: () => { 320 + if (released) return; 321 + released = true; 322 + entry.refCount -= 1; 323 + if (entry.refCount === 0) { 324 + entry.teardownPromise = this.teardownEntry(entry).finally(() => { 325 + entry.teardownPromise = null; 326 + }); 327 + } 328 + }, 329 + }; 330 + return handle; 331 + } 332 + 333 + private async teardownEntry(entry: RegistryEntry): Promise<void> { 334 + if (entry.saveTimer) { 335 + clearTimeout(entry.saveTimer); 336 + entry.saveTimer = null; 337 + await this.runSave(entry).catch(() => undefined); 412 338 } 413 - const stopped = entry.node?.stop(); 414 - if (stopped) void stopped.catch(() => undefined); 339 + if (entry.pendingSave) { 340 + try { 341 + await entry.pendingSave; 342 + } catch { 343 + // already logged in runSave 344 + } 345 + } 346 + if (entry.provider) { 347 + try { 348 + entry.provider.destroy(); 349 + } catch { 350 + // best-effort 351 + } 352 + entry.provider = null; 353 + } 354 + if (entry.node) { 355 + try { 356 + await entry.node.stop(); 357 + } catch { 358 + // best-effort 359 + } 360 + entry.node = null; 361 + } 362 + this.detachSaveHandler(entry); 363 + // Intentionally keep `entry.ydoc` and `entry.ownerRecord` alive in the 364 + // registry. The next `acquire(uri)` reuses them for a flash-free re-open 365 + // and refreshes asynchronously. 415 366 } 416 - ydocRegistry.clear(); 417 - entryTitleListeners.clear(); 367 + } 368 + 369 + // --------------------------------------------------------------------------- 370 + // Default singleton — used by all production callers. 371 + // Tests should instantiate DocSessionRegistry directly for isolation. 372 + // --------------------------------------------------------------------------- 373 + 374 + const defaultRegistry = new DocSessionRegistry(); 375 + 376 + export function acquireDocSession( 377 + uri: string, 378 + options?: { initialYdoc?: Y.Doc }, 379 + ): DocSession { 380 + return defaultRegistry.acquire(uri, options); 381 + } 382 + 383 + export function subscribeEntryTitleChanges( 384 + listener: (uri: string, name: string) => void, 385 + ): () => void { 386 + return defaultRegistry.subscribeToTitleChanges(listener); 418 387 }
+14
vitest.config.ts
··· 4 4 test: { 5 5 environment: 'node', 6 6 include: ['src/**/*.test.ts'], 7 + pool: 'forks', 8 + forks: { 9 + // @automerge/automerge ships a node-specific entry (fullfat_node.js, 10 + // loads WASM from disk) and a browser entry (fullfat_base64.js, embeds 11 + // ~3.5MB of WASM as base64). Vite picks browser by default and OOMs. 12 + // --conditions=node tells Node.js to prefer the node condition when 13 + // Vite externalises the package and falls back to native resolution. 14 + execArgv: ['--conditions=node'], 15 + }, 16 + server: { 17 + deps: { 18 + external: [/@automerge/], 19 + }, 20 + }, 7 21 }, 8 22 });