on-zero 0.16.19 → 0.16.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cjs/createUseQuery.cjs +2 -1
  2. package/dist/cjs/createUseQuery.native.js +2 -1
  3. package/dist/cjs/createUseQuery.native.js.map +1 -1
  4. package/dist/cjs/createUseQueryDirect.cjs +2 -1
  5. package/dist/cjs/createUseQueryDirect.native.js +2 -1
  6. package/dist/cjs/createUseQueryDirect.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.cjs +75 -9
  8. package/dist/cjs/createZeroClient.native.js +151 -81
  9. package/dist/cjs/createZeroClient.native.js.map +1 -1
  10. package/dist/cjs/createZeroClient.recovery.test.cjs +351 -10
  11. package/dist/cjs/createZeroClient.recovery.test.native.js +412 -12
  12. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  13. package/dist/cjs/generate.cjs +16 -11
  14. package/dist/cjs/generate.native.js +16 -11
  15. package/dist/cjs/generate.native.js.map +1 -1
  16. package/dist/cjs/generate.test.cjs +22 -0
  17. package/dist/cjs/generate.test.native.js +22 -0
  18. package/dist/cjs/generate.test.native.js.map +1 -1
  19. package/dist/cjs/helpers/recoverZeroClient.cjs +542 -52
  20. package/dist/cjs/helpers/recoverZeroClient.native.js +721 -59
  21. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  22. package/dist/cjs/helpers/recoverZeroClient.test.cjs +318 -0
  23. package/dist/cjs/helpers/recoverZeroClient.test.native.js +361 -0
  24. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  25. package/dist/cjs/kvStore.cjs +30 -0
  26. package/dist/cjs/kvStore.native.js +34 -0
  27. package/dist/cjs/kvStore.native.js.map +1 -0
  28. package/dist/cjs/queryJson.cjs +58 -0
  29. package/dist/cjs/queryJson.native.js +86 -0
  30. package/dist/cjs/queryJson.native.js.map +1 -0
  31. package/dist/cjs/resolveQuery.cjs +2 -0
  32. package/dist/cjs/resolveQuery.native.js +2 -0
  33. package/dist/cjs/resolveQuery.native.js.map +1 -1
  34. package/dist/cjs/useQuery.empty.test.cjs +11 -1
  35. package/dist/cjs/useQuery.empty.test.native.js +23 -1
  36. package/dist/cjs/useQuery.empty.test.native.js.map +1 -1
  37. package/dist/cjs/useQuery.ssr.test.cjs +22 -1
  38. package/dist/cjs/useQuery.ssr.test.native.js +30 -1
  39. package/dist/cjs/useQuery.ssr.test.native.js.map +1 -1
  40. package/dist/esm/createUseQuery.mjs +2 -1
  41. package/dist/esm/createUseQuery.mjs.map +1 -1
  42. package/dist/esm/createUseQuery.native.js +2 -1
  43. package/dist/esm/createUseQuery.native.js.map +1 -1
  44. package/dist/esm/createUseQueryDirect.mjs +2 -1
  45. package/dist/esm/createUseQueryDirect.mjs.map +1 -1
  46. package/dist/esm/createUseQueryDirect.native.js +2 -1
  47. package/dist/esm/createUseQueryDirect.native.js.map +1 -1
  48. package/dist/esm/createZeroClient.mjs +76 -10
  49. package/dist/esm/createZeroClient.mjs.map +1 -1
  50. package/dist/esm/createZeroClient.native.js +152 -82
  51. package/dist/esm/createZeroClient.native.js.map +1 -1
  52. package/dist/esm/createZeroClient.recovery.test.mjs +352 -11
  53. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  54. package/dist/esm/createZeroClient.recovery.test.native.js +413 -13
  55. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  56. package/dist/esm/generate.mjs +16 -11
  57. package/dist/esm/generate.mjs.map +1 -1
  58. package/dist/esm/generate.native.js +16 -11
  59. package/dist/esm/generate.native.js.map +1 -1
  60. package/dist/esm/generate.test.mjs +22 -0
  61. package/dist/esm/generate.test.mjs.map +1 -1
  62. package/dist/esm/generate.test.native.js +22 -0
  63. package/dist/esm/generate.test.native.js.map +1 -1
  64. package/dist/esm/helpers/recoverZeroClient.mjs +542 -54
  65. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  66. package/dist/esm/helpers/recoverZeroClient.native.js +721 -61
  67. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  68. package/dist/esm/helpers/recoverZeroClient.test.mjs +319 -1
  69. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  70. package/dist/esm/helpers/recoverZeroClient.test.native.js +362 -1
  71. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  72. package/dist/esm/kvStore.mjs +5 -0
  73. package/dist/esm/kvStore.mjs.map +1 -0
  74. package/dist/esm/kvStore.native.js +6 -0
  75. package/dist/esm/kvStore.native.js.map +1 -0
  76. package/dist/esm/queryJson.mjs +32 -0
  77. package/dist/esm/queryJson.mjs.map +1 -0
  78. package/dist/esm/queryJson.native.js +57 -0
  79. package/dist/esm/queryJson.native.js.map +1 -0
  80. package/dist/esm/resolveQuery.mjs +2 -0
  81. package/dist/esm/resolveQuery.mjs.map +1 -1
  82. package/dist/esm/resolveQuery.native.js +2 -0
  83. package/dist/esm/resolveQuery.native.js.map +1 -1
  84. package/dist/esm/useQuery.empty.test.mjs +12 -2
  85. package/dist/esm/useQuery.empty.test.mjs.map +1 -1
  86. package/dist/esm/useQuery.empty.test.native.js +24 -2
  87. package/dist/esm/useQuery.empty.test.native.js.map +1 -1
  88. package/dist/esm/useQuery.ssr.test.mjs +23 -2
  89. package/dist/esm/useQuery.ssr.test.mjs.map +1 -1
  90. package/dist/esm/useQuery.ssr.test.native.js +31 -2
  91. package/dist/esm/useQuery.ssr.test.native.js.map +1 -1
  92. package/package.json +11 -3
  93. package/src/createUseQuery.tsx +2 -1
  94. package/src/createUseQueryDirect.tsx +2 -1
  95. package/src/createZeroClient.recovery.test.tsx +370 -7
  96. package/src/createZeroClient.tsx +100 -10
  97. package/src/generate.test.ts +31 -0
  98. package/src/generate.ts +22 -10
  99. package/src/helpers/recoverZeroClient.test.ts +323 -0
  100. package/src/helpers/recoverZeroClient.ts +896 -78
  101. package/src/kvStore.native.ts +7 -0
  102. package/src/kvStore.ts +7 -0
  103. package/src/queryJson.ts +46 -0
  104. package/src/resolveQuery.ts +2 -0
  105. package/src/useQuery.empty.test.tsx +16 -2
  106. package/src/useQuery.ssr.test.ts +23 -2
  107. package/types/createUseQuery.d.ts.map +1 -1
  108. package/types/createUseQueryDirect.d.ts.map +1 -1
  109. package/types/createZeroClient.d.ts.map +1 -1
  110. package/types/createZeroClient.recovery.test.d.ts.map +1 -1
  111. package/types/generate.d.ts.map +1 -1
  112. package/types/helpers/recoverZeroClient.d.ts +6 -0
  113. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  114. package/types/kvStore.d.ts +2 -0
  115. package/types/kvStore.d.ts.map +1 -0
  116. package/types/kvStore.native.d.ts +3 -0
  117. package/types/kvStore.native.d.ts.map +1 -0
  118. package/types/queryJson.d.ts +3 -0
  119. package/types/queryJson.d.ts.map +1 -0
  120. package/types/resolveQuery.d.ts.map +1 -1
