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.
- package/dist/cjs/createZeroClient.cjs +111 -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 +145 -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/multiInstance.test.cjs +12 -0
- package/dist/cjs/multiInstance.test.native.js +21 -0
- package/dist/cjs/multiInstance.test.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 +112 -13
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +146 -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/multiInstance.test.mjs +12 -0
- package/dist/esm/multiInstance.test.mjs.map +1 -1
- package/dist/esm/multiInstance.test.native.js +21 -0
- package/dist/esm/multiInstance.test.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 +188 -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/multiInstance.test.tsx +13 -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'
|
|
@@ -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 ?
|
|
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
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
})
|