on-zero 0.6.3 → 0.6.5

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 (90) hide show
  1. package/dist/cjs/createZeroClient.cjs +111 -12
  2. package/dist/cjs/createZeroClient.connection.test.cjs +163 -0
  3. package/dist/cjs/createZeroClient.connection.test.native.js +242 -0
  4. package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -0
  5. package/dist/cjs/createZeroClient.native.js +145 -12
  6. package/dist/cjs/createZeroClient.native.js.map +1 -1
  7. package/dist/cjs/createZeroClient.recovery.test.cjs +52 -0
  8. package/dist/cjs/createZeroClient.recovery.test.native.js +52 -0
  9. package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
  10. package/dist/cjs/helpers/recoverZeroClient.cjs +140 -14
  11. package/dist/cjs/helpers/recoverZeroClient.native.js +155 -22
  12. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  13. package/dist/cjs/helpers/recoverZeroClient.test.cjs +181 -0
  14. package/dist/cjs/helpers/recoverZeroClient.test.native.js +209 -0
  15. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  16. package/dist/cjs/index.cjs +1 -0
  17. package/dist/cjs/index.native.js +1 -0
  18. package/dist/cjs/index.native.js.map +1 -1
  19. package/dist/cjs/multi.cjs +13 -0
  20. package/dist/cjs/multi.native.js +35 -0
  21. package/dist/cjs/multi.native.js.map +1 -1
  22. package/dist/cjs/multiInstance.test.cjs +12 -0
  23. package/dist/cjs/multiInstance.test.native.js +21 -0
  24. package/dist/cjs/multiInstance.test.native.js.map +1 -1
  25. package/dist/cjs/multiPartition.test.cjs +42 -0
  26. package/dist/cjs/multiPartition.test.native.js +51 -0
  27. package/dist/cjs/multiPartition.test.native.js.map +1 -0
  28. package/dist/esm/createZeroClient.connection.test.mjs +164 -0
  29. package/dist/esm/createZeroClient.connection.test.mjs.map +1 -0
  30. package/dist/esm/createZeroClient.connection.test.native.js +240 -0
  31. package/dist/esm/createZeroClient.connection.test.native.js.map +1 -0
  32. package/dist/esm/createZeroClient.mjs +112 -13
  33. package/dist/esm/createZeroClient.mjs.map +1 -1
  34. package/dist/esm/createZeroClient.native.js +146 -13
  35. package/dist/esm/createZeroClient.native.js.map +1 -1
  36. package/dist/esm/createZeroClient.recovery.test.mjs +52 -0
  37. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  38. package/dist/esm/createZeroClient.recovery.test.native.js +52 -0
  39. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  40. package/dist/esm/helpers/recoverZeroClient.mjs +140 -15
  41. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  42. package/dist/esm/helpers/recoverZeroClient.native.js +155 -23
  43. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  44. package/dist/esm/helpers/recoverZeroClient.test.mjs +182 -1
  45. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  46. package/dist/esm/helpers/recoverZeroClient.test.native.js +210 -1
  47. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  48. package/dist/esm/index.js +2 -2
  49. package/dist/esm/index.js.map +1 -1
  50. package/dist/esm/index.mjs +2 -2
  51. package/dist/esm/index.mjs.map +1 -1
  52. package/dist/esm/index.native.js +2 -2
  53. package/dist/esm/index.native.js.map +1 -1
  54. package/dist/esm/multi.mjs +13 -1
  55. package/dist/esm/multi.mjs.map +1 -1
  56. package/dist/esm/multi.native.js +35 -1
  57. package/dist/esm/multi.native.js.map +1 -1
  58. package/dist/esm/multiInstance.test.mjs +12 -0
  59. package/dist/esm/multiInstance.test.mjs.map +1 -1
  60. package/dist/esm/multiInstance.test.native.js +21 -0
  61. package/dist/esm/multiInstance.test.native.js.map +1 -1
  62. package/dist/esm/multiPartition.test.mjs +43 -0
  63. package/dist/esm/multiPartition.test.mjs.map +1 -0
  64. package/dist/esm/multiPartition.test.native.js +49 -0
  65. package/dist/esm/multiPartition.test.native.js.map +1 -0
  66. package/lint/ssr-guards.js +164 -0
  67. package/package.json +2 -2
  68. package/readme.md +126 -0
  69. package/src/createZeroClient.connection.test.tsx +162 -0
  70. package/src/createZeroClient.recovery.test.tsx +68 -0
  71. package/src/createZeroClient.tsx +188 -13
  72. package/src/helpers/recoverZeroClient.test.ts +204 -1
  73. package/src/helpers/recoverZeroClient.ts +263 -42
  74. package/src/index.ts +3 -0
  75. package/src/multi.ts +33 -0
  76. package/src/multiInstance.test.tsx +13 -0
  77. package/src/multiPartition.test.ts +38 -0
  78. package/types/createZeroClient.connection.test.d.ts +5 -0
  79. package/types/createZeroClient.connection.test.d.ts.map +1 -0
  80. package/types/createZeroClient.d.ts +17 -0
  81. package/types/createZeroClient.d.ts.map +1 -1
  82. package/types/createZeroServer.d.ts +4 -4
  83. package/types/helpers/recoverZeroClient.d.ts +15 -1
  84. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  85. package/types/index.d.ts +1 -1
  86. package/types/index.d.ts.map +1 -1
  87. package/types/multi.d.ts +1 -0
  88. package/types/multi.d.ts.map +1 -1
  89. package/types/multiPartition.test.d.ts +2 -0
  90. package/types/multiPartition.test.d.ts.map +1 -0