@@ -1,3 +1,5 @@
1
+ import { globalValue } from './globalValue'
2
+
1
3
  import type { ZeroEventsEmitter, ZeroRecoveryReasonKey } from '../types'
2
4
  import type { Context, LogLevel, LogSink } from '@rocicorp/logger'
3
5
  import type { UpdateNeededReason } from '@rocicorp/zero'
@@ -14,6 +16,15 @@ const RECOVER_GUARD_MS = 60_000
14
16
  // land.
15
17
  const RELOAD_LATCH_TIMEOUT_MS = 15_000
16
18
 
19
+ // update-needed reasons that mean "new client code landed": every mounted Zero
20
+ // instance whose group is stale fires one, and they all ride ONE group
21
+ // transition per shared client identity instead of each recovering alone.
22
+ const GROUP_TRANSITION_REASONS: ReadonlySet<ZeroRecoveryReasonKey> = new Set([
23
+ 'NewClientGroup',
24
+ 'VersionNotSupported',
25
+ 'SchemaVersionNotSupported',
26
+ ])
27
+
17
28
  // the fatal local-store-loss signature Zero logs when the IndexedDB it expects
18
29
  // is gone (evicted, or deleted by another tab).
19
30
  const LOCAL_STORE_LOST = 'Expected IndexedDB not found'
@@ -44,6 +55,7 @@ export type ZeroLogPattern = string | RegExp
44
55
  export type RecoveryGuardStorage = {
45
56
  getItem: (key: string) => string | null
46
57
  setItem: (key: string, value: string) => void
58
+ removeItem?: (key: string) => void
47
59
  }
48
60
 
49
61
  // what a scheduleReload consumer receives so it can gate/annotate the reload
@@ -85,24 +97,128 @@ export type ZeroRecoveryDeps = {
85
97
  recoverInPlace?: () => Promise<boolean>
86
98
  // injectable for tests; defaults to a real page reload.
87
99
  reload?: () => void | boolean
100
+ // shared client identity for group-transition coalescing (the Zero userID):
101
+ // sibling instances with the same identity ride one transition. defaults to
102
+ // '' (one transition per document for every mounted instance).
103
+ clientIdentity?: string
104
+ // read this instance's Zero client group ID. instances share one ID if and
105
+ // only if they share name, mutators, indexes, and schema version, so a
106
+ // post-recovery failure proves by comparison whether the recovery actually
107
+ // landed new code. without it the transition falls back to a one-retry
108
+ // budget before going terminal.
109
+ getGroupID?: () => Promise<string> | string
110
+ // wait for the reminted replacement to connect. in-place path only: the
111
+ // transition resolves only when every reminted participant reports
112
+ // connected. defaults to resolving immediately.
113
+ awaitReconnected?: () => Promise<boolean>
114
+ }
115
+
116
+ // one participant of an in-progress group transition: a mounted Zero instance
117
+ // (identified by its recovery closures, which are stable per instance) that
118
+ // signaled while the transition was open.
119
+ type TransitionParticipant = {
120
+ deps: ZeroRecoveryDeps
121
+ // whether this participant's stale store must be dropped before recovery.
122
+ // SchemaVersionNotSupported sets it; NewClientGroup never does (its data
123
+ // is still valid, and the store is shared with sibling tabs).
124
+ dropLocalState: boolean
125
+ deleteExecuted: boolean
126
+ remintExecuted: boolean
127
+ reminted: boolean
128
+ skipped: boolean
129
+ // the instance's group when it joined (undefined when unreadable).
130
+ oldGroupID: string | undefined
131
+ wrappedRemint: (() => Promise<boolean>) | null
132
+ }
133
+
134
+ type GroupTransition = {
135
+ key: string
136
+ reasons: Set<ZeroRecoveryReasonKey>
137
+ participants: Map<object, TransitionParticipant>
138
+ // every instance constructed after this sequence number belongs to the new
139
+ // code: if one of those fires while the transition is still open, the
140
+ // recovery did not fix it (or even newer code superseded it).
141
+ startSeq: number
142
+ startedAtMs: number
143
+ // set when a reload run gathers this transition's work: joins after that
144
+ // run their delete/remint immediately instead of waiting for a next run.
145
+ remintsExecuted: boolean
146
+ pendingWaits: number
147
+ settled: boolean
148
+ outcome: 'completed' | 'terminal' | 'reloading' | 'superseded' | null
149
+ // releases connected-waits when the transition settles another way
150
+ // (terminal, superseded): without this a settle during a wait would leave
151
+ // the reload run awaiting forever and wedge the latch with it.
152
+ settledPromise: Promise<void>
153
+ notifySettled: () => void
88
154
  }
89
155
 
90
- // one reload per page-load. the local-state deletes that must precede it are
91
- // collected here so EVERY affected instance (a combined client has several) drops
92
- // its own store before we reload — not just whichever instance fired first. they
93
- // are thunks (not started promises) so a deferred reload can't leave the app
94
- // running on an already-deleted store: the deletes run only when the reload
95
- // actually proceeds.
96
- let reloadScheduled = false
97
- let reloadInProgress = false
98
- let reloadLatchTimer: ReturnType<typeof setTimeout> | undefined
99
- const pendingDeletes: Array<() => Promise<unknown>> = []
100
- const pendingInPlaceRecoveries: Array<() => Promise<boolean>> = []
156
+ type TerminalStamp = {
157
+ t: number
158
+ groups: string[]
159
+ }
160
+
161
+ // everything recovery coordinates through, shared across dual-loaded module
162
+ // copies (cjs/esm) via globalValue. before, each copy kept its own reload
163
+ // latch and guard: N copies scheduled N reloads and the second copy's guard
164
+ // check went fatal while the first copy's reload was still pending. now one
165
+ // transition, one latch, and one guard serve the whole realm.
166
+ type RecoverySharedState = {
167
+ // one reload per page-load. the local-state deletes that must precede it are
168
+ // collected so EVERY affected instance drops its own store before we reload.
169
+ // they are thunks (not started promises) so a deferred reload can't leave the
170
+ // app running on an already-deleted store: the deletes run only when the
171
+ // reload actually proceeds. keyed by owner so the same instance never queues
172
+ // its delete twice and a group transition can take a delete over.
173
+ reloadScheduled: boolean
174
+ reloadInProgress: boolean
175
+ reloadLatchTimer: ReturnType<typeof setTimeout> | undefined
176
+ pendingDeletes: Array<{ owner: object; run: () => Promise<unknown> }>
177
+ pendingInPlaceRecoveries: Array<() => Promise<boolean>>
178
+ // within-a-page-load per-reason guard for the non-transition reasons: real
179
+ // loop protection everywhere, including Hermes (no storage needed). a reload
180
+ // wipes it, which is why the injectable storage below survives across reloads.
181
+ inMemoryGuard: Map<string, number>
182
+ constructionSeq: number
183
+ constructionSeqByDeps: WeakMap<object, number>
184
+ groupByDeps: WeakMap<object, string>
185
+ transitions: Map<string, GroupTransition>
186
+ terminalMemory: Map<string, TerminalStamp>
187
+ terminalEmitted: Map<string, number>
188
+ unknownPostSignals: Map<string, { count: number; windowStartMs: number }>
189
+ lastStoreClosedAtMs: number
190
+ }
191
+
192
+ function sharedRecoveryState(): RecoverySharedState {
193
+ return globalValue<RecoverySharedState>('on-zero:recovery-shared', () => ({
194
+ reloadScheduled: false,
195
+ reloadInProgress: false,
196
+ reloadLatchTimer: undefined,
197
+ pendingDeletes: [],
198
+ pendingInPlaceRecoveries: [],
199
+ inMemoryGuard: new Map(),
200
+ constructionSeq: 0,
201
+ constructionSeqByDeps: new WeakMap(),
202
+ groupByDeps: new WeakMap(),
203
+ transitions: new Map(),
204
+ terminalMemory: new Map(),
205
+ terminalEmitted: new Map(),
206
+ unknownPostSignals: new Map(),
207
+ lastStoreClosedAtMs: 0,
208
+ }))
209
+ }
210
+
211
+ function documentScope(): string {
212
+ try {
213
+ return globalThis.location?.href ?? ''
214
+ } catch {
215
+ return ''
216
+ }
217
+ }
101
218
 
