on-zero 0.6.3 → 0.6.4
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.
- package/dist/cjs/createZeroClient.cjs +91 -12
- package/dist/cjs/createZeroClient.connection.test.cjs +163 -0
- package/dist/cjs/createZeroClient.connection.test.native.js +242 -0
- package/dist/cjs/createZeroClient.connection.test.native.js.map +1 -0
- package/dist/cjs/createZeroClient.native.js +99 -12
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/createZeroClient.recovery.test.cjs +52 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js +52 -0
- package/dist/cjs/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.cjs +140 -14
- package/dist/cjs/helpers/recoverZeroClient.native.js +155 -22
- package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/cjs/helpers/recoverZeroClient.test.cjs +181 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js +209 -0
- package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/multi.cjs +13 -0
- package/dist/cjs/multi.native.js +35 -0
- package/dist/cjs/multi.native.js.map +1 -1
- package/dist/cjs/multiPartition.test.cjs +42 -0
- package/dist/cjs/multiPartition.test.native.js +51 -0
- package/dist/cjs/multiPartition.test.native.js.map +1 -0
- package/dist/esm/createZeroClient.connection.test.mjs +164 -0
- package/dist/esm/createZeroClient.connection.test.mjs.map +1 -0
- package/dist/esm/createZeroClient.connection.test.native.js +240 -0
- package/dist/esm/createZeroClient.connection.test.native.js.map +1 -0
- package/dist/esm/createZeroClient.mjs +92 -13
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +100 -13
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.mjs +52 -0
- package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
- package/dist/esm/createZeroClient.recovery.test.native.js +52 -0
- package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.mjs +140 -15
- package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.native.js +155 -23
- package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs +182 -1
- package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js +210 -1
- package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/multi.mjs +13 -1
- package/dist/esm/multi.mjs.map +1 -1
- package/dist/esm/multi.native.js +35 -1
- package/dist/esm/multi.native.js.map +1 -1
- package/dist/esm/multiPartition.test.mjs +43 -0
- package/dist/esm/multiPartition.test.mjs.map +1 -0
- package/dist/esm/multiPartition.test.native.js +49 -0
- package/dist/esm/multiPartition.test.native.js.map +1 -0
- package/lint/ssr-guards.js +164 -0
- package/package.json +2 -2
- package/readme.md +126 -0
- package/src/createZeroClient.connection.test.tsx +162 -0
- package/src/createZeroClient.recovery.test.tsx +68 -0
- package/src/createZeroClient.tsx +159 -13
- package/src/helpers/recoverZeroClient.test.ts +204 -1
- package/src/helpers/recoverZeroClient.ts +263 -42
- package/src/index.ts +3 -0
- package/src/multi.ts +33 -0
- package/src/multiPartition.test.ts +38 -0
- package/types/createZeroClient.connection.test.d.ts +5 -0
- package/types/createZeroClient.connection.test.d.ts.map +1 -0
- package/types/createZeroClient.d.ts +17 -0
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/createZeroServer.d.ts +4 -4
- package/types/helpers/recoverZeroClient.d.ts +15 -1
- package/types/helpers/recoverZeroClient.d.ts.map +1 -1
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/multi.d.ts +1 -0
- package/types/multi.d.ts.map +1 -1
- package/types/multiPartition.test.d.ts +2 -0
- package/types/multiPartition.test.d.ts.map +1 -0
package/src/createZeroClient.tsx
CHANGED
|
@@ -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'
|
|
@@ -377,6 +380,20 @@ export function createZeroClientInternal<
|
|
|
377
380
|
// with different identities would thrash this slot.
|
|
378
381
|
let cachedZero: { key: string; instance: ZeroInstance } | null = null
|
|
379
382
|
|
|
383
|
+
// in-place re-mint: drop the current instance's local state then reconstruct a
|
|
384
|
+
// fresh client WITHOUT a page reload — the native-safe recovery path (a reload
|
|
385
|
+
// may never land on prod native, wedging the module latch). the mounted
|
|
386
|
+
// provider registers a bump() that changes its instanceKey; remint() drives it
|
|
387
|
+
// through the same rotate effect a real identity change uses. guarded in-memory
|
|
388
|
+
// (Hermes has no sessionStorage) so a client-not-found storm can't reconstruct
|
|
389
|
+
// in a tight loop.
|
|
390
|
+
const REMINT_GUARD_MS = 12_000
|
|
391
|
+
const REMINT_MAX_ATTEMPTS = 5
|
|
392
|
+
const REMINT_ATTEMPT_RESET_MS = 60_000
|
|
393
|
+
const remintControl: { bump: (() => void) | null } = { bump: null }
|
|
394
|
+
let lastRemintAt = 0
|
|
395
|
+
let remintAttempts = 0
|
|
396
|
+
|
|
380
397
|
function invalidateZeroInstance(instanceToInvalidate: ZeroInstance | null): void {
|
|
381
398
|
if (!instanceToInvalidate) return
|
|
382
399
|
|
|
@@ -406,6 +423,34 @@ export function createZeroClientInternal<
|
|
|
406
423
|
}
|
|
407
424
|
}
|
|
408
425
|
|
|
426
|
+
// supported in-place recovery: reconstruct a fresh Zero client without a page
|
|
427
|
+
// reload. by default drops the current instance's local store first (a
|
|
428
|
+
// ClientNotFound / desync means it's unusable), then bumps the provider's
|
|
429
|
+
// instanceKey so the rotate effect mints a clean client. returns false when
|
|
430
|
+
// suppressed by the rate guard or when no provider is mounted.
|
|
431
|
+
async function remint(opts: { dropLocalState?: boolean } = {}): Promise<boolean> {
|
|
432
|
+
// no mounted provider to reconstruct through — bail BEFORE the guard so an
|
|
433
|
+
// unmounted call doesn't burn an attempt or start the cooldown.
|
|
434
|
+
if (!remintControl.bump) return false
|
|
435
|
+
const now = Date.now()
|
|
436
|
+
const sinceLast = now - lastRemintAt
|
|
437
|
+
if (lastRemintAt > 0 && sinceLast < REMINT_GUARD_MS) return false
|
|
438
|
+
if (sinceLast > REMINT_ATTEMPT_RESET_MS) remintAttempts = 0
|
|
439
|
+
if (remintAttempts >= REMINT_MAX_ATTEMPTS) return false
|
|
440
|
+
lastRemintAt = now
|
|
441
|
+
remintAttempts += 1
|
|
442
|
+
|
|
443
|
+
const { dropLocalState = true } = opts
|
|
444
|
+
if (dropLocalState && latestZeroInstance) {
|
|
445
|
+
await deleteZeroInstance(latestZeroInstance).catch(() => {})
|
|
446
|
+
}
|
|
447
|
+
// re-check: the provider may have unmounted during the async delete.
|
|
448
|
+
const bump = remintControl.bump
|
|
449
|
+
if (!bump) return false
|
|
450
|
+
bump()
|
|
451
|
+
return true
|
|
452
|
+
}
|
|
453
|
+
|
|
409
454
|
// when ProvideZero is rendered without a real Zero instance (SSG, disable=true,
|
|
410
455
|
// or transiently while the active path is still creating its first instance),
|
|
411
456
|
// we want descendants' useZero() / useConnectionState() / on-zero useQuery to
|
|
@@ -459,6 +504,23 @@ export function createZeroClientInternal<
|
|
|
459
504
|
// awaited before a self-healing recovery reload — e.g. wait for the dev
|
|
460
505
|
// origin to be reachable so the reload doesn't hit a restarting server.
|
|
461
506
|
beforeReload?: () => Promise<void>
|
|
507
|
+
// take over WHEN/HOW the recovery reload happens (IDE gate + countdown,
|
|
508
|
+
// native expo-updates reload, …) while still driving the same
|
|
509
|
+
// deletes-then-reload work via ctx.performReload. default: immediate reload.
|
|
510
|
+
scheduleReload?: (ctx: ScheduleReloadContext) => void
|
|
511
|
+
// cross-reload loop-guard backing store; defaults to sessionStorage on web.
|
|
512
|
+
// inject a native KV so Hermes gets real cross-reload protection.
|
|
513
|
+
guardStorage?: RecoveryGuardStorage
|
|
514
|
+
// return true for a classified recovery log the app wants treated as benign
|
|
515
|
+
// (its own cold-boot timeout, say) so it does NOT trigger recovery.
|
|
516
|
+
benignLogFilter?: (message: string) => boolean
|
|
517
|
+
// called when the connection needs auth; return a fresh token to reconnect
|
|
518
|
+
// in place. lets an expired token auto-recover without a reload.
|
|
519
|
+
refreshAuth?: () => Promise<string | undefined>
|
|
520
|
+
// when true, mirror this instance's connection state onto
|
|
521
|
+
// document.body.dataset.zero* for e2e/diagnostics. enable on ONE instance
|
|
522
|
+
// (the control/primary) so multiple instances don't fight over the dataset.
|
|
523
|
+
connectionDataset?: boolean
|
|
462
524
|
}
|
|
463
525
|
|
|
464
526
|
// providezero keeps the same provider/fiber layout on ssr and client so
|
|
@@ -493,6 +555,11 @@ export function createZeroClientInternal<
|
|
|
493
555
|
transport,
|
|
494
556
|
pullIntervalMs = 30_000,
|
|
495
557
|
beforeReload,
|
|
558
|
+
scheduleReload,
|
|
559
|
+
guardStorage,
|
|
560
|
+
benignLogFilter,
|
|
561
|
+
refreshAuth,
|
|
562
|
+
connectionDataset,
|
|
496
563
|
disable,
|
|
497
564
|
...props
|
|
498
565
|
}: ProvideZeroProps) => {
|
|
@@ -532,10 +599,23 @@ export function createZeroClientInternal<
|
|
|
532
599
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
533
600
|
}, [])
|
|
534
601
|
|
|
602
|
+
// remint() reconstructs the client in place by bumping this counter, which
|
|
603
|
+
// changes instanceKey and drives the rotate effect exactly as a real
|
|
604
|
+
// identity change does. register the bump so the imperative remint() API can
|
|
605
|
+
// reach this mounted provider.
|
|
606
|
+
const [remintGeneration, setRemintGeneration] = useState(0)
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
remintControl.bump = () => setRemintGeneration((generation) => generation + 1)
|
|
609
|
+
return () => {
|
|
610
|
+
remintControl.bump = null
|
|
611
|
+
}
|
|
612
|
+
}, [])
|
|
613
|
+
|
|
535
614
|
// identity = every primitive option except the auth token value (token
|
|
536
615
|
// changes refresh in place below; logged-in <-> logged-out still rotates
|
|
537
616
|
// via hasAuth, matching zero's documented provider semantics). function
|
|
538
617
|
// props (callbacks, logSink, batchViewUpdates) are bound at construction.
|
|
618
|
+
// remintGeneration is included so remint() forces a fresh instance.
|
|
539
619
|
const instanceKey = JSON.stringify([
|
|
540
620
|
Object.entries({ kvStore: 'mem', ...props })
|
|
541
621
|
.filter(
|
|
@@ -545,6 +625,7 @@ export function createZeroClientInternal<
|
|
|
545
625
|
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
|
546
626
|
hasAuth,
|
|
547
627
|
transport,
|
|
628
|
+
remintGeneration,
|
|
548
629
|
])
|
|
549
630
|
|
|
550
631
|
// create/rotate in an effect — commit-safe: a discarded concurrent render
|
|
@@ -589,6 +670,9 @@ export function createZeroClientInternal<
|
|
|
589
670
|
deleteLocalState: () => deleteZeroInstance(instanceRef.current),
|
|
590
671
|
zeroEvents,
|
|
591
672
|
beforeReload,
|
|
673
|
+
scheduleReload,
|
|
674
|
+
guardStorage,
|
|
675
|
+
benignLogFilter,
|
|
592
676
|
}
|
|
593
677
|
const recovery = makeZeroRecovery(recoveryDeps)
|
|
594
678
|
const createdInstance = new ZeroClient<Schema, ZeroMutators>({
|
|
@@ -653,7 +737,19 @@ export function createZeroClientInternal<
|
|
|
653
737
|
<DisabledContext.Provider value={liveInstance ? false : 'empty'}>
|
|
654
738
|
<ZeroContext.Provider value={liveInstance ?? (DISABLED_ZERO_STUB as any)}>
|
|
655
739
|
{liveInstance ? <SetZeroInstance /> : null}
|
|
656
|
-
{liveInstance ?
|
|
740
|
+
{liveInstance ? (
|
|
741
|
+
<ConnectionMonitor
|
|
742
|
+
zeroEvents={zeroEvents}
|
|
743
|
+
refreshAuth={refreshAuth}
|
|
744
|
+
exposeDataset={connectionDataset}
|
|
745
|
+
datasetCacheUrl={
|
|
746
|
+
connectionDataset
|
|
747
|
+
? ((props as { cacheURL?: string; server?: string }).cacheURL ??
|
|
748
|
+
(props as { cacheURL?: string; server?: string }).server)
|
|
749
|
+
: undefined
|
|
750
|
+
}
|
|
751
|
+
/>
|
|
752
|
+
) : null}
|
|
657
753
|
{children}
|
|
658
754
|
</ZeroContext.Provider>
|
|
659
755
|
</DisabledContext.Provider>
|
|
@@ -689,30 +785,79 @@ export function createZeroClientInternal<
|
|
|
689
785
|
return null
|
|
690
786
|
}
|
|
691
787
|
|
|
692
|
-
// monitors connection state and emits events (replaces onError callback removed
|
|
788
|
+
// monitors connection state and emits events (replaces onError callback removed
|
|
789
|
+
// in 0.25). also owns the generic-Zero connection recovery that used to live in
|
|
790
|
+
// each consumer: stale-poke reconnect, needs-auth token refresh, and optional
|
|
791
|
+
// e2e dataset bookkeeping.
|
|
693
792
|
const ConnectionMonitor = memo(
|
|
694
793
|
({
|
|
695
794
|
zeroEvents,
|
|
795
|
+
refreshAuth,
|
|
796
|
+
exposeDataset,
|
|
797
|
+
datasetCacheUrl,
|
|
696
798
|
}: {
|
|
697
799
|
zeroEvents: ReturnType<typeof createEmitter<ZeroEvent | null>>
|
|
800
|
+
refreshAuth?: () => Promise<string | undefined>
|
|
801
|
+
exposeDataset?: boolean
|
|
802
|
+
datasetCacheUrl?: string
|
|
698
803
|
}) => {
|
|
804
|
+
const zeroInstance = useZero<Schema, ZeroMutators>()
|
|
699
805
|
const state = useConnectionState()
|
|
700
806
|
const prevState = useRef(state.name)
|
|
807
|
+
// one reconnect per distinct stale-poke reason / one refresh per needs-auth
|
|
808
|
+
// transition, so a stuck error state doesn't retry-storm.
|
|
809
|
+
const staleReconnectRef = useRef<string | null>(null)
|
|
810
|
+
const needsAuthRef = useRef(false)
|
|
701
811
|
|
|
702
812
|
useEffect(() => {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
813
|
+
const name = state.name
|
|
814
|
+
const reason =
|
|
815
|
+
'reason' in state && typeof state.reason === 'string' ? state.reason : ''
|
|
816
|
+
|
|
817
|
+
// mirror connection state onto the body dataset for e2e/diagnostics
|
|
818
|
+
// (enabled on one instance so instances don't clobber each other).
|
|
819
|
+
if (exposeDataset && typeof document !== 'undefined' && document.body) {
|
|
820
|
+
document.body.dataset.zeroState = name
|
|
821
|
+
if (datasetCacheUrl) document.body.dataset.zeroCacheUrl = datasetCacheUrl
|
|
822
|
+
if (reason) document.body.dataset.zeroReason = reason.slice(0, 200)
|
|
823
|
+
else delete document.body.dataset.zeroReason
|
|
824
|
+
if (name === 'connected') document.body.dataset.zeroConnected = 'true'
|
|
825
|
+
else delete document.body.dataset.zeroConnected
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// stale-poke / stale-cookie: the local view is behind the server
|
|
829
|
+
// snapshot; a plain reconnect resolves it. not fatal — don't emit error.
|
|
830
|
+
if (name === 'error' && isRecoverableZeroStalePokeMessage(reason)) {
|
|
831
|
+
if (staleReconnectRef.current !== reason) {
|
|
832
|
+
staleReconnectRef.current = reason
|
|
833
|
+
void Promise.resolve(zeroInstance.connection?.connect?.()).catch(() => {})
|
|
834
|
+
}
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if (name !== 'error') staleReconnectRef.current = null
|
|
838
|
+
|
|
839
|
+
// needs-auth: the token expired and zero won't auto-resume unless the
|
|
840
|
+
// auth string changes. refresh it and reconnect in place, once.
|
|
841
|
+
if (name === 'needs-auth') {
|
|
842
|
+
if (refreshAuth && !needsAuthRef.current) {
|
|
843
|
+
needsAuthRef.current = true
|
|
844
|
+
void refreshAuth()
|
|
845
|
+
.then((token) => {
|
|
846
|
+
if (token) zeroInstance.connection?.connect?.({ auth: token })
|
|
847
|
+
})
|
|
848
|
+
.catch(() => {})
|
|
849
|
+
}
|
|
850
|
+
} else {
|
|
851
|
+
needsAuthRef.current = false
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (name !== prevState.current) {
|
|
855
|
+
prevState.current = name
|
|
856
|
+
if (name === 'error' || name === 'needs-auth') {
|
|
857
|
+
zeroEvents.emit({ type: 'error', message: reason || name })
|
|
713
858
|
}
|
|
714
859
|
}
|
|
715
|
-
}, [state, zeroEvents])
|
|
860
|
+
}, [state, zeroEvents, zeroInstance, refreshAuth, exposeDataset, datasetCacheUrl])
|
|
716
861
|
|
|
717
862
|
return null
|
|
718
863
|
},
|
|
@@ -779,5 +924,6 @@ export function createZeroClientInternal<
|
|
|
779
924
|
zero,
|
|
780
925
|
preload,
|
|
781
926
|
getQuery,
|
|
927
|
+
remint,
|
|
782
928
|
}
|
|
783
929
|
}
|
|
@@ -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
|
})
|