@@ -29,7 +29,10 @@ import { registerClientInstance } from './instanceRegistry'
29
29
  import { createMutators } from './helpers/createMutators'
30
30
  import {
31
31
  composeRecoveryLogSink,
32
+ isRecoverableZeroStalePokeMessage,
32
33
  makeZeroRecovery,
34
+ type RecoveryGuardStorage,
35
+ type ScheduleReloadContext,
33
36
  type ZeroRecoveryDeps,
34
37
  } from './helpers/recoverZeroClient'
35
38
  import { getAuth } from './helpers/getAuth'
@@ -255,11 +258,40 @@ export function createZeroClientInternal<
255
258
 
256
259
  let latestZeroInstance: ZeroInstance | null = null
257
260
 
261
+ // the provider creates the instance one effect-tick after mount, so on a
262
+ // fresh page load (deep link) components render once before it exists. the
263
+ // documented `useMutation(zero.mutate.x.y)` pattern dereferences `mutate`
264
+ // during that render — hand back a lazy path that resolves the live instance
265
+ // at CALL time instead of throwing at property access. a call that fires
266
+ // with still-no instance throws the same error, so real misuse stays loud.
267
+ function lazyMutatePath(path: string[]): any {
268
+ const resolve = () => {
269
+ if (latestZeroInstance === null) {
270
+ throw new Error(
271
+ `Zero instance not initialized. Ensure ZeroProvider is mounted before accessing 'zero'.`,
272
+ )
273
+ }
274
+ let target: any = (latestZeroInstance as any).mutate
275
+ for (const key of path) target = target[key]
276
+ return target
277
+ }
278
+ return new Proxy(function lazyMutator() {} as any, {
279
+ get(_, key) {
280
+ if (typeof key === 'symbol') return undefined
281
+ return lazyMutatePath([...path, key])
282
+ },
283
+ apply(_, __, args) {
284
+ return resolve()(...args)
285
+ },
286
+ })
287
+ }
288
+
258
289
  // Proxy allows swapping the Zero instance on login without breaking existing references.
259
290
  // Ideally rocicorp/zero would support .setAuth() natively, but for now we swap instances.
