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.
Files changed (82) hide show
  1. package/dist/cjs/createZeroClient.cjs +91 -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 +99 -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/multiPartition.test.cjs +42 -0
  23. package/dist/cjs/multiPartition.test.native.js +51 -0
  24. package/dist/cjs/multiPartition.test.native.js.map +1 -0
  25. package/dist/esm/createZeroClient.connection.test.mjs +164 -0
  26. package/dist/esm/createZeroClient.connection.test.mjs.map +1 -0
  27. package/dist/esm/createZeroClient.connection.test.native.js +240 -0
  28. package/dist/esm/createZeroClient.connection.test.native.js.map +1 -0
  29. package/dist/esm/createZeroClient.mjs +92 -13
  30. package/dist/esm/createZeroClient.mjs.map +1 -1
  31. package/dist/esm/createZeroClient.native.js +100 -13
  32. package/dist/esm/createZeroClient.native.js.map +1 -1
  33. package/dist/esm/createZeroClient.recovery.test.mjs +52 -0
  34. package/dist/esm/createZeroClient.recovery.test.mjs.map +1 -1
  35. package/dist/esm/createZeroClient.recovery.test.native.js +52 -0
  36. package/dist/esm/createZeroClient.recovery.test.native.js.map +1 -1
  37. package/dist/esm/helpers/recoverZeroClient.mjs +140 -15
  38. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  39. package/dist/esm/helpers/recoverZeroClient.native.js +155 -23
  40. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  41. package/dist/esm/helpers/recoverZeroClient.test.mjs +182 -1
  42. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  43. package/dist/esm/helpers/recoverZeroClient.test.native.js +210 -1
  44. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  45. package/dist/esm/index.js +2 -2
  46. package/dist/esm/index.js.map +1 -1
  47. package/dist/esm/index.mjs +2 -2
  48. package/dist/esm/index.mjs.map +1 -1
  49. package/dist/esm/index.native.js +2 -2
  50. package/dist/esm/index.native.js.map +1 -1
  51. package/dist/esm/multi.mjs +13 -1
  52. package/dist/esm/multi.mjs.map +1 -1
  53. package/dist/esm/multi.native.js +35 -1
  54. package/dist/esm/multi.native.js.map +1 -1
  55. package/dist/esm/multiPartition.test.mjs +43 -0
  56. package/dist/esm/multiPartition.test.mjs.map +1 -0
  57. package/dist/esm/multiPartition.test.native.js +49 -0
  58. package/dist/esm/multiPartition.test.native.js.map +1 -0
  59. package/lint/ssr-guards.js +164 -0
  60. package/package.json +2 -2
  61. package/readme.md +126 -0
  62. package/src/createZeroClient.connection.test.tsx +162 -0
  63. package/src/createZeroClient.recovery.test.tsx +68 -0
  64. package/src/createZeroClient.tsx +159 -13
  65. package/src/helpers/recoverZeroClient.test.ts +204 -1
  66. package/src/helpers/recoverZeroClient.ts +263 -42
  67. package/src/index.ts +3 -0
  68. package/src/multi.ts +33 -0
  69. package/src/multiPartition.test.ts +38 -0
  70. package/types/createZeroClient.connection.test.d.ts +5 -0
  71. package/types/createZeroClient.connection.test.d.ts.map +1 -0
  72. package/types/createZeroClient.d.ts +17 -0
  73. package/types/createZeroClient.d.ts.map +1 -1
  74. package/types/createZeroServer.d.ts +4 -4
  75. package/types/helpers/recoverZeroClient.d.ts +15 -1
  76. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  77. package/types/index.d.ts +1 -1
  78. package/types/index.d.ts.map +1 -1
  79. package/types/multi.d.ts +1 -0
  80. package/types/multi.d.ts.map +1 -1
  81. package/types/multiPartition.test.d.ts +2 -0
  82. package/types/multiPartition.test.d.ts.map +1 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * canonical oxlint plugin for zero model SSR guards — shipped WITH on-zero so