102
- // within-a-page-load per-reason guard: real loop protection everywhere,
103
- // including Hermes (no storage needed). a reload wipes it, which is why the
104
- // injectable storage below survives across reloads.
105
- const inMemoryGuard = new Map<string, number>()
219
+ function guardStorageKey(reasonKey: ZeroRecoveryReasonKey): string {
220
+ return `on-zero-recover-${documentScope()}-${reasonKey}`
221
+ }
106
222
 
107
223
  function defaultGuardStorage(): RecoveryGuardStorage | undefined {
108
224
  try {
@@ -114,11 +230,14 @@ function defaultGuardStorage(): RecoveryGuardStorage | undefined {
114
230
  }
115
231
  }
116
232
 
117
- // cross-page-load, per-reason guard: distinct keys mean a NewClientGroup recovery
118
- // never suppresses a later SchemaVersionNotSupported one. the in-memory tier
119
- // catches re-fires within a page-load (works on Hermes); the storage tier
120
- // survives the reload, so a reason that reloaded then immediately re-fires is
121
- // caught as a genuine fatal instead of reload-storming across loads.
233
+ // cross-page-load, per-reason guard for the NON-transition reasons: distinct
234
+ // keys mean a client-state-not-found recovery never suppresses a later
235
+ // desync one. the in-memory tier catches re-fires within a page-load (works
236
+ // on Hermes); the storage tier survives the reload, so a reason that reloaded
237
+ // then immediately re-fires is caught as a genuine fatal instead of
238
+ // reload-storming across loads. group-transition reasons use the richer
239
+ // markers below instead (they must tell "the reload didn't help" apart from
240
+ // "newer code landed since").
122
241
  function recoveryGuardOpen(
123
242
  reasonKey: ZeroRecoveryReasonKey,
124
243
  guardStorage: RecoveryGuardStorage | undefined
@@ -127,14 +246,10 @@ function recoveryGuardOpen(
127
246
  // its guard to the current document so one preview frame recovering does not
128
247
  // falsely fatal a sibling frame. the URL survives a reload, preserving the
129
248
  // cross-reload loop guard for the document that actually recovered.
130
- let documentScope = ''
131
- try {
132
- documentScope = globalThis.location?.href ?? ''
133
- } catch {}
134
- const key = `on-zero-recover-${documentScope}-${reasonKey}`
249
+ const key = guardStorageKey(reasonKey)
135
250
  const now = Date.now()
136
251
 
137
- const memLast = inMemoryGuard.get(key) ?? 0
252
+ const memLast = sharedRecoveryState().inMemoryGuard.get(key) ?? 0
138
253
  if (memLast > 0 && now - memLast < RECOVER_GUARD_MS) return false
139
254
 
140
255
  const storage = guardStorage ?? defaultGuardStorage()
@@ -149,20 +264,81 @@ function recoveryGuardOpen(
149
264
  }
150
265
  }
151
266
 
152
- inMemoryGuard.set(key, now)
267
+ sharedRecoveryState().inMemoryGuard.set(key, now)
153
268
  return true
154
269
  }
155
270
 
156
- // "Store is closed" is noisy during intentional instance replacement. One log is
157
- // teardown noise; repeated logs seconds apart mean a live client is stuck using a
158
- // closed local store and needs fresh local state.
159
- let lastStoreClosedAtMs = 0
271
+ // what a group transition persists across a reload: when it fired, which
272
+ // client groups it recovered from, and whether it ended terminal. legacy
273
+ // writers stored a bare timestamp; those read back as { t } with unknown
274
+ // groups and keep the legacy conservative behavior.
275
+ type RecoveryMarker = {
276
+ t: number
277
+ groups?: string[]
278
+ terminal?: boolean
279
+ msg?: string
280
+ }
281
+
282
+ function readRecoveryMarker(
283
+ reasonKey: ZeroRecoveryReasonKey,
284
+ guardStorage: RecoveryGuardStorage | undefined
285
+ ): RecoveryMarker | null {
286
+ const storage = guardStorage ?? defaultGuardStorage()
287
+ if (!storage) return null
288
+ let raw: string | null = null
289
+ try {
290
+ raw = storage.getItem(guardStorageKey(reasonKey))
291
+ } catch {
292
+ return null
293
+ }
294
+ if (!raw) return null
295
+ if (/^\d+$/.test(raw)) return { t: Number(raw) }
296
+ try {
297
+ const parsed = JSON.parse(raw) as RecoveryMarker
298
+ if (!parsed || typeof parsed.t !== 'number') return null
299
+ return parsed
300
+ } catch {
301
+ return null
302
+ }
303
+ }
304
+
305
+ function writeRecoveryMarker(
306
+ reasonKey: ZeroRecoveryReasonKey,
307
+ marker: RecoveryMarker,
308
+ guardStorage: RecoveryGuardStorage | undefined
309
+ ): void {
310
+ const storage = guardStorage ?? defaultGuardStorage()
311
+ if (!storage) return
312
+ try {
313
+ storage.setItem(guardStorageKey(reasonKey), JSON.stringify(marker))
314
+ } catch {
315
+ // storage unavailable mid-flight — the in-memory tier is the floor.
316
+ }
317
+ }
318
+
319
+ function clearRecoveryMarker(
320
+ reasonKey: ZeroRecoveryReasonKey,
321
+ guardStorage: RecoveryGuardStorage | undefined
322
+ ): void {
323
+ const storage = guardStorage ?? defaultGuardStorage()
324
+ if (!storage) return
325
+ try {
326
+ if (typeof storage.removeItem === 'function') {
327
+ storage.removeItem(guardStorageKey(reasonKey))
328
+ } else {
329
+ storage.setItem(guardStorageKey(reasonKey), JSON.stringify({ t: 0 }))
330
+ }
331
+ } catch {
332
+ // storage unavailable mid-flight — the next read treats it as stale.
333
+ }
334
+ }
160
335
 
161
336
  function armReloadLatchTimeout() {
337
+ const shared = sharedRecoveryState()
162
338
  if (typeof setTimeout !== 'function') return
163
- if (reloadLatchTimer) clearTimeout(reloadLatchTimer)
164
- reloadLatchTimer = setTimeout(() => {
165
- reloadLatchTimer = undefined
339
+ if (shared.reloadLatchTimer) clearTimeout(shared.reloadLatchTimer)
340
+ shared.reloadLatchTimer = setTimeout(() => {
341
+ shared.reloadLatchTimer = undefined
166
342
  // re-open SCHEDULING only. a reload that never landed (still deferred behind
167
343
  // a consumer's IDE gate / countdown, which can hold for minutes) must not
168
344
  // kill future recovery — but the deferred performReload still owns the
@@ -170,32 +346,561 @@ function armReloadLatchTimeout() {
170
346
  // beforeReload can exceed this timeout), reloadInProgress must stay set so a
171
347
  // second recovery can't double-drive the reload. so touch neither
172
348
  // pendingDeletes nor reloadInProgress here.
173
- reloadScheduled = false
349
+ shared.reloadScheduled = false
174
350
  }, RELOAD_LATCH_TIMEOUT_MS)
175
351
  // don't hold the event loop open (node/native) waiting on this defense timer.
176
352
  if (
177
- reloadLatchTimer &&
178
- typeof reloadLatchTimer === 'object' &&
179
- 'unref' in reloadLatchTimer
353
+ shared.reloadLatchTimer &&
354
+ typeof shared.reloadLatchTimer === 'object' &&
355
+ 'unref' in shared.reloadLatchTimer
180
356
  ) {
181
- reloadLatchTimer.unref()
357
+ shared.reloadLatchTimer.unref()
182
358
  }
183
359
  }
184
360
 
185
361
  function disarmReloadLatchTimeout() {
186
- if (reloadLatchTimer) {
187
- clearTimeout(reloadLatchTimer)
188
- reloadLatchTimer = undefined
362
+ const shared = sharedRecoveryState()
363
+ if (shared.reloadLatchTimer) {
364
+ clearTimeout(shared.reloadLatchTimer)
365
+ shared.reloadLatchTimer = undefined
366
+ }
367
+ }
368
+
369
+ function pushGlobalDelete(deps: ZeroRecoveryDeps): void {
370
+ const shared = sharedRecoveryState()
371
+ if (shared.pendingDeletes.some((entry) => entry.owner === deps.deleteLocalState)) {
372
+ return
373
+ }
374
+ shared.pendingDeletes.push({
375
+ owner: deps.deleteLocalState,
376
+ run: () => Promise.resolve().then(deps.deleteLocalState),
377
+ })
378
+ }
379
+
380
+ function removeGlobalDelete(owner: object): void {
381
+ const pending = sharedRecoveryState().pendingDeletes
382
+ const index = pending.findIndex((entry) => entry.owner === owner)
383
+ if (index !== -1) pending.splice(index, 1)
384
+ }
385
+
386
+ function removeGlobalRemint(fn: (() => Promise<boolean>) | undefined): void {
387
+ if (!fn) return
388
+ const pending = sharedRecoveryState().pendingInPlaceRecoveries
389
+ const index = pending.indexOf(fn)
390
+ if (index !== -1) pending.splice(index, 1)
391
+ }
392
+
393
+ // the construction sequence number tells pre-transition instances (built
394
+ // before the transition started: they join it) from post-transition ones
395
+ // (built by the transition's own remints: if one of those fires, the recovery
396
+ // did not fix it). stamped in makeZeroRecovery, which runs once per instance.
397
+ function seqOf(deps: ZeroRecoveryDeps): number {
398
+ const shared = sharedRecoveryState()
399
+ let seq = shared.constructionSeqByDeps.get(deps)
400
+ if (seq === undefined) {
401
+ seq = ++shared.constructionSeq
402
+ shared.constructionSeqByDeps.set(deps, seq)
403
+ }
404
+ return seq
405
+ }
406
+
407
+ function refreshGroupCache(deps: ZeroRecoveryDeps): void {
408
+ if (!deps.getGroupID) return
409
+ let result: Promise<string> | string
410
+ try {
411
+ result = deps.getGroupID()
412
+ } catch {
413
+ return
414
+ }
415
+ if (typeof result === 'string') {
416
+ if (result) sharedRecoveryState().groupByDeps.set(deps, result)
417
+ return
418
+ }
419
+ void Promise.resolve(result).then(
420
+ (id) => {
421
+ if (typeof id === 'string' && id) sharedRecoveryState().groupByDeps.set(deps, id)
422
+ },
423
+ () => {}
424
+ )
425
+ }
426
+
427
+ function cachedGroupID(deps: ZeroRecoveryDeps): string | undefined {
428
+ return sharedRecoveryState().groupByDeps.get(deps)
429
+ }
430
+
431
+ // prime the group cache right after construction so the synchronous read at
432
+ // signal time hits: signals always fire post-open, long after this resolves.
433
+ export function primeGroupCache(deps: ZeroRecoveryDeps): void {
434
+ refreshGroupCache(deps)
435
+ }
436
+
437
+ function participantKey(deps: ZeroRecoveryDeps): object {
438
+ return deps.recoverInPlace ?? deps.deleteLocalState
439
+ }
440
+
441
+ function transitionKey(deps: ZeroRecoveryDeps): string {
442
+ return `${documentScope()}\0${deps.clientIdentity ?? ''}`
443
+ }
444
+
445
+ function findOpenTransitionParticipant(
446
+ deps: ZeroRecoveryDeps
447
+ ): TransitionParticipant | undefined {
448
+ const transition = sharedRecoveryState().transitions.get(transitionKey(deps))
449
+ if (!transition || transition.settled) return undefined
450
+ // a post-transition instance is never a participant of the open transition.
451
+ if (seqOf(deps) > transition.startSeq) return undefined
452
+ return transition.participants.get(participantKey(deps))
453
+ }
454
+
455
+ // true while any group transition is in flight. remint() consults this: a
456
+ // transition owns the retry policy (one remint per provider per transition,
457
+ // terminal-on-failure), so its remints bypass the cooldown/attempt budget and
458
+ // rapid legitimate transitions all land.
459
+ export function isGroupTransitionOpen(): boolean {
460
+ return sharedRecoveryState().transitions.size > 0
461
+ }
462
+
463
+ function terminalKey(key: string, reasonKey: ZeroRecoveryReasonKey): string {
464
+ return `${key}\0${reasonKey}`
465
+ }
466
+
467
+ function stampTerminal(
468
+ key: string,
469
+ reasonKey: ZeroRecoveryReasonKey,
470
+ guardStorage: RecoveryGuardStorage | undefined,
471
+ message: string,
472
+ groups: string[]
473
+ ): void {
474
+ sharedRecoveryState().terminalMemory.set(terminalKey(key, reasonKey), {
475
+ t: Date.now(),
476
+ groups,
477
+ })
478
+ writeRecoveryMarker(
479
+ reasonKey,
480
+ { t: Date.now(), terminal: true, groups, msg: message },
481
+ guardStorage
482
+ )
483
+ }
484
+
485
+ // the one terminal error a failed transition emits: a single console error
486
+ // carrying the old/new group IDs, one fatal event, and one host event — never
487
+ // a cascade, never a loop. re-emits after the window expires so a failure an
488
+ // hour later still surfaces.
489
+ function emitTerminalOnce(
490
+ deps: ZeroRecoveryDeps,
491
+ key: string,
492
+ reasonKey: ZeroRecoveryReasonKey,
493
+ message: string
494
+ ): void {
495
+ const shared = sharedRecoveryState()
496
+ const now = Date.now()
497
+ const emitKey = terminalKey(key, reasonKey)
498
+ const lastEmitted = shared.terminalEmitted.get(emitKey) ?? 0
499
+ if (lastEmitted > 0 && now - lastEmitted < RECOVER_GUARD_MS) return
500
+ shared.terminalEmitted.set(emitKey, now)
501
+ try {
502
+ console.error(`[on-zero] ${message}`)
503
+ deps.zeroEvents.emit({ type: 'fatal', reasonKey, reason: message })
504
+ } catch {}
505
+ // hosts embedding this app (preview shells, test harnesses) observe the
506
+ // terminal state without sharing the module instance: a plain event on the
507
+ // realm's global scope.
508
+ try {
509
+ globalThis.dispatchEvent?.(
510
+ new CustomEvent('on-zero-fatal', { detail: { reasonKey, reason: message } })
511
+ )
512
+ } catch {}
513
+ }
514
+
515
+ function settleTransition(
516
+ transition: GroupTransition,
517
+ outcome: GroupTransition['outcome']
518
+ ): void {
519
+ if (transition.settled) return
520
+ transition.settled = true
521
+ transition.outcome = outcome
522
+ sharedRecoveryState().transitions.delete(transition.key)
523
+ transition.notifySettled()
524
+ }
525
+
526
+ function checkTransitionComplete(transition: GroupTransition): void {
527
+ if (transition.settled) return
528
+ for (const participant of transition.participants.values()) {
529
+ if (!participant.reminted && !participant.skipped) return
530
+ }
531
+ if (transition.pendingWaits > 0) return
532
+ settleTransition(transition, 'completed')
533
+ }
534
+
535
+ function failTransition(
536
+ transition: GroupTransition,
537
+ input: {
538
+ reasonKey: ZeroRecoveryReasonKey
539
+ message: string
540
+ signalDeps: ZeroRecoveryDeps
541
+ }
542
+ ): void {
543
+ if (transition.settled) return
544
+ const groups = [
545
+ ...new Set(
546
+ [...transition.participants.values()]
547
+ .map((participant) => participant.oldGroupID)
548
+ .filter((group): group is string => typeof group === 'string')
549
+ ),
550
+ ]
551
+ settleTransition(transition, 'terminal')
552
+ // the transition failed as a whole: stamp every reason it carried plus the
553
+ // signal's own, so a re-fire on any of them stays silent this window.
554
+ for (const reasonKey of new Set([...transition.reasons, input.reasonKey])) {
555
+ stampTerminal(
556
+ transition.key,
557
+ reasonKey,
558
+ input.signalDeps.guardStorage,
559
+ input.message,
560
+ groups
561
+ )
562
+ }
563
+ emitTerminalOnce(input.signalDeps, transition.key, input.reasonKey, input.message)
564
+ }
565
+
566
+ function makeWrappedRemint(
567
+ transition: GroupTransition,
568
+ participant: TransitionParticipant
569
+ ): () => Promise<boolean> {
570
+ return async () => {
571
+ if (participant.skipped || participant.reminted) return true
572
+ const remint = participant.deps.recoverInPlace
573
+ if (!remint) {
574
+ participant.skipped = true
575
+ checkTransitionComplete(transition)
576
+ return false
577
+ }
578
+ let ok: boolean
579
+ try {
580
+ ok = await remint()
581
+ } catch (error) {
582
+ // the recovery itself is broken — fail loud once rather than spin.
583
+ const [firstReason] = transition.reasons
584
+ failTransition(transition, {
585
+ reasonKey: firstReason ?? 'NewClientGroup',
586
+ message: `in-place recovery threw (${error instanceof Error ? error.message : String(error)})`,
587
+ signalDeps: participant.deps,
588
+ })
589
+ return false
590
+ }
591
+ // false means "nothing to reconstruct" (the provider unmounted
592
+ // mid-transition): skip the connected wait, don't fail the transition.
593
+ if (!ok) {
594
+ participant.skipped = true
595
+ checkTransitionComplete(transition)
596
+ return false
597
+ }
598
+ participant.reminted = true
599
+ const awaitReconnected = participant.deps.awaitReconnected
600
+ if (awaitReconnected) {
601
+ transition.pendingWaits += 1
602
+ try {
603
+ // released early when the transition settles another way so the
604
+ // reload run awaiting this remint can finish and reopen the latch.
605
+ await Promise.race([awaitReconnected(), transition.settledPromise])
606
+ } catch {
607
+ // a wait that rejects is over either way; the transition resolves
608
+ // from whoever is left, and a still-stale instance re-fires into a
609
+ // new transition.
610
+ } finally {
611
+ transition.pendingWaits -= 1
612
+ }
613
+ }
614
+ checkTransitionComplete(transition)
615
+ return true
616
+ }
617
+ }
618
+
619
+ async function runParticipantImmediate(
620
+ transition: GroupTransition,
621
+ participant: TransitionParticipant
622
+ ): Promise<void> {
623
+ // claim both flags synchronously: a reload run gathering in a later
624
+ // microtask must skip this participant, never double-run it.
625
+ const doDelete = participant.dropLocalState && !participant.deleteExecuted
626
+ const doRemint = !participant.remintExecuted
627
+ participant.deleteExecuted = true
628
+ participant.remintExecuted = true
629
+ if (doDelete) {
630
+ try {
631
+ await participant.deps.deleteLocalState()
632
+ } catch {}
633
+ }
634
+ if (doRemint) {
635
+ if (participant.wrappedRemint) await participant.wrappedRemint()
636
+ else {
637
+ participant.skipped = true
638
+ checkTransitionComplete(transition)
639
+ }
640
+ }
641
+ }
642
+
643
+ function joinTransition(
644
+ transition: GroupTransition,
645
+ deps: ZeroRecoveryDeps,
646
+ reasonKey: ZeroRecoveryReasonKey,
647
+ dropLocalState: boolean
648
+ ): void {
649
+ transition.reasons.add(reasonKey)
650
+ const key = participantKey(deps)
651
+ const existing = transition.participants.get(key)
652
+ if (existing) {
653
+ // same instance firing again (or for a second reason) while the
654
+ // transition is open: dedupe, folding a stricter drop need in.
655
+ if (dropLocalState && !existing.dropLocalState) {
656
+ existing.dropLocalState = true
657
+ // the delete already ran without the drop: queue it for the next run
658
+ // rather than strand it.
659
+ if (existing.deleteExecuted) pushGlobalDelete(deps)
660
+ }
661
+ return
662
+ }
663
+ // this instance's recovery now belongs to the transition: take over any
664
+ // globally-queued work so it can neither double-run nor strand.
665
+ removeGlobalDelete(deps.deleteLocalState)
666
+ removeGlobalRemint(deps.recoverInPlace)
667
+ const participant: TransitionParticipant = {
668
+ deps,
669
+ dropLocalState,
670
+ deleteExecuted: false,
671
+ remintExecuted: false,
672
+ reminted: false,
673
+ skipped: false,
674
+ oldGroupID: cachedGroupID(deps),
675
+ wrappedRemint: null,
676
+ }
677
+ participant.wrappedRemint = makeWrappedRemint(transition, participant)
678
+ transition.participants.set(key, participant)
679
+ // joins stay silent (the transition's one recovering already fired), but a
680
+ // join after the reload run gathered must execute now — the run won't come
681
+ // back for it.
682
+ if (transition.remintsExecuted) void runParticipantImmediate(transition, participant)
683
+ }
684
+
685
+ function startTransition(
686
+ deps: ZeroRecoveryDeps,
687
+ reasonKey: ZeroRecoveryReasonKey,
688
+ message: string,
689
+ dropLocalState: boolean
690
+ ): void {
691
+ const shared = sharedRecoveryState()
692
+ const key = transitionKey(deps)
693
+ let notifySettled!: () => void
694
+ const settledPromise = new Promise<void>((resolve) => {
695
+ notifySettled = resolve
696
+ })
697
+ const transition: GroupTransition = {
698
+ key,
699
+ reasons: new Set([reasonKey]),
700
+ participants: new Map(),
701
+ startSeq: shared.constructionSeq,
702
+ startedAtMs: Date.now(),
703
+ remintsExecuted: false,
704
+ pendingWaits: 0,
705
+ settled: false,
706
+ outcome: null,
707
+ settledPromise,
708
+ notifySettled,
709
+ }
710
+ shared.transitions.set(key, transition)
711
+ joinTransition(transition, deps, reasonKey, dropLocalState)
712
+ console.warn(`[on-zero] ${message} — recovering`)
713
+ deps.zeroEvents.emit({ type: 'recovering', reasonKey, reason: message })
714
+
715
+ // a pending (not yet running) reload gathers this transition's work when it
716
+ // runs: piggyback on it. a mid-flight reload already gathered: run ours
717
+ // immediately instead of stranding it.
718
+ if (shared.reloadScheduled && !shared.reloadInProgress) return
719
+ if (shared.reloadInProgress) {
720
+ transition.remintsExecuted = true
721
+ for (const participant of transition.participants.values()) {
722
+ void runParticipantImmediate(transition, participant)
723
+ }
724
+ return
725
+ }
726
+ shared.reloadScheduled = true
727
+ armReloadLatchTimeout()
728
+
729
+ const runReload = () => performReload(deps)
730
+ if (deps.scheduleReload) {
731
+ deps.scheduleReload({
732
+ reason: message,
733
+ reasonKey,
734
+ dropLocalState,
735
+ performReload: runReload,
736
+ })
737
+ } else {
738
+ void runReload()
189
739
  }
190
740
  }
191
741
 
742
+ // no open transition: decide whether this failure may start one. signals that
743
+ // arrive while a transition is in flight join it and never touch the guard —
744
+ // only a failure after a completed transition consumes it. verifiable progress
745
+ // (the firing instance's group differs from every group on record) always
746
+ // starts a new transition, so rapid legitimate changes all land; an unchanged
747
+ // group after a reload, or a repeated failure with unreadable groups, goes
748
+ // terminal once instead of looping.
749
+ function checkTransitionGuard(
750
+ deps: ZeroRecoveryDeps,
751
+ reasonKey: ZeroRecoveryReasonKey
752
+ ): 'allow' | 'silent' {
753
+ const shared = sharedRecoveryState()
754
+ const key = transitionKey(deps)
755
+ const now = Date.now()
756
+ const myGroup = cachedGroupID(deps)
757
+
758
+ const memory = shared.terminalMemory.get(terminalKey(key, reasonKey))
759
+ if (memory && memory.t > 0 && now - memory.t < RECOVER_GUARD_MS) {
760
+ if (
761
+ myGroup !== undefined &&
762
+ memory.groups.length > 0 &&
763
+ !memory.groups.includes(myGroup)
764
+ ) {
765
+ // newer code landed since the terminal: retry.
766
+ shared.terminalMemory.delete(terminalKey(key, reasonKey))
767
+ } else {
768
+ return 'silent'
769
+ }
770
+ }
771
+
772
+ const marker = readRecoveryMarker(reasonKey, deps.guardStorage)
773
+ if (marker && marker.t > 0 && now - marker.t < RECOVER_GUARD_MS) {
774
+ const markerGroups =
775
+ marker.groups && marker.groups.length > 0 ? marker.groups : undefined
776
+ if (
777
+ myGroup !== undefined &&
778
+ markerGroups !== undefined &&
779
+ !markerGroups.includes(myGroup)
780
+ ) {
781
+ // the firing instance already runs newer code than the recorded
782
+ // recovery did: clear the marker and start a fresh transition.
783
+ clearRecoveryMarker(reasonKey, deps.guardStorage)
784
+ return 'allow'
785
+ }
786
+ if (marker.terminal) {
787
+ emitTerminalOnce(
788
+ deps,
789
+ key,
790
+ reasonKey,
791
+ marker.msg ??
792
+ `update needed (${reasonKey}) — recovery already failed, not retrying`
793
+ )
794
+ return 'silent'
795
+ }
796
+ if (
797
+ myGroup !== undefined &&
798
+ markerGroups !== undefined &&
799
+ markerGroups.includes(myGroup)
800
+ ) {
801
+ // the recorded reload did not change the client group: the reload
802
+ // can't fix this, so go terminal once with the IDs.
803
+ const message = `update needed (${reasonKey}) — client group ${myGroup} → ${myGroup} (unchanged after reload), not reloading`
804
+ stampTerminal(key, reasonKey, deps.guardStorage, message, markerGroups)
805
+ emitTerminalOnce(deps, key, reasonKey, message)
806
+ return 'silent'
807
+ }
808
+ // unreadable groups plus a fresh reload marker: stay conservative
809
+ // (legacy parity) and go terminal once rather than risk a reload loop.
810
+ const message = `update needed (${reasonKey}) — already recovered once, not reloading`
811
+ stampTerminal(key, reasonKey, deps.guardStorage, message, markerGroups ?? [])
812
+ emitTerminalOnce(deps, key, reasonKey, message)
813
+ return 'silent'
814
+ }
815
+
816
+ return 'allow'
817
+ }
818
+
819
+ // an instance built by the open transition's own remints is firing: the
820
+ // recovery did not fix it, or even newer code superseded it. an unchanged
821
+ // group proves the former and fails the transition terminally; a changed
822
+ // group proves the latter and supersedes into a fresh transition.
823
+ function handlePostTransitionSignal(
824
+ transition: GroupTransition,
825
+ deps: ZeroRecoveryDeps,
826
+ reasonKey: ZeroRecoveryReasonKey,
827
+ message: string,
828
+ dropLocalState: boolean
829
+ ): void {
830
+ const shared = sharedRecoveryState()
831
+ const newGroup = cachedGroupID(deps)
832
+ const oldGroups = [
833
+ ...new Set(
834
+ [...transition.participants.values()]
835
+ .map((participant) => participant.oldGroupID)
836
+ .filter((group): group is string => typeof group === 'string')
837
+ ),
838
+ ]
839
+ if (newGroup !== undefined && oldGroups.length > 0) {
840
+ if (!oldGroups.includes(newGroup)) {
841
+ // verifiably newer code: the transition's target is obsolete, not
842
+ // failed. supersede it — no guard consumed — and start fresh.
843
+ settleTransition(transition, 'superseded')
844
+ startTransition(deps, reasonKey, message, dropLocalState)
845
+ return
846
+ }
847
+ failTransition(transition, {
848
+ reasonKey,
849
+ message: `${message} — client group ${newGroup} did not change after recovery (still ${oldGroups.join(', ')}), not retrying`,
850
+ signalDeps: deps,
851
+ })
852
+ return
853
+ }
854
+ // groups unreadable: allow one supersede per window (the overlapping-edit
855
+ // case), then go terminal rather than remint-loop.
856
+ const now = Date.now()
857
+ const budget = shared.unknownPostSignals.get(transition.key)
858
+ const freshBudget =
859
+ budget && now - budget.windowStartMs < RECOVER_GUARD_MS ? budget : null
860
+ if (freshBudget && freshBudget.count >= 1) {
861
+ failTransition(transition, {
862
+ reasonKey,
863
+ message: `${message} — recovery did not resolve the client group, not retrying`,
864
+ signalDeps: deps,
865
+ })
866
+ return
867
+ }
868
+ shared.unknownPostSignals.set(transition.key, {
869
+ count: (freshBudget?.count ?? 0) + 1,
870
+ windowStartMs: freshBudget?.windowStartMs ?? now,
871
+ })
872
+ settleTransition(transition, 'superseded')
873
+ startTransition(deps, reasonKey, message, dropLocalState)
874
+ }
875
+
876
+ function recoverViaGroupTransition(
877
+ deps: ZeroRecoveryDeps,
878
+ reasonKey: ZeroRecoveryReasonKey,
879
+ message: string,
880
+ dropLocalState: boolean
881
+ ): void {
882
+ const key = transitionKey(deps)
883
+ const open = sharedRecoveryState().transitions.get(key)
884
+ if (open && !open.settled) {
885
+ if (seqOf(deps) > open.startSeq) {
886
+ handlePostTransitionSignal(open, deps, reasonKey, message, dropLocalState)
887
+ return
888
+ }
889
+ joinTransition(open, deps, reasonKey, dropLocalState)
890
+ return
891
+ }
892
+ if (checkTransitionGuard(deps, reasonKey) !== 'allow') return
893
+ startTransition(deps, reasonKey, message, dropLocalState)
894
+ }
895
+
192
896
  // deletes every collected instance's local store, awaits beforeReload, then
193
897
  // reloads. deferred a microtask so sibling instances failing in the same tick
194
898
  // enqueue their deletes first. idempotent: a consumer that calls performReload
195
899
  // twice (or after the latch timeout) only runs one reload.
196
900
  function performReload(deps: ZeroRecoveryDeps): Promise<void> {
197
- if (reloadInProgress) return Promise.resolve()
198
- reloadInProgress = true
901
+ const shared = sharedRecoveryState()
902
+ if (shared.reloadInProgress) return Promise.resolve()
903
+ shared.reloadInProgress = true
199
904
  // the consumer has committed to reloading, so the un-latch timeout has no job
200
905
  // now — disarm it before the (possibly slow) delete/beforeReload chain so it
201
906
  // can't fire mid-flight and re-open scheduling under an in-progress reload.
@@ -213,20 +918,79 @@ function performReload(deps: ZeroRecoveryDeps): Promise<void> {
213
918
  reload.call(globalThis.location)
214
919
  return true
215
920
  })
216
- let inPlaceRecoveries: Array<() => Promise<boolean>> = []
921
+ // gather in the first microtask so sibling instances failing in the same
922
+ // tick enqueue first: every synchronous signal runs before any microtask
923
+ // does. a transition that joins after the gather runs its own work
924
+ // immediately instead of waiting for a next run.
925
+ let globalDeletes: Array<{ owner: object; run: () => Promise<unknown> }> = []
926
+ let globalRemints: Array<() => Promise<boolean>> = []
927
+ let openTransitions: GroupTransition[] = []
928
+ const transitionDeletes: Array<() => Promise<unknown>> = []
929
+ const transitionRemints: Array<{
930
+ transition: GroupTransition
931
+ run: () => Promise<boolean>
932
+ }> = []
217
933
  return Promise.resolve()
218
934
  .then(() => {
219
- inPlaceRecoveries = pendingInPlaceRecoveries.splice(0)
220
- return Promise.allSettled(pendingDeletes.splice(0).map((run) => run()))
935
+ globalDeletes = shared.pendingDeletes.splice(0)
936
+ globalRemints = shared.pendingInPlaceRecoveries.splice(0)
937
+ openTransitions = [...shared.transitions.values()].filter(
938
+ (transition) => !transition.settled
939
+ )
940
+ for (const transition of openTransitions) {
941
+ transition.remintsExecuted = true
942
+ for (const participant of transition.participants.values()) {
943
+ if (participant.dropLocalState && !participant.deleteExecuted) {
944
+ participant.deleteExecuted = true
945
+ const runDelete = participant.deps.deleteLocalState
946
+ transitionDeletes.push(() => Promise.resolve().then(runDelete))
947
+ }
948
+ if (!participant.remintExecuted) {
949
+ participant.remintExecuted = true
950
+ if (participant.wrappedRemint) {
951
+ transitionRemints.push({
952
+ transition,
953
+ run: participant.wrappedRemint,
954
+ })
955
+ } else {
956
+ participant.skipped = true
957
+ checkTransitionComplete(transition)
958
+ }
959
+ }
960
+ }
961
+ }
962
+ return Promise.allSettled([
963
+ ...globalDeletes.map((entry) => entry.run()),
964
+ ...transitionDeletes.map((run) => run()),
965
+ ])
221
966
  })
222
967
  .then(() => deps.beforeReload?.())
223
968
  .catch(() => {})
224
969
  .then(async () => {
225
- const reloadStarted = doReload()
226
- if (reloadStarted !== false) return
970
+ let reloadStarted: void | boolean = false
227
971
  try {
228
- const results = await Promise.all(inPlaceRecoveries.map((recover) => recover()))
229
- if (inPlaceRecoveries.length > 0 && results.some((recovered) => !recovered)) {
972
+ reloadStarted = doReload()
973
+ } catch {
974
+ // a throwing reload is an unavailable reload: fall through to the
975
+ // in-place path rather than strand the recovery.
976
+ reloadStarted = false
977
+ }
978
+ if (reloadStarted !== false) {
979
+ noteTransitionsReloading(openTransitions)
980
+ return
981
+ }
982
+ try {
983
+ // a transition may have settled (terminal, superseded) while the
984
+ // deletes/beforeReload ran: skip its gathered remints.
985
+ const liveRemints = transitionRemints.filter(
986
+ ({ transition }) => !transition.settled
987
+ )
988
+ const results = await Promise.all([
989
+ ...globalRemints.map((recover) => recover()),
990
+ ...liveRemints.map(({ run }) => run()),
991
+ ])
992
+ const globalResults = results.slice(0, globalRemints.length)
993
+ if (globalRemints.length > 0 && globalResults.some((recovered) => !recovered)) {
230
994
  console.error('[on-zero] recovery could not reload or reconstruct the client')
231
995
  }
232
996
  } catch (error) {
@@ -234,12 +998,38 @@ function performReload(deps: ZeroRecoveryDeps): Promise<void> {
234
998
  } finally {
235
999
  // a page reload tears down this module, but an in-place recovery keeps
236
1000
  // it alive. reopen the latch so a later, distinct failure can recover.
237
- reloadInProgress = false
238
- reloadScheduled = false
1001
+ shared.reloadInProgress = false
1002
+ shared.reloadScheduled = false
239
1003
  }
240
1004
  })
241
1005
  }
242
1006
 
1007
+ // the page is really going down for these transitions: persist which groups
1008
+ // they recovered from so the reloaded page can tell "the reload didn't help"
1009
+ // (same group → terminal once) from "newer code landed" (changed group → a
1010
+ // fresh transition is fine).
1011
+ function noteTransitionsReloading(transitions: readonly GroupTransition[]): void {
1012
+ for (const transition of transitions) {
1013
+ if (transition.settled) continue
1014
+ const groups = [
1015
+ ...new Set(
1016
+ [...transition.participants.values()]
1017
+ .map((participant) => participant.oldGroupID)
1018
+ .filter((group): group is string => typeof group === 'string')
1019
+ ),
1020
+ ]
1021
+ const [firstParticipant] = transition.participants.values()
1022
+ for (const reasonKey of transition.reasons) {
1023
+ writeRecoveryMarker(
1024
+ reasonKey,
1025
+ { t: Date.now(), groups },
1026
+ firstParticipant?.deps.guardStorage
1027
+ )
1028
+ }
1029
+ settleTransition(transition, 'reloading')
1030
+ }
1031
+ }
1032
+
243
1033
  function recover(
244
1034
  deps: ZeroRecoveryDeps,
245
1035
  reasonKey: ZeroRecoveryReasonKey,
@@ -248,17 +1038,34 @@ function recover(
248
1038
  ): void {
249
1039
  if (typeof window === 'undefined') return
250
1040
  deps.onRecovery?.(reasonKey)
251
- // each affected instance drops its OWN stale store, even when a reload is
252
- // already queued — otherwise a sibling's store survives the reload and
253
- // fatal-loops on the next boot. the single scheduled reload awaits these.
254
- if (dropLocalState) {
255
- pendingDeletes.push(() => Promise.resolve().then(deps.deleteLocalState))
1041
+ refreshGroupCache(deps)
1042
+ if (GROUP_TRANSITION_REASONS.has(reasonKey)) {
1043
+ recoverViaGroupTransition(deps, reasonKey, message, dropLocalState)
1044
+ return
256
1045
  }
257
- if (deps.recoverInPlace && !pendingInPlaceRecoveries.includes(deps.recoverInPlace)) {
258
- pendingInPlaceRecoveries.push(deps.recoverInPlace)
1046
+ const shared = sharedRecoveryState()
1047
+ const participant = findOpenTransitionParticipant(deps)
1048
+ if (!participant) {
1049
+ // each affected instance drops its OWN stale store, even when a reload is
1050
+ // already queued — otherwise a sibling's store survives the reload and
1051
+ // fatal-loops on the next boot. the single scheduled reload awaits these.
1052
+ if (dropLocalState) {
1053
+ pushGlobalDelete(deps)
1054
+ }
1055
+ if (
1056
+ deps.recoverInPlace &&
1057
+ !shared.pendingInPlaceRecoveries.includes(deps.recoverInPlace)
1058
+ ) {
1059
+ shared.pendingInPlaceRecoveries.push(deps.recoverInPlace)
1060
+ }
1061
+ } else if (dropLocalState && !participant.dropLocalState) {
1062
+ // the open transition owns this instance's recovery: fold the stricter
1063
+ // drop need into it instead of queueing a second delete.
1064
+ participant.dropLocalState = true
1065
+ if (participant.deleteExecuted) pushGlobalDelete(deps)
259
1066
  }
260
1067
  // only ONE reload per page-load; a later trigger just contributes its delete.
261
- if (reloadScheduled) return
1068
+ if (shared.reloadScheduled) return
262
1069
  if (!recoveryGuardOpen(reasonKey, deps.guardStorage)) {
263
1070
  console.error(`[on-zero] ${message} — already recovered once, not reloading`)
264
1071
  deps.zeroEvents.emit({ type: 'fatal', reasonKey, reason: message })
@@ -272,7 +1079,7 @@ function recover(
272
1079
  } catch {}
273
1080
  return
274
1081
  }
275
- reloadScheduled = true
1082
+ shared.reloadScheduled = true
276
1083
  armReloadLatchTimeout()
277
1084
  console.warn(`[on-zero] ${message} — recovering`)
278
1085
  deps.zeroEvents.emit({ type: 'recovering', reasonKey, reason: message })
@@ -294,6 +1101,9 @@ function recover(
294
1101
  // reloadWithReason, so these handlers are the ONLY recovery — they must cover
295
1102
  // every reason or the app fatal-blanks forever.
296
1103
  export function makeZeroRecovery(deps: ZeroRecoveryDeps) {
1104
+ // stamp the construction order now: post-transition instances (built by the
1105
+ // transition's own remints) must read newer than any transition they meet.
1106
+ seqOf(deps)
297
1107
  return {
298
1108
  onUpdateNeeded(reason: UpdateNeededReason) {
299
1109
  // every update-needed reason needs new client code → reload. but only
@@ -438,8 +1248,9 @@ export function classifyZeroRecoveryLog(
438
1248
  return { ...desync, dropLocalState: true }
439
1249
  }
440
1250
  if (text.includes(STORE_CLOSED) && !isBenignStoreClosedLog(text)) {
441
- const prevMs = lastStoreClosedAtMs
442
- lastStoreClosedAtMs = nowMs
1251
+ const shared = sharedRecoveryState()
1252
+ const prevMs = shared.lastStoreClosedAtMs
1253
+ shared.lastStoreClosedAtMs = nowMs
443
1254
  if (
444
1255
  prevMs > 0 &&
445
1256
  nowMs - prevMs >= STORE_CLOSED_REPEAT_MIN_MS &&
@@ -500,19 +1311,26 @@ export function isRecoverableZeroStalePokeMessage(message: string): boolean {
500
1311
  )
501
1312
  }
502
1313
 
503
- // test-only: the reload latch + pending work + in-memory guard are in-memory
504
- // (a real page reload clears them); tests simulate that reset between successive
505
- // "page loads". the injectable/sessionStorage guard is NOT cleared here — like a
506
- // real reload, it survives, which is what catches an immediate re-fire.
1314
+ // test-only: the reload latch + pending work + in-memory guard + open
1315
+ // transitions are in-memory (a real page reload clears them); tests simulate
1316
+ // that reset between successive "page loads". the injectable/sessionStorage
1317
+ // guard is NOT cleared here — like a real reload, it survives, which is what
1318
+ // catches an immediate re-fire. the construction sequence stays monotonic so
1319
+ // pre/post-transition comparisons keep working across resets.
507
1320
  export function resetRecoveryStateForTests() {
508
- reloadScheduled = false
509
- reloadInProgress = false
510
- if (reloadLatchTimer) {
511
- clearTimeout(reloadLatchTimer)
512
- reloadLatchTimer = undefined
513
- }
514
- pendingDeletes.length = 0
515
- pendingInPlaceRecoveries.length = 0
516
- lastStoreClosedAtMs = 0
517
- inMemoryGuard.clear()
1321
+ const shared = sharedRecoveryState()
1322
+ shared.reloadScheduled = false
1323
+ shared.reloadInProgress = false
1324
+ if (shared.reloadLatchTimer) {
1325
+ clearTimeout(shared.reloadLatchTimer)
1326
+ shared.reloadLatchTimer = undefined
1327
+ }
1328
+ shared.pendingDeletes.length = 0
1329
+ shared.pendingInPlaceRecoveries.length = 0
1330
+ shared.lastStoreClosedAtMs = 0
1331
+ shared.inMemoryGuard.clear()
1332
+ shared.transitions.clear()
1333
+ shared.terminalMemory.clear()
1334
+ shared.terminalEmitted.clear()
1335
+ shared.unknownPostSignals.clear()
518
1336
  }