260
291
  const zero: ZeroInstance = new Proxy({} as never, {
261
292
  get(_, key) {
262
293
  if (latestZeroInstance === null) {
294
+ if (key === 'mutate') return lazyMutatePath([])
263
295
  throw new Error(
264
296
  `Zero instance not initialized. Ensure ZeroProvider is mounted before accessing 'zero'.`,
265
297
  )
@@ -377,6 +409,20 @@ export function createZeroClientInternal<
377
409
  // with different identities would thrash this slot.
378
410
  let cachedZero: { key: string; instance: ZeroInstance } | null = null
379
411
 
412
+ // in-place re-mint: drop the current instance's local state then reconstruct a
413
+ // fresh client WITHOUT a page reload — the native-safe recovery path (a reload
414
+ // may never land on prod native, wedging the module latch). the mounted
415
+ // provider registers a bump() that changes its instanceKey; remint() drives it
416
+ // through the same rotate effect a real identity change uses. guarded in-memory
417
+ // (Hermes has no sessionStorage) so a client-not-found storm can't reconstruct
418
+ // in a tight loop.
419
+ const REMINT_GUARD_MS = 12_000
420
+ const REMINT_MAX_ATTEMPTS = 5
421
+ const REMINT_ATTEMPT_RESET_MS = 60_000
422
+ const remintControl: { bump: (() => void) | null } = { bump: null }
423
+ let lastRemintAt = 0
424
+ let remintAttempts = 0
425
+
380
426
  function invalidateZeroInstance(instanceToInvalidate: ZeroInstance | null): void {
381
427
  if (!instanceToInvalidate) return
382
428
 
@@ -406,6 +452,34 @@ export function createZeroClientInternal<
406
452
  }
407
453
  }
408
454
 
455
+ // supported in-place recovery: reconstruct a fresh Zero client without a page
456
+ // reload. by default drops the current instance's local store first (a
457
+ // ClientNotFound / desync means it's unusable), then bumps the provider's
458
+ // instanceKey so the rotate effect mints a clean client. returns false when
459
+ // suppressed by the rate guard or when no provider is mounted.
460
+ async function remint(opts: { dropLocalState?: boolean } = {}): Promise<boolean> {
461
+ // no mounted provider to reconstruct through — bail BEFORE the guard so an
462
+ // unmounted call doesn't burn an attempt or start the cooldown.
463
+ if (!remintControl.bump) return false
464
+ const now = Date.now()
465
+ const sinceLast = now - lastRemintAt
466
+ if (lastRemintAt > 0 && sinceLast < REMINT_GUARD_MS) return false
467
+ if (sinceLast > REMINT_ATTEMPT_RESET_MS) remintAttempts = 0
468
+ if (remintAttempts >= REMINT_MAX_ATTEMPTS) return false
469
+ lastRemintAt = now
470
+ remintAttempts += 1
471
+
472
+ const { dropLocalState = true } = opts
473
+ if (dropLocalState && latestZeroInstance) {
474
+ await deleteZeroInstance(latestZeroInstance).catch(() => {})
475
+ }
476
+ // re-check: the provider may have unmounted during the async delete.
477
+ const bump = remintControl.bump
478
+ if (!bump) return false
479
+ bump()
480
+ return true
481
+ }
482
+
409
483
  // when ProvideZero is rendered without a real Zero instance (SSG, disable=true,
410
484
  // or transiently while the active path is still creating its first instance),
411
485
  // we want descendants' useZero() / useConnectionState() / on-zero useQuery to
@@ -459,6 +533,23 @@ export function createZeroClientInternal<
459
533
  // awaited before a self-healing recovery reload — e.g. wait for the dev
460
534
  // origin to be reachable so the reload doesn't hit a restarting server.
461
535
  beforeReload?: () => Promise<void>
536
+ // take over WHEN/HOW the recovery reload happens (IDE gate + countdown,
537
+ // native expo-updates reload, …) while still driving the same
538
+ // deletes-then-reload work via ctx.performReload. default: immediate reload.
539
+ scheduleReload?: (ctx: ScheduleReloadContext) => void
540
+ // cross-reload loop-guard backing store; defaults to sessionStorage on web.
541
+ // inject a native KV so Hermes gets real cross-reload protection.
542
+ guardStorage?: RecoveryGuardStorage
543
+ // return true for a classified recovery log the app wants treated as benign
544
+ // (its own cold-boot timeout, say) so it does NOT trigger recovery.
545
+ benignLogFilter?: (message: string) => boolean
546
+ // called when the connection needs auth; return a fresh token to reconnect
547
+ // in place. lets an expired token auto-recover without a reload.
548
+ refreshAuth?: () => Promise<string | undefined>
549
+ // when true, mirror this instance's connection state onto
550
+ // document.body.dataset.zero* for e2e/diagnostics. enable on ONE instance
551
+ // (the control/primary) so multiple instances don't fight over the dataset.
552
+ connectionDataset?: boolean
462
553
  }
463
554
 
464
555
  // providezero keeps the same provider/fiber layout on ssr and client so
@@ -493,6 +584,11 @@ export function createZeroClientInternal<
493
584
  transport,
494
585
  pullIntervalMs = 30_000,
495
586
  beforeReload,
587
+ scheduleReload,
588
+ guardStorage,
589
+ benignLogFilter,
590
+ refreshAuth,
591
+ connectionDataset,
496
592
  disable,
497
593
  ...props
498
594
  }: ProvideZeroProps) => {
@@ -532,10 +628,23 @@ export function createZeroClientInternal<
532
628
  // eslint-disable-next-line react-hooks/exhaustive-deps
533
629
  }, [])