3
+ * every consumer references ONE source instead of copying it. add it as a
4
+ * jsPlugins entry in the consumer's .oxlintrc.json.
5
+ *
6
+ * it enforces on-zero's own footgun contract: run() and
7
+ * server.asyncTasks.push() must appear only inside a tree-shakeable
8
+ * `if (process.env.VITE_ENVIRONMENT === 'ssr')` block, never in the
9
+ * non-tree-shakeable `!== 'ssr'` early-return form.
10
+ *
11
+ * valid guards (tree-shakeable):
12
+ * - if (isSSR) { ... } — `typeof window === 'undefined'` constant
13
+ * - if (process.env.VITE_ENVIRONMENT === 'ssr') { ... } — legacy
14
+ *
15
+ * `if (server)` is NOT valid because it doesn't tree-shake.
16
+ */
17
+
18
+ function isEnvSSRCheck(node) {
19
+ if (!node) return false
20
+
21
+ // process.env.VITE_ENVIRONMENT === 'ssr'
22
+ if (node.type === 'BinaryExpression' && node.operator === '===') {
23
+ const left = node.left
24
+ const right = node.right
25
+ if (
26
+ left.type === 'MemberExpression' &&
27
+ left.object?.type === 'MemberExpression' &&
28
+ left.object?.object?.name === 'process' &&
29
+ left.object?.property?.name === 'env' &&
30
+ left.property?.name === 'VITE_ENVIRONMENT' &&
31
+ right.type === 'Literal' &&
32
+ right.value === 'ssr'
33
+ ) {
34
+ return true
35
+ }
36
+ }
37
+
38
+ // check compound conditions: if (x && isSSR)
39
+ if (node.type === 'LogicalExpression' && node.operator === '&&') {
40
+ return isEnvSSRCheck(node.left) || isEnvSSRCheck(node.right)
41
+ }
42
+
43
+ return false
44
+ }
45
+
46
+ function isSSRGuard(node) {
47
+ if (node.type !== 'IfStatement') return false
48
+ return isEnvSSRCheck(node.test)
49
+ }
50
+
51
+ function isModelFile(filename) {
52
+ return (
53
+ filename.includes('/data/mutations/') &&
54
+ !filename.includes('/data/mutations/helpers/')
55
+ )
56
+ }
57
+
58
+ // detect: if (process.env.VITE_ENVIRONMENT !== 'ssr') return
59
+ function isSSREarlyReturn(node) {
60
+ if (node.type !== 'IfStatement') return false
61
+ const test = node.test
62
+ if (
63
+ test.type === 'BinaryExpression' &&
64
+ test.operator === '!==' &&
65
+ test.left?.type === 'MemberExpression' &&
66
+ test.left?.object?.type === 'MemberExpression' &&
67
+ test.left?.object?.object?.name === 'process' &&
68
+ test.left?.object?.property?.name === 'env' &&
69
+ test.left?.property?.name === 'VITE_ENVIRONMENT' &&
70
+ test.right?.type === 'Literal' &&
71
+ test.right?.value === 'ssr'
72
+ ) {
73
+ const body =
74
+ node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent]
75
+ return body.some((s) => s.type === 'ReturnStatement')
76
+ }
77
+ return false
78
+ }
79
+
80
+ function createSSRGuardRule(matchCallee, messageId, message) {
81
+ return {
82
+ meta: {
83
+ type: 'problem',
84
+ messages: { [messageId]: message },
85
+ },
86
+ create(context) {
87
+ const filename = context.filename || context.getFilename()
88
+ if (!isModelFile(filename)) return {}
89
+
90
+ let isServerOnly = false
91
+ let guardDepth = 0
92
+
93
+ return {
94
+ ImportDeclaration(node) {
95
+ if (node.source.value === 'server-only') {
96
+ isServerOnly = true
97
+ }
98
+ },
99
+ IfStatement(node) {
100
+ if (isSSRGuard(node)) guardDepth++
101
+ },
102
+ 'IfStatement:exit'(node) {
103
+ if (isSSRGuard(node)) guardDepth--
104
+ },
105
+ CallExpression(node) {
106
+ if (isServerOnly) return
107
+ if (matchCallee(node) && guardDepth === 0) {
108
+ context.report({ node, messageId })
109
+ }
110
+ },
111
+ }
112
+ },
113
+ }
114
+ }
115
+
116
+ const runCallRule = createSSRGuardRule(
117
+ (node) => node.callee.type === 'Identifier' && node.callee.name === 'run',
118
+ 'missingGuard',
119
+ 'run() is a footgun on client, must be inside `if (process.env.VITE_ENVIRONMENT === "ssr")`',
120
+ )
121
+
122
+ const asyncTasksRule = createSSRGuardRule(
123
+ (node) => {
124
+ const callee = node.callee
125
+ return (
126
+ callee.type === 'MemberExpression' &&
127
+ callee.property?.name === 'push' &&
128
+ callee.object?.type === 'MemberExpression' &&
129
+ callee.object?.property?.name === 'asyncTasks'
130
+ )
131
+ },
132
+ 'missingGuard',
133
+ 'server.asyncTasks.push must be inside `if (process.env.VITE_ENVIRONMENT === "ssr")`',
134
+ )
135
+
136
+ const noEarlyReturnRule = {
137
+ meta: {
138
+ type: 'problem',
139
+ messages: {
140
+ noEarlyReturn:
141
+ 'Use `if (process.env.VITE_ENVIRONMENT === "ssr") { ... }` block, not `!== "ssr"` early return',
142
+ },
143
+ },
144
+ create(context) {
145
+ const filename = context.filename || context.getFilename()
146
+ if (!isModelFile(filename)) return {}
147
+ return {
148
+ IfStatement(node) {
149
+ if (isSSREarlyReturn(node)) {
150
+ context.report({ node, messageId: 'noEarlyReturn' })
151
+ }
152
+ },
153
+ }
154
+ },
155
+ }
156
+
157
+ export default {
158
+ meta: { name: 'ssr-guards' },
159
+ rules: {
160
+ 'run-call': runCallRule,
161
+ 'async-tasks': asyncTasksRule,
162
+ 'no-early-return': noEarlyReturnRule,
163
+ },
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "on-zero",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "A typed layer over @rocicorp/zero with queries, mutations, and permissions",
5
5
  "sideEffects": false,
6
6
  "source": "src/index.ts",
@@ -83,7 +83,7 @@
83
83
  }