534
630
 
631
+ // remint() reconstructs the client in place by bumping this counter, which
632
+ // changes instanceKey and drives the rotate effect exactly as a real
633
+ // identity change does. register the bump so the imperative remint() API can
634
+ // reach this mounted provider.
635
+ const [remintGeneration, setRemintGeneration] = useState(0)
636
+ useEffect(() => {
637
+ remintControl.bump = () => setRemintGeneration((generation) => generation + 1)
638
+ return () => {
639
+ remintControl.bump = null
640
+ }
641
+ }, [])
642
+
535
643
  // identity = every primitive option except the auth token value (token
536
644
  // changes refresh in place below; logged-in <-> logged-out still rotates
537
645
  // via hasAuth, matching zero's documented provider semantics). function
538
646
  // props (callbacks, logSink, batchViewUpdates) are bound at construction.
647
+ // remintGeneration is included so remint() forces a fresh instance.
539
648
  const instanceKey = JSON.stringify([
540
649
  Object.entries({ kvStore: 'mem', ...props })
541
650
  .filter(
@@ -545,6 +654,7 @@ export function createZeroClientInternal<
545
654
  .sort(([a], [b]) => (a < b ? -1 : 1)),
546
655
  hasAuth,
547
656
  transport,
657
+ remintGeneration,
548
658
  ])
549
659
 
550
660
  // create/rotate in an effect — commit-safe: a discarded concurrent render
@@ -589,6 +699,9 @@ export function createZeroClientInternal<
589
699
  deleteLocalState: () => deleteZeroInstance(instanceRef.current),
590
700
  zeroEvents,
591
701
  beforeReload,
702
+ scheduleReload,
703
+ guardStorage,
704
+ benignLogFilter,
592
705
  }
593
706
  const recovery = makeZeroRecovery(recoveryDeps)
594
707
  const createdInstance = new ZeroClient<Schema, ZeroMutators>({
@@ -653,7 +766,19 @@ export function createZeroClientInternal<
653
766
  <DisabledContext.Provider value={liveInstance ? false : 'empty'}>
654
767
  <ZeroContext.Provider value={liveInstance ?? (DISABLED_ZERO_STUB as any)}>
655
768
  {liveInstance ? <SetZeroInstance /> : null}
656
- {liveInstance ? <ConnectionMonitor zeroEvents={zeroEvents} /> : null}
769
+ {liveInstance ? (
770
+ <ConnectionMonitor
771
+ zeroEvents={zeroEvents}
772
+ refreshAuth={refreshAuth}
773
+ exposeDataset={connectionDataset}
774
+ datasetCacheUrl={
775
+ connectionDataset
776
+ ? ((props as { cacheURL?: string; server?: string }).cacheURL ??
777
+ (props as { cacheURL?: string; server?: string }).server)
778
+ : undefined
779
+ }
780
+ />
781
+ ) : null}
657
782
  {children}
658
783
  </ZeroContext.Provider>
659
784
  </DisabledContext.Provider>
@@ -689,30 +814,79 @@ export function createZeroClientInternal<
689
814
  return null
690
815
  }
691
816
 
692
- // monitors connection state and emits events (replaces onError callback removed in 0.25)
817
+ // monitors connection state and emits events (replaces onError callback removed
818
+ // in 0.25). also owns the generic-Zero connection recovery that used to live in
819
+ // each consumer: stale-poke reconnect, needs-auth token refresh, and optional
820
+ // e2e dataset bookkeeping.
693
821
  const ConnectionMonitor = memo(
694
822
  ({
695
823
  zeroEvents,
824
+ refreshAuth,
825
+ exposeDataset,
826
+ datasetCacheUrl,
696
827
  }: {
697
828
  zeroEvents: ReturnType<typeof createEmitter<ZeroEvent | null>>
829
+ refreshAuth?: () => Promise<string | undefined>
830
+ exposeDataset?: boolean
831
+ datasetCacheUrl?: string
698
832
  }) => {
833
+ const zeroInstance = useZero<Schema, ZeroMutators>()
699
834
  const state = useConnectionState()
700
835
  const prevState = useRef(state.name)
836
+ // one reconnect per distinct stale-poke reason / one refresh per needs-auth
837
+ // transition, so a stuck error state doesn't retry-storm.
838
+ const staleReconnectRef = useRef<string | null>(null)
839
+ const needsAuthRef = useRef(false)
701
840
 
702
841
  useEffect(() => {
703
- if (state.name !== prevState.current) {
704
- const reason = 'reason' in state ? state.reason : ''
705
- prevState.current = state.name
706
-
707
- if (state.name === 'error' || state.name === 'needs-auth') {
708
- const message = typeof reason === 'string' ? reason : state.name
709
- zeroEvents.emit({
710
- type: 'error',
711
- message,
712
- })
842
+ const name = state.name
843
+ const reason =
844
+ 'reason' in state && typeof state.reason === 'string' ? state.reason : ''
845
+
846
+ // mirror connection state onto the body dataset for e2e/diagnostics
847
+ // (enabled on one instance so instances don't clobber each other).
848
+ if (exposeDataset && typeof document !== 'undefined' && document.body) {
849
+ document.body.dataset.zeroState = name
850
+ if (datasetCacheUrl) document.body.dataset.zeroCacheUrl = datasetCacheUrl
851
+ if (reason) document.body.dataset.zeroReason = reason.slice(0, 200)
852
+ else delete document.body.dataset.zeroReason
853
+ if (name === 'connected') document.body.dataset.zeroConnected = 'true'
854
+ else delete document.body.dataset.zeroConnected
855
+ }
856
+
857
+ // stale-poke / stale-cookie: the local view is behind the server
858
+ // snapshot; a plain reconnect resolves it. not fatal — don't emit error.
859
+ if (name === 'error' && isRecoverableZeroStalePokeMessage(reason)) {
860
+ if (staleReconnectRef.current !== reason) {
861
+ staleReconnectRef.current = reason
862
+ void Promise.resolve(zeroInstance.connection?.connect?.()).catch(() => {})
863
+ }
864
+ return
865
+ }
866
+ if (name !== 'error') staleReconnectRef.current = null
867
+
868
+ // needs-auth: the token expired and zero won't auto-resume unless the
869
+ // auth string changes. refresh it and reconnect in place, once.
870
+ if (name === 'needs-auth') {
871
+ if (refreshAuth && !needsAuthRef.current) {
872
+ needsAuthRef.current = true
873
+ void refreshAuth()
874
+ .then((token) => {
875
+ if (token) zeroInstance.connection?.connect?.({ auth: token })
876
+ })
877
+ .catch(() => {})
878
+ }
879
+ } else {
880
+ needsAuthRef.current = false
881
+ }
882
+
883
+ if (name !== prevState.current) {
884
+ prevState.current = name
885
+ if (name === 'error' || name === 'needs-auth') {
886
+ zeroEvents.emit({ type: 'error', message: reason || name })
713
887
  }
714
888
  }
715
- }, [state, zeroEvents])
889
+ }, [state, zeroEvents, zeroInstance, refreshAuth, exposeDataset, datasetCacheUrl])
716
890
 
717
891
  return null
718
892
  },
@@ -779,5 +953,6 @@ export function createZeroClientInternal<
779
953
  zero,
780
954
  preload,
781
955
  getQuery,
956
+ remint,
782
957
  }
783
958
  }
@@ -6,11 +6,12 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
6
6
  import {
7
7
  classifyZeroRecoveryLog,
8
8
  composeRecoveryLogSink,
9
+ isRecoverableZeroStalePokeMessage,
9
10
  makeZeroRecovery,
10
11
  resetRecoveryStateForTests,
11
12
  } from './recoverZeroClient'
12
13
 
13
- import type { ZeroRecoveryDeps } from './recoverZeroClient'
14
+ import type { ScheduleReloadContext, ZeroRecoveryDeps } from './recoverZeroClient'
14
15
  import type { ZeroEvent } from '../types'
15
16
 
16
17
  let emitterSeq = 0
@@ -274,4 +275,206 @@ describe('zero recovery', () => {
274
275
  reason: 'client state not found',
275
276
  })
276
277
  })
278
+
279
+ test('scheduleReload defers the reload AND the store delete until performReload runs', async () => {
280
+ const { deps, deleteLocalState, reload } = setup()
281
+ let captured: ScheduleReloadContext | undefined
282
+ deps.scheduleReload = (ctx) => {
283
+ captured = ctx
284
+ }
285
+ makeZeroRecovery(deps).onClientStateNotFound()
286
+ await flush()
287
+ // deferred: nothing reloaded and the store is NOT yet deleted (so the app
288
+ // isn't left running on a deleted store while the reload is gated).
289
+ expect(reload).not.toHaveBeenCalled()
290
+ expect(deleteLocalState).not.toHaveBeenCalled()
291
+ expect(captured).toMatchObject({
292
+ reasonKey: 'client-state-not-found',
293
+ dropLocalState: true,
294
+ })
295
+ await captured!.performReload()
296
+ await flush()
297
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
298
+ expect(reload).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ test('scheduleReload that reloads directly skips the delete and beforeReload', async () => {
302
+ const { deps, deleteLocalState, reload } = setup()
303
+ const nativeReload = vi.fn()
304
+ const beforeReload = vi.fn(async () => {})
305
+ deps.beforeReload = beforeReload
306
+ deps.scheduleReload = () => {
307
+ nativeReload()
308
+ }
309
+
310
+ makeZeroRecovery(deps).onClientStateNotFound()
311
+ await flush()
312
+
313
+ expect(nativeReload).toHaveBeenCalledTimes(1)
314
+ expect(deleteLocalState).not.toHaveBeenCalled()
315
+ expect(beforeReload).not.toHaveBeenCalled()
316
+ expect(reload).not.toHaveBeenCalled()
317
+ })
318
+
319
+ test('scheduleReload performReload is idempotent (one reload even if called twice)', async () => {
320
+ const { deps, deleteLocalState, reload } = setup()
321
+ let captured: ScheduleReloadContext | undefined
322
+ deps.scheduleReload = (ctx) => {
323
+ captured = ctx
324
+ }
325
+ makeZeroRecovery(deps).onClientStateNotFound()
326
+ await flush()
327
+ await captured!.performReload()
328
+ await captured!.performReload()
329
+ await flush()
330
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
331
+ expect(reload).toHaveBeenCalledTimes(1)
332
+ })
333
+
334
+ test('scheduleReload deferred PAST the latch timeout still deletes and reloads exactly once', async () => {
335
+ vi.useFakeTimers()
336
+ try {
337
+ const { deps, deleteLocalState, reload } = setup()
338
+ let captured: ScheduleReloadContext | undefined
339
+ deps.scheduleReload = (ctx) => {
340
+ captured = ctx
341
+ }
342
+ makeZeroRecovery(deps).onClientStateNotFound()
343
+ expect(captured).toBeDefined()
344
+
345
+ // the consumer holds performReload behind a gate for longer than the latch
346
+ // timeout (soot's IDE gate can hold for minutes). the timeout re-opens
347
+ // scheduling but must NOT drop the pending delete thunk.
348
+ await vi.advanceTimersByTimeAsync(20_000)
349
+ expect(deleteLocalState).not.toHaveBeenCalled()
350
+ expect(reload).not.toHaveBeenCalled()
351
+
352
+ // when the consumer finally commits, the store STILL gets dropped and the
353
+ // reload fires exactly once — never a bare reload back onto the bad store.
354
+ await captured!.performReload()
355
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
356
+ expect(reload).toHaveBeenCalledTimes(1)
357
+ } finally {
358
+ vi.useRealTimers()
359
+ }
360
+ })
361
+
362
+ test('a slow beforeReload holds the latch through the timeout window (no double reload)', async () => {
363
+ vi.useFakeTimers()
364
+ try {
365
+ const { deps, reload } = setup()
366
+ let releaseBeforeReload: () => void = () => {}
367
+ deps.beforeReload = () =>
368
+ new Promise<void>((resolve) => {
369
+ releaseBeforeReload = resolve
370
+ })
371
+ const recovery = makeZeroRecovery(deps)
372
+ // default path: performReload runs immediately, disarms the latch, then
373
+ // blocks on beforeReload (soot's waitForOriginReachable can exceed 15s).
374
+ recovery.onClientStateNotFound()
375
+ await vi.advanceTimersByTimeAsync(20_000)
376
+ // a second recovery arrives while the first reload is still in-flight.
377
+ recovery.onClientStateNotFound()
378
+ await vi.advanceTimersByTimeAsync(0)
379
+ expect(reload).not.toHaveBeenCalled()
380
+ // releasing the first beforeReload reloads exactly once, never twice.
381
+ releaseBeforeReload()
382
+ await vi.advanceTimersByTimeAsync(0)
383
+ expect(reload).toHaveBeenCalledTimes(1)
384
+ } finally {
385
+ vi.useRealTimers()
386
+ }
387
+ })
388
+
389
+ test('injectable guardStorage gives cross-reload loop protection (Hermes has no sessionStorage)', async () => {
390
+ const store = new Map<string, string>()
391
+ const { deps, reload, events } = setup()
392
+ deps.guardStorage = {
393
+ getItem: (key) => store.get(key) ?? null,
394
+ setItem: (key, value) => {
395
+ store.set(key, value)
396
+ },
397
+ }
398
+ const recovery = makeZeroRecovery(deps)
399
+ recovery.onClientStateNotFound()
400
+ await flush()
401
+ expect(reload).toHaveBeenCalledTimes(1)
402
+ // a reload wipes in-memory state but NOT the injected store.
403
+ resetRecoveryStateForTests()
404
+ recovery.onClientStateNotFound()
405
+ await flush()
406
+ // the injected store catches the immediate re-fire → fatal, no reload storm.
407
+ expect(reload).toHaveBeenCalledTimes(1)
408
+ expect(events.some((event) => event.type === 'fatal')).toBe(true)
409
+ })
410
+
411
+ test('a guardStorage that throws never crashes recovery (in-memory is the floor)', async () => {
412
+ const { deps, reload } = setup()
413
+ deps.guardStorage = {
414
+ getItem: () => {
415
+ throw new Error('no storage on this platform')
416
+ },
417
+ setItem: () => {
418
+ throw new Error('no storage on this platform')
419
+ },
420
+ }
421
+ makeZeroRecovery(deps).onClientStateNotFound()
422
+ await flush()
423
+ expect(reload).toHaveBeenCalledTimes(1)
424
+ })
425
+
426
+ test('classifier recognizes the mutation / connection desync class', () => {
427
+ const cases: Array<[readonly unknown[], string]> = [
428
+ [['sent mutation ID 5 but expected 4'], 'mutation-desync'],
429
+ [['oooMutation detected'], 'mutation-desync'],
430
+ [['Server reported an out-of-order mutation'], 'mutation-desync'],
431
+ [['Ignoring mutation 3, already processed. Expected: 4'], 'mutation-desync'],
432
+ [['InvalidConnectionRequestLastMutationID'], 'mutation-desync'],
433
+ [['InvalidConnectionRequestBaseCookie'], 'connection-cookie-invalid'],
434
+ [['ClientNotFound: client gone'], 'client-not-found'],
435
+ [['connection userID mismatch'], 'connection-userid-mismatch'],
436
+ ]
437
+ for (const [args, reason] of cases) {
438
+ expect(classifyZeroRecoveryLog('error', args)).toMatchObject({
439
+ reason,
440
+ dropLocalState: true,
441
+ })
442
+ }
443
+ // still narrow: app-infra strings and non-error levels do NOT classify.
444
+ expect(
445
+ classifyZeroRecoveryLog('error', ['consecutive server-ack timeouts']),
446
+ ).toBeUndefined()
447
+ expect(
448
+ classifyZeroRecoveryLog('error', ['Connection attempt timed out after 10 seconds']),
449
+ ).toBeUndefined()
450
+ expect(classifyZeroRecoveryLog('warn', ['ClientNotFound'])).toBeUndefined()
451
+ })
452
+
453
+ test('benignLogFilter suppresses recovery for a matching classified log', async () => {
454
+ const { deps, deleteLocalState, reload } = setup()
455
+ deps.benignLogFilter = (message) => message.includes('ClientNotFound')
456
+ const sink = composeRecoveryLogSink(deps)
457
+ sink.log('error', undefined, 'ClientNotFound: gone')
458
+ await flush()
459
+ expect(deleteLocalState).not.toHaveBeenCalled()
460
+ expect(reload).not.toHaveBeenCalled()
461
+ // a non-benign desync still recovers through the same sink.
462
+ sink.log('error', undefined, 'sent mutation ID 5 but expected 4')
463
+ await flush()
464
+ expect(reload).toHaveBeenCalledTimes(1)
465
+ })
466
+
467
+ test('isRecoverableZeroStalePokeMessage matches only the stale-cookie signatures', () => {
468
+ expect(
469
+ isRecoverableZeroStalePokeMessage(
470
+ 'Server returned unexpected base cookie during sync',
471
+ ),
472
+ ).toBe(true)
473
+ expect(
474
+ isRecoverableZeroStalePokeMessage(
475
+ 'Received cookie 5 is < than last snapshot cookie 9, ignoring client view',
476
+ ),
477
+ ).toBe(true)
478
+ expect(isRecoverableZeroStalePokeMessage('client state not found')).toBe(false)
479
+ })
277
480
  })