84
84
  },
85
85
  "dependencies": {
86
- "@take-out/helpers": "0.6.3",
86
+ "@take-out/helpers": "0.6.4",
87
87
  "chokidar": "^4.0.3",
88
88
  "citty": "^0.1.6",
89
89
  "valibot": "^1.1.0"
package/readme.md CHANGED
@@ -578,6 +578,26 @@ constraints:
578
578
  - the server stays single: one `createZeroServer` per backend process.
579
579
  - omitting `instanceName` keeps the exact single-instance behavior.
580
580
 
581
+ **guarding the partition against drift.** a generated namespace that ends up in
582
+ NEITHER instance's split silently un-registers its queries (a `useQuery` then
583
+ throws "query not registered" at runtime, error-boundarying a whole screen). run
584
+ `assertZeroInstancePartition` at module eval over each split so the drift is a
585
+ boot throw instead — it also catches a namespace listed in more than one
586
+ partition:
587
+
588
+ ```ts
589
+ import { assertZeroInstancePartition } from 'on-zero/multi'
590
+
591
+ assertZeroInstancePartition('query namespace', groupedQueries, {
592
+ control: controlQueries,
593
+ project: projectQueries,
594
+ })
595
+ assertZeroInstancePartition('model namespace', models, {
596
+ control: controlModels,
597
+ project: projectModels,
598
+ })
599
+ ```
600
+
581
601
  ### server validation hooks
582
602
 
583
603
  add custom validation for all queries and mutations:
@@ -748,6 +768,112 @@ import { ensureAuth } from 'on-zero'
748
768
  const auth = ensureAuth() // AuthData (throws if not authenticated)
749
769
  ```
750
770
 
771
+ ## recovery
772
+
773
+ on-zero self-heals a client whose local sync state is lost or rejected. this is
774
+ **on by default** — a consumer that passes nothing gets the full behavior. the
775
+ hooks below let you compose ONE extra behavior (gate the reload, reload
776
+ natively, drop a benign log, refresh auth) without re-implementing the stack.
777
+
778
+ ### what's on by default
779
+
780
+ `ProvideZero` installs Zero's `onUpdateNeeded` / `onClientStateNotFound` and a
781
+ log sink that watches for the fatal store-loss / desync signatures. on a match it
782
+ drops the affected instance's local store and reloads the page ONCE. this covers:
783
+
784
+ - **update-needed** — `SchemaVersionNotSupported` (drops local state, the rows
785
+ are now incompatible), `NewClientGroup` / `VersionNotSupported` (reload
786
+ without dropping, so a sibling tab's shared IndexedDB survives).
787
+ - **client-state-not-found** — the store is unusable; drop it and reload.
788
+ - **log-only fatals** — `Expected IndexedDB not found`, native sqlite
789
+ `This statement has been finalized`, and repeated `Store is closed`.
790
+ - **the mutation/connection desync class** — `sent mutation ID … but expected`,
791
+ `oooMutation`, `already processed`, `InvalidConnectionRequestBaseCookie` /
792
+ `…LastMutationID`, `ClientNotFound`, `connection userID mismatch`. these
793
+ surface only through the error log, so the log sink recovers on them too.
794
+
795
+ genuinely app-infra signatures (your own synthesized ack-timeout marker, a
796
+ cold-boot connect timeout) are NOT in this list — keep those app-side and use
797
+ `benignLogFilter` to stop them from tripping recovery.
798
+
799
+ ### the hooks (all optional props on `ProvideZero`)
800
+
801
+ - **`scheduleReload?: (ctx) => void`** — take over WHEN/HOW the recovery reload
802
+ happens. `ctx = { reason, reasonKey, dropLocalState, performReload }`. the
803
+ default is an immediate reload; inject this to gate it (only reload when the
804
+ user is on a safe surface), show a countdown toast, or reload natively —
805
+ then call `ctx.performReload()` to run the real deletes-then-reload work. the
806
+ store delete is deferred until `performReload` runs, so a gated reload never
807
+ leaves the app on an already-deleted store. `performReload` is idempotent.
808
+
809
+ ```tsx
810
+ // native (expo): reload the bundle instead of location.reload()
811
+ <ProvideZero
812
+ scheduleReload={(ctx) => {
813
+ void ctx
814
+ .performReload()
815
+ .then(() => Updates.reloadAsync())
816
+ .catch(() => DevSettings.reload())
817
+ }}
818
+
819
+ />
820
+ ```
821
+
822
+ - **`beforeReload?: () => Promise<void>`** — awaited right before the reload
823
+ (e.g. wait for the dev origin to come back so the reload doesn't hit a
824
+ restarting server). composes with `scheduleReload`.
825
+
826
+ - **`benignLogFilter?: (message) => boolean`** — return `true` for a classified
827
+ recovery log you want treated as benign so it does NOT recover (your app's
828
+ cold-boot timeout, say). only affects recovery; the log still reaches the sink.
829
+
830
+ - **`refreshAuth?: () => Promise<string | undefined>`** — called when the
831
+ connection enters `needs-auth` (an expired token). return a fresh token and
832
+ on-zero reconnects in place — no reload. fires once per needs-auth transition.
833
+
834
+ - **`guardStorage?: { getItem, setItem }`** — the loop-guard's cross-reload
835
+ backing store. defaults to `sessionStorage` on web; inject a native KV
836
+ (MMKV/sqlite) on Hermes so native gets real cross-reload loop protection.
837
+
838
+ - **`connectionDataset?: boolean`** — mirror this instance's connection state
839
+ onto `document.body.dataset.zero*` (`zeroState`, `zeroConnected`,
840
+ `zeroReason`, `zeroCacheUrl`) for e2e/diagnostics. enable on ONE instance so
841
+ multiple instances don't clobber the dataset.
842
+
843
+ ### guard + latch semantics
844
+
845
+ - a **per-reason guard** (60s window) means the SAME reason re-failing right
846
+ after its reload is surfaced as `fatal` instead of reload-storming; distinct
847
+ reasons never suppress each other. it's two-tier: an in-memory map (real loop
848
+ protection within a page-load, works on Hermes) plus the cross-reload
849
+ `guardStorage` (survives the reload to catch an immediate re-fire).
850
+ - a **one-reload latch** means every affected instance of a combined client
851
+ drops its own store but only ONE reload fires. the latch **times out** (15s)
852
+ so a reload that never lands (a gated/native reload, a failed `reload()`)
853
+ can't kill recovery for the rest of the page's life.
854
+
855
+ ### remint — in-place recovery without a reload
856
+
857
+ `createZeroClient` returns **`remint(opts?)`** — the supported, native-safe
858
+ recovery path (a reload may never land on prod native, wedging the latch).
859
+ it drops the current instance's local store (unless `dropLocalState: false`) and
860
+ reconstructs a fresh Zero client in place, no page reload. it is rate-guarded
861
+ in-memory (12s between mints, 5 attempts before backing off, reset after 60s
862
+ stable) and returns `false` when suppressed. route your own
863
+ `onClientStateNotFound` to it if you need in-place recovery:
864
+
865
+ ```tsx
866
+ const { remint, ProvideZero } = createZeroClient({ … })
867
+ <ProvideZero onClientStateNotFound={() => { void remint() }} … />
868
+ ```
869
+
870
+ ### stale-poke resume (automatic)
871
+
872
+ a recoverable stale-cookie / stale-poke error (`Server returned unexpected base
873
+ cookie during sync`; `Received cookie … is < than last snapshot cookie … ignoring
874
+ client view`) is generic Zero behavior — on-zero's connection monitor reconnects
875
+ instead of surfacing a fatal error, deduped per reason. no configuration.
876
+
751
877
  ## patterns
752
878
 
753
879
  **server-only mutations:**
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { createSchema, string, table } from '@rocicorp/zero'
4
+ import { act } from 'react'
5
+ import { createRoot, type Root } from 'react-dom/client'
6
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest'
7
+
8
+ // a controllable connection whose state useConnectionState (useSyncExternalStore)
9
+ // subscribes to — set() notifies listeners so ConnectionMonitor's effect re-runs.
10
+ const fakeZero = vi.hoisted(() => {
11
+ class FakeConnectionState {
12
+ current: { name: string; reason?: string }
13
+ listeners = new Set<() => void>()
14
+ constructor(initial: { name: string; reason?: string }) {
15
+ this.current = initial
16
+ }
17
+ subscribe = (cb: () => void) => {
18
+ this.listeners.add(cb)
19
+ return () => {
20
+ this.listeners.delete(cb)
21
+ }
22
+ }
23
+ set(next: { name: string; reason?: string }) {
24
+ this.current = next
25
+ for (const cb of this.listeners) cb()
26
+ }
27
+ }
28
+
29
+ class FakeZero {
30
+ readonly context = {}
31
+ readonly connection = {
32
+ state: new FakeConnectionState({ name: 'connecting' }),
33
+ connect: vi.fn(async () => {}),
34
+ }
35
+ readonly delete = vi.fn(async () => ({ errors: [] }))
36
+ readonly close = vi.fn()
37
+ readonly run = vi.fn(async () => [])
38
+ readonly preload = vi.fn(() => ({ cleanup: () => {}, complete: Promise.resolve() }))
39
+
40
+ constructor() {
41
+ instances.push(this)
42
+ }
43
+ }
44
+
45
+ const instances: FakeZero[] = []
46
+ return { FakeZero, FakeConnectionState, instances }
47
+ })
48
+
49
+ vi.mock('@rocicorp/zero', async (importOriginal) => {
50
+ const actual = await importOriginal<typeof import('@rocicorp/zero')>()
51
+ return { ...actual, Zero: fakeZero.FakeZero }
52
+ })
53
+
54
+ import { createZeroClient } from './createZeroClient'
55
+
56
+ declare global {
57
+ // eslint-disable-next-line no-var
58
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined
59
+ }
60
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
61
+
62
+ const noteTable = table('note').columns({ id: string(), body: string() }).primaryKey('id')
63
+ const schema = createSchema({ tables: [noteTable] })
64
+
65
+ const client = createZeroClient({
66
+ schema,
67
+ models: {},
68
+ groupedQueries: {},
69
+ instanceName: 'connection-test',
70
+ })
71
+
72
+ let container: HTMLDivElement
73
+ let root: Root | null
74
+
75
+ beforeEach(() => {
76
+ fakeZero.instances.length = 0
77
+ container = document.createElement('div')
78
+ root = null
79
+ delete document.body.dataset.zeroState
80
+ delete document.body.dataset.zeroConnected
81
+ delete document.body.dataset.zeroReason
82
+ })
83
+
84
+ afterEach(() => {
85
+ if (root) act(() => root?.unmount())
86
+ })
87
+
88
+ async function mount(extraProps: Record<string, unknown> = {}, userID = 'conn') {
89
+ root = createRoot(container)
90
+ await act(async () => {
91
+ root?.render(
92
+ <client.ProvideZero
93
+ cacheURL="http://127.0.0.1:7788/zero"
94
+ userID={userID}
95
+ {...extraProps}
96
+ >
97
+ <span>ok</span>
98
+ </client.ProvideZero>,
99
+ )
100
+ await Promise.resolve()
101
+ })
102
+ return fakeZero.instances.at(-1)!
103
+ }
104
+
105
+ test('refreshAuth reconnects in place on needs-auth, once per transition', async () => {
106
+ const refreshAuth = vi.fn(async () => 'fresh-token')
107
+ const instance = await mount({ refreshAuth }, 'conn-auth')
108
+
109
+ await act(async () => {
110
+ instance.connection.state.set({ name: 'needs-auth', reason: 'token expired' })
111
+ await Promise.resolve()
112
+ })
113
+
114
+ expect(refreshAuth).toHaveBeenCalledTimes(1)
115
+ await act(async () => {
116
+ await Promise.resolve()
117
+ })
118
+ expect(instance.connection.connect).toHaveBeenCalledWith({ auth: 'fresh-token' })
119
+ })
120
+
121
+ test('stale-poke error reconnects instead of surfacing a fatal error', async () => {
122
+ const events: Array<{ type: string }> = []
123
+ const off = client.zeroEvents.listen((event) => {
124
+ if (event) events.push(event)
125
+ })
126
+ const instance = await mount({}, 'conn-stale')
127
+
128
+ await act(async () => {
129
+ instance.connection.state.set({
130
+ name: 'error',
131
+ reason: 'Server returned unexpected base cookie during sync',
132
+ })
133
+ await Promise.resolve()
134
+ })
135
+
136
+ // reconnect issued, no auth arg (plain resume)
137
+ expect(instance.connection.connect).toHaveBeenCalledTimes(1)
138
+ expect(instance.connection.connect).toHaveBeenCalledWith()
139
+ // the stale-poke reason must NOT be emitted as an error event
140
+ expect(events.some((event) => event.type === 'error')).toBe(false)
141
+ off()
142
+ })
143
+
144
+ test('connectionDataset mirrors connection state onto the body dataset', async () => {
145
+ const instance = await mount({ connectionDataset: true }, 'conn-dataset')
146
+
147
+ await act(async () => {
148
+ instance.connection.state.set({ name: 'connected' })
149
+ await Promise.resolve()
150
+ })
151
+ expect(document.body.dataset.zeroState).toBe('connected')
152
+ expect(document.body.dataset.zeroConnected).toBe('true')
153
+ expect(document.body.dataset.zeroCacheUrl).toBe('http://127.0.0.1:7788/zero')
154
+
155
+ await act(async () => {
156
+ instance.connection.state.set({ name: 'error', reason: 'boom' })
157
+ await Promise.resolve()
158
+ })
159
+ expect(document.body.dataset.zeroState).toBe('error')
160
+ expect(document.body.dataset.zeroConnected).toBeUndefined()
161
+ expect(document.body.dataset.zeroReason).toBe('boom')
162
+ })
@@ -102,3 +102,71 @@ test('zero.delete invalidates cached instance before a remount recovery', async
102
102
  expect(fakeZero.instances).toHaveLength(2)
103
103
  expect(fakeZero.instances[1]).not.toBe(first)
104
104
  })
105
+
106
+ test('remint drops local state and reconstructs a fresh instance in place', async () => {
107
+ // unique userID so instanceKey misses the module-level cache from prior tests
108
+ // and a fresh instance is genuinely constructed here.
109
+ root = createRoot(container)
110
+ await act(async () => {
111
+ root?.render(
112
+ <client.ProvideZero cacheURL="http://127.0.0.1:7777/zero" userID="remint-test">
113
+ <span>ok</span>
114
+ </client.ProvideZero>,
115
+ )
116
+ await Promise.resolve()
117
+ })
118
+ const first = fakeZero.instances.at(-1)
119
+ expect(first).toBeDefined()
120
+ const countBefore = fakeZero.instances.length
121
+
122
+ let result: boolean | undefined
123
+ await act(async () => {
124
+ result = await client.remint()
125
+ // let the generation bump re-render and the rotate effect mint the fresh one
126
+ await Promise.resolve()
127
+ })
128
+
129
+ expect(result).toBe(true)
130
+ // dropped the rejected store + closed it, then minted a genuinely new client
131
+ // without unmounting the provider (no page reload).
132
+ expect(first?.delete).toHaveBeenCalledTimes(1)
133
+ expect(fakeZero.instances.length).toBe(countBefore + 1)
134
+ expect(fakeZero.instances.at(-1)).not.toBe(first)
135
+ })
136
+
137
+ test('remint with no provider mounted returns false without burning the guard budget', async () => {
138
+ // own client so the shared remint guard state is fresh for this assertion.
139
+ const isolated = createZeroClient({
140
+ schema,
141
+ models: {},
142
+ groupedQueries: {},
143
+ instanceName: 'remint-unmounted-test',
144
+ })
145
+
146
+ // nothing mounted → remintControl.bump is null. these must NOT start the 12s
147
+ // cooldown or consume the attempt budget.
148
+ expect(await isolated.remint()).toBe(false)
149
+ expect(await isolated.remint()).toBe(false)
150
+
151
+ root = createRoot(container)
152
+ await act(async () => {
153
+ root?.render(
154
+ <isolated.ProvideZero
155
+ cacheURL="http://127.0.0.1:7777/zero"
156
+ userID="remint-unmounted"
157
+ >
158
+ <span>ok</span>
159
+ </isolated.ProvideZero>,
160
+ )
161
+ await Promise.resolve()
162
+ })
163
+
164
+ // a mounted remint immediately after is still allowed — proof the unmounted
165
+ // calls didn't burn the guard (old code set lastRemintAt before this check).
166
+ let result: boolean | undefined
167
+ await act(async () => {
168
+ result = await isolated.remint()
169
+ await Promise.resolve()
170
+ })
171
+ expect(result).toBe(true)
172
+ })