@teleporthq/teleport-shared 0.43.54 → 0.43.59

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 (145) hide show
  1. package/__tests__/_helpers/emitted-module-harness.ts +122 -0
  2. package/__tests__/cache/client-runtime.test.ts +341 -0
  3. package/__tests__/cache/server-runtime.test.ts +369 -0
  4. package/__tests__/utils/ast-effect-cleanup.ts +312 -0
  5. package/__tests__/utils/ast-scope.ts +120 -0
  6. package/__tests__/utils/ast-statement-order.ts +351 -0
  7. package/__tests__/utils/string-utils.ts +16 -0
  8. package/dist/cjs/cache/api-routes.d.ts +27 -0
  9. package/dist/cjs/cache/api-routes.d.ts.map +1 -0
  10. package/dist/cjs/cache/api-routes.js +33 -0
  11. package/dist/cjs/cache/api-routes.js.map +1 -0
  12. package/dist/cjs/cache/client-runtime.d.ts +41 -0
  13. package/dist/cjs/cache/client-runtime.d.ts.map +1 -0
  14. package/dist/cjs/cache/client-runtime.js +48 -0
  15. package/dist/cjs/cache/client-runtime.js.map +1 -0
  16. package/dist/cjs/cache/constants.d.ts +62 -0
  17. package/dist/cjs/cache/constants.d.ts.map +1 -0
  18. package/dist/cjs/cache/constants.js +69 -0
  19. package/dist/cjs/cache/constants.js.map +1 -0
  20. package/dist/cjs/cache/index.d.ts +6 -0
  21. package/dist/cjs/cache/index.d.ts.map +1 -0
  22. package/dist/cjs/cache/index.js +22 -0
  23. package/dist/cjs/cache/index.js.map +1 -0
  24. package/dist/cjs/cache/server-runtime.d.ts +33 -0
  25. package/dist/cjs/cache/server-runtime.d.ts.map +1 -0
  26. package/dist/cjs/cache/server-runtime.js +40 -0
  27. package/dist/cjs/cache/server-runtime.js.map +1 -0
  28. package/dist/cjs/cache/version-store.d.ts +28 -0
  29. package/dist/cjs/cache/version-store.d.ts.map +1 -0
  30. package/dist/cjs/cache/version-store.js +37 -0
  31. package/dist/cjs/cache/version-store.js.map +1 -0
  32. package/dist/cjs/index.d.ts +9 -1
  33. package/dist/cjs/index.d.ts.map +1 -1
  34. package/dist/cjs/index.js +17 -1
  35. package/dist/cjs/index.js.map +1 -1
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  37. package/dist/cjs/utils/ast-effect-cleanup.d.ts +54 -0
  38. package/dist/cjs/utils/ast-effect-cleanup.d.ts.map +1 -0
  39. package/dist/cjs/utils/ast-effect-cleanup.js +358 -0
  40. package/dist/cjs/utils/ast-effect-cleanup.js.map +1 -0
  41. package/dist/cjs/utils/ast-scope.d.ts +46 -0
  42. package/dist/cjs/utils/ast-scope.d.ts.map +1 -0
  43. package/dist/cjs/utils/ast-scope.js +482 -0
  44. package/dist/cjs/utils/ast-scope.js.map +1 -0
  45. package/dist/cjs/utils/ast-statement-order.d.ts +103 -0
  46. package/dist/cjs/utils/ast-statement-order.d.ts.map +1 -0
  47. package/dist/cjs/utils/ast-statement-order.js +312 -0
  48. package/dist/cjs/utils/ast-statement-order.js.map +1 -0
  49. package/dist/cjs/utils/email-date.d.ts +28 -0
  50. package/dist/cjs/utils/email-date.d.ts.map +1 -0
  51. package/dist/cjs/utils/email-date.js +35 -0
  52. package/dist/cjs/utils/email-date.js.map +1 -0
  53. package/dist/cjs/utils/html-content-model.d.ts +62 -0
  54. package/dist/cjs/utils/html-content-model.d.ts.map +1 -0
  55. package/dist/cjs/utils/html-content-model.js +293 -0
  56. package/dist/cjs/utils/html-content-model.js.map +1 -0
  57. package/dist/cjs/utils/storefront-tax.d.ts +59 -0
  58. package/dist/cjs/utils/storefront-tax.d.ts.map +1 -0
  59. package/dist/cjs/utils/storefront-tax.js +80 -0
  60. package/dist/cjs/utils/storefront-tax.js.map +1 -0
  61. package/dist/cjs/utils/string-utils.d.ts +1 -0
  62. package/dist/cjs/utils/string-utils.d.ts.map +1 -1
  63. package/dist/cjs/utils/string-utils.js +9 -1
  64. package/dist/cjs/utils/string-utils.js.map +1 -1
  65. package/dist/cjs/utils/style-declarations.d.ts +34 -0
  66. package/dist/cjs/utils/style-declarations.d.ts.map +1 -0
  67. package/dist/cjs/utils/style-declarations.js +77 -0
  68. package/dist/cjs/utils/style-declarations.js.map +1 -0
  69. package/dist/esm/cache/api-routes.d.ts +27 -0
  70. package/dist/esm/cache/api-routes.d.ts.map +1 -0
  71. package/dist/esm/cache/api-routes.js +28 -0
  72. package/dist/esm/cache/api-routes.js.map +1 -0
  73. package/dist/esm/cache/client-runtime.d.ts +41 -0
  74. package/dist/esm/cache/client-runtime.d.ts.map +1 -0
  75. package/dist/esm/cache/client-runtime.js +44 -0
  76. package/dist/esm/cache/client-runtime.js.map +1 -0
  77. package/dist/esm/cache/constants.d.ts +62 -0
  78. package/dist/esm/cache/constants.d.ts.map +1 -0
  79. package/dist/esm/cache/constants.js +64 -0
  80. package/dist/esm/cache/constants.js.map +1 -0
  81. package/dist/esm/cache/index.d.ts +6 -0
  82. package/dist/esm/cache/index.d.ts.map +1 -0
  83. package/dist/esm/cache/index.js +6 -0
  84. package/dist/esm/cache/index.js.map +1 -0
  85. package/dist/esm/cache/server-runtime.d.ts +33 -0
  86. package/dist/esm/cache/server-runtime.d.ts.map +1 -0
  87. package/dist/esm/cache/server-runtime.js +36 -0
  88. package/dist/esm/cache/server-runtime.js.map +1 -0
  89. package/dist/esm/cache/version-store.d.ts +28 -0
  90. package/dist/esm/cache/version-store.d.ts.map +1 -0
  91. package/dist/esm/cache/version-store.js +33 -0
  92. package/dist/esm/cache/version-store.js.map +1 -0
  93. package/dist/esm/index.d.ts +9 -1
  94. package/dist/esm/index.d.ts.map +1 -1
  95. package/dist/esm/index.js +9 -1
  96. package/dist/esm/index.js.map +1 -1
  97. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  98. package/dist/esm/utils/ast-effect-cleanup.d.ts +54 -0
  99. package/dist/esm/utils/ast-effect-cleanup.d.ts.map +1 -0
  100. package/dist/esm/utils/ast-effect-cleanup.js +330 -0
  101. package/dist/esm/utils/ast-effect-cleanup.js.map +1 -0
  102. package/dist/esm/utils/ast-scope.d.ts +46 -0
  103. package/dist/esm/utils/ast-scope.d.ts.map +1 -0
  104. package/dist/esm/utils/ast-scope.js +477 -0
  105. package/dist/esm/utils/ast-scope.js.map +1 -0
  106. package/dist/esm/utils/ast-statement-order.d.ts +103 -0
  107. package/dist/esm/utils/ast-statement-order.d.ts.map +1 -0
  108. package/dist/esm/utils/ast-statement-order.js +302 -0
  109. package/dist/esm/utils/ast-statement-order.js.map +1 -0
  110. package/dist/esm/utils/email-date.d.ts +28 -0
  111. package/dist/esm/utils/email-date.d.ts.map +1 -0
  112. package/dist/esm/utils/email-date.js +31 -0
  113. package/dist/esm/utils/email-date.js.map +1 -0
  114. package/dist/esm/utils/html-content-model.d.ts +62 -0
  115. package/dist/esm/utils/html-content-model.d.ts.map +1 -0
  116. package/dist/esm/utils/html-content-model.js +284 -0
  117. package/dist/esm/utils/html-content-model.js.map +1 -0
  118. package/dist/esm/utils/storefront-tax.d.ts +59 -0
  119. package/dist/esm/utils/storefront-tax.d.ts.map +1 -0
  120. package/dist/esm/utils/storefront-tax.js +74 -0
  121. package/dist/esm/utils/storefront-tax.js.map +1 -0
  122. package/dist/esm/utils/string-utils.d.ts +1 -0
  123. package/dist/esm/utils/string-utils.d.ts.map +1 -1
  124. package/dist/esm/utils/string-utils.js +7 -0
  125. package/dist/esm/utils/string-utils.js.map +1 -1
  126. package/dist/esm/utils/style-declarations.d.ts +34 -0
  127. package/dist/esm/utils/style-declarations.d.ts.map +1 -0
  128. package/dist/esm/utils/style-declarations.js +73 -0
  129. package/dist/esm/utils/style-declarations.js.map +1 -0
  130. package/package.json +3 -3
  131. package/src/cache/api-routes.ts +93 -0
  132. package/src/cache/client-runtime.ts +416 -0
  133. package/src/cache/constants.ts +74 -0
  134. package/src/cache/index.ts +5 -0
  135. package/src/cache/server-runtime.ts +251 -0
  136. package/src/cache/version-store.ts +137 -0
  137. package/src/index.ts +16 -0
  138. package/src/utils/ast-effect-cleanup.ts +370 -0
  139. package/src/utils/ast-scope.ts +558 -0
  140. package/src/utils/ast-statement-order.ts +345 -0
  141. package/src/utils/email-date.ts +44 -0
  142. package/src/utils/html-content-model.ts +298 -0
  143. package/src/utils/storefront-tax.ts +128 -0
  144. package/src/utils/string-utils.ts +8 -0
  145. package/src/utils/style-declarations.ts +117 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Evaluates an emitted runtime module the way the generated project will, but
3
+ * with every ambient it touches supplied explicitly.
4
+ *
5
+ * Emitted code is only ever exercised in a real deployment, which is the worst
6
+ * possible place to discover that a cache hands back a fresh array reference or
7
+ * throws on a full quota. Running the actual generated source here is the only
8
+ * way these guarantees are tested at all.
9
+ */
10
+ export interface FakeStorage {
11
+ readonly length: number
12
+ getItem(key: string): string | null
13
+ setItem(key: string, value: string): void
14
+ removeItem(key: string): void
15
+ key(index: number): string | null
16
+ }
17
+
18
+ export const createFakeStorage = (options: { failOnSet?: boolean } = {}) => {
19
+ const map = new Map<string, string>()
20
+ const storage = {
21
+ get length() {
22
+ return map.size
23
+ },
24
+ getItem: (key: string) => (map.has(key) ? (map.get(key) as string) : null),
25
+ setItem: (key: string, value: string) => {
26
+ if (options.failOnSet) {
27
+ const error = new Error('quota') as Error & { name: string }
28
+ error.name = 'QuotaExceededError'
29
+ throw error
30
+ }
31
+ map.set(key, value)
32
+ },
33
+ removeItem: (key: string) => {
34
+ map.delete(key)
35
+ },
36
+ key: (index: number) => Array.from(map.keys())[index] ?? null,
37
+ }
38
+ return { storage: storage as FakeStorage, map }
39
+ }
40
+
41
+ export interface LoadOptions {
42
+ // tslint:disable-next-line:no-any
43
+ requireStub?: (path: string) => any
44
+ // tslint:disable-next-line:no-any
45
+ window?: any
46
+ // tslint:disable-next-line:no-any
47
+ document?: any
48
+ // tslint:disable-next-line:no-any
49
+ performance?: any
50
+ // tslint:disable-next-line:no-any
51
+ fetch?: any
52
+ // tslint:disable-next-line:no-any
53
+ BroadcastChannel?: any
54
+ // tslint:disable-next-line:no-any
55
+ consoleStub?: any
56
+ }
57
+
58
+ const noop = (): undefined => undefined
59
+ const NOOP_CONSOLE = { warn: noop, error: noop, log: noop }
60
+
61
+ // tslint:disable-next-line:no-any
62
+ export const loadEmittedModule = (code: string, options: LoadOptions = {}): any => {
63
+ const module = { exports: {} as Record<string, unknown> }
64
+ // tslint:disable-next-line:function-constructor
65
+ const factory = new Function(
66
+ 'module',
67
+ 'exports',
68
+ 'require',
69
+ 'window',
70
+ 'document',
71
+ 'performance',
72
+ 'fetch',
73
+ 'BroadcastChannel',
74
+ 'console',
75
+ code
76
+ )
77
+
78
+ factory(
79
+ module,
80
+ module.exports,
81
+ options.requireStub ?? (() => ({})),
82
+ options.window,
83
+ options.document,
84
+ options.performance,
85
+ options.fetch ?? (() => Promise.reject(new Error('no fetch in test'))),
86
+ options.BroadcastChannel,
87
+ options.consoleStub ?? NOOP_CONSOLE
88
+ )
89
+
90
+ return module.exports
91
+ }
92
+
93
+ /** A `window` with working session/local storage and an event listener list. */
94
+ export const createFakeWindow = (options: { failOnSet?: boolean } = {}) => {
95
+ const session = createFakeStorage(options)
96
+ const local = createFakeStorage()
97
+ const listeners: Record<string, Array<(event: unknown) => void>> = {}
98
+
99
+ const documentListeners: Record<string, Array<(event: unknown) => void>> = {}
100
+
101
+ return {
102
+ session,
103
+ local,
104
+ listeners,
105
+ documentListeners,
106
+ document: {
107
+ visibilityState: 'visible',
108
+ addEventListener: (type: string, handler: (event: unknown) => void) => {
109
+ documentListeners[type] = documentListeners[type] || []
110
+ documentListeners[type].push(handler)
111
+ },
112
+ },
113
+ window: {
114
+ sessionStorage: session.storage,
115
+ localStorage: local.storage,
116
+ addEventListener: (type: string, handler: (event: unknown) => void) => {
117
+ listeners[type] = listeners[type] || []
118
+ listeners[type].push(handler)
119
+ },
120
+ },
121
+ }
122
+ }
@@ -0,0 +1,341 @@
1
+ import { generateCacheClientRuntime } from '../../src/cache/client-runtime'
2
+ import { createFakeWindow, loadEmittedModule } from '../_helpers/emitted-module-harness'
3
+
4
+ const CODE = generateCacheClientRuntime()
5
+ const SCOPE = 'ds_1:teleport_products'
6
+
7
+ const load = (options: { failOnSet?: boolean; reload?: boolean } = {}) => {
8
+ const fake = createFakeWindow({ failOnSet: options.failOnSet })
9
+ const runtime = loadEmittedModule(CODE, {
10
+ window: fake.window,
11
+ document: fake.document,
12
+ performance: { getEntriesByType: () => [{ type: options.reload ? 'reload' : 'navigate' }] },
13
+ })
14
+ runtime.tqMarkHydrated()
15
+ return { runtime, fake }
16
+ }
17
+
18
+ describe('emitted client cache runtime', () => {
19
+ it('derives a key that is stable under property order', () => {
20
+ const { runtime } = load()
21
+ const a = runtime.tqCacheKey({ page: 1, query: 'shoe', perPage: 20 })
22
+ const b = runtime.tqCacheKey({ perPage: 20, query: 'shoe', page: 1 })
23
+ expect(a).toBe(b)
24
+ })
25
+
26
+ it('distinguishes different filter/sort/search/page combinations', () => {
27
+ const { runtime } = load()
28
+ const base = { page: 1, perPage: 20, query: '', filters: '[]', sorts: '[]' }
29
+ const keys = new Set([
30
+ runtime.tqCacheKey(base),
31
+ runtime.tqCacheKey({ ...base, page: 2 }),
32
+ runtime.tqCacheKey({ ...base, query: 'shoe' }),
33
+ runtime.tqCacheKey({ ...base, sorts: '[{"field":"price"}]' }),
34
+ runtime.tqCacheKey({ ...base, filters: '[{"source":"category"}]' }),
35
+ ])
36
+ expect(keys.size).toBe(5)
37
+ })
38
+
39
+ /**
40
+ * `DataProvider` runs `useEffect(() => setData(initialData), [initialData])`.
41
+ * A fresh reference per read is an infinite render loop, so this is the single
42
+ * most important property of the whole browser cache.
43
+ */
44
+ it('hands back the SAME reference every time', () => {
45
+ const { runtime } = load()
46
+ const rows = [{ id: 1 }]
47
+ runtime.tqCacheSet(SCOPE, 'k', rows, 60, 1)
48
+
49
+ const first = runtime.tqCacheGet(SCOPE, 'k')
50
+ const second = runtime.tqCacheGet(SCOPE, 'k')
51
+ expect(first).toBe(second)
52
+ expect(first).toBe(rows)
53
+ })
54
+
55
+ it('keeps reference identity after a reparse from storage', () => {
56
+ const fake = createFakeWindow()
57
+ const write = loadEmittedModule(CODE, {
58
+ window: fake.window,
59
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
60
+ })
61
+ write.tqMarkHydrated()
62
+ write.tqCacheSet(SCOPE, 'k', [{ id: 1 }], 60, 1)
63
+
64
+ // A fresh module instance, same tab: the value now comes off storage.
65
+ const read = loadEmittedModule(CODE, {
66
+ window: fake.window,
67
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
68
+ })
69
+ read.tqMarkHydrated()
70
+ expect(read.tqCacheGet(SCOPE, 'k')).toBe(read.tqCacheGet(SCOPE, 'k'))
71
+ })
72
+
73
+ /**
74
+ * The peek is evaluated during RENDER. Returning a value before the first
75
+ * client effect would make the hydration render differ from the server render.
76
+ */
77
+ it('returns undefined until the hydration latch is flipped', () => {
78
+ const fake = createFakeWindow()
79
+ const runtime = loadEmittedModule(CODE, {
80
+ window: fake.window,
81
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
82
+ })
83
+ runtime.tqCacheSet(SCOPE, 'k', [{ id: 1 }], 60, 1)
84
+
85
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
86
+ runtime.tqMarkHydrated()
87
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toEqual([{ id: 1 }])
88
+ })
89
+
90
+ it('expires an entry once its TTL has passed', () => {
91
+ const { runtime } = load()
92
+ const now = Date.now()
93
+ const spy = jest.spyOn(Date, 'now')
94
+ spy.mockReturnValue(now)
95
+ runtime.tqCacheSet(SCOPE, 'k', [{ id: 1 }], 60, 1)
96
+
97
+ spy.mockReturnValue(now + 61_000)
98
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
99
+ spy.mockRestore()
100
+ })
101
+
102
+ /**
103
+ * A mid-mount expiry must not flip `initialData` back to undefined: the
104
+ * provider would blank the list and NOT refetch, because `params` never
105
+ * changed.
106
+ */
107
+ it('keeps a sticky entry alive past its TTL once it has been handed out', () => {
108
+ const { runtime } = load()
109
+ const now = Date.now()
110
+ const spy = jest.spyOn(Date, 'now')
111
+ spy.mockReturnValue(now)
112
+ runtime.tqCacheSet(SCOPE, 'k', [{ id: 1 }], 60, 1)
113
+ expect(runtime.tqCacheGet(SCOPE, 'k', { sticky: true })).toEqual([{ id: 1 }])
114
+
115
+ spy.mockReturnValue(now + 61_000)
116
+ expect(runtime.tqCacheGet(SCOPE, 'k', { sticky: true })).toEqual([{ id: 1 }])
117
+ // A non-sticky read of the same entry still sees it as expired.
118
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
119
+ spy.mockRestore()
120
+ })
121
+
122
+ it('drops the whole scope when the version moves', () => {
123
+ const { runtime } = load()
124
+ runtime.tqCacheSet(SCOPE, 'a', [1], 60, 1)
125
+ runtime.tqCacheSet(SCOPE, 'b', [2], 60, 1)
126
+ runtime.tqCacheSet('other:table', 'c', [3], 60, 1)
127
+
128
+ runtime.tqCacheSetVersion(SCOPE, 2)
129
+
130
+ expect(runtime.tqCacheGet(SCOPE, 'a')).toBeUndefined()
131
+ expect(runtime.tqCacheGet(SCOPE, 'b')).toBeUndefined()
132
+ expect(runtime.tqCacheGet('other:table', 'c')).toEqual([3])
133
+ })
134
+
135
+ it('purges everything when the page load was a reload', () => {
136
+ const fake = createFakeWindow()
137
+ const first = loadEmittedModule(CODE, {
138
+ window: fake.window,
139
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
140
+ })
141
+ first.tqMarkHydrated()
142
+ first.tqCacheSet(SCOPE, 'k', [{ id: 1 }], 600, 1)
143
+ expect(fake.session.map.size).toBe(1)
144
+
145
+ // Same tab, but this load is a refresh.
146
+ const afterReload = loadEmittedModule(CODE, {
147
+ window: fake.window,
148
+ performance: { getEntriesByType: () => [{ type: 'reload' }] },
149
+ })
150
+ afterReload.tqMarkHydrated()
151
+
152
+ expect(fake.session.map.size).toBe(0)
153
+ expect(afterReload.tqCacheGet(SCOPE, 'k')).toBeUndefined()
154
+ })
155
+
156
+ it('survives a full storage quota by staying in memory', () => {
157
+ const { runtime, fake } = load({ failOnSet: true })
158
+ const rows = [{ id: 1 }]
159
+
160
+ expect(() => runtime.tqCacheSet(SCOPE, 'k', rows, 60, 1)).not.toThrow()
161
+ expect(fake.session.map.size).toBe(0)
162
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBe(rows)
163
+ })
164
+
165
+ it('drops a corrupt storage entry instead of throwing', () => {
166
+ const fake = createFakeWindow()
167
+ fake.session.map.set('tqc:' + SCOPE + '|k', '{not json')
168
+ const runtime = loadEmittedModule(CODE, {
169
+ window: fake.window,
170
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
171
+ })
172
+ runtime.tqMarkHydrated()
173
+
174
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
175
+ expect(fake.session.map.size).toBe(0)
176
+ })
177
+
178
+ it('never throws and never caches when there is no window at all (SSR)', () => {
179
+ const runtime = loadEmittedModule(CODE, { window: undefined, performance: undefined })
180
+ runtime.tqMarkHydrated()
181
+
182
+ expect(() => runtime.tqCacheSet(SCOPE, 'k', [1], 60, 1)).not.toThrow()
183
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
184
+ })
185
+
186
+ it('evicts the least recently used entry past the cap', () => {
187
+ const { runtime } = load()
188
+ for (let i = 0; i < 70; i++) {
189
+ runtime.tqCacheSet(SCOPE, 'k' + i, [i], 600, 1)
190
+ }
191
+ expect(runtime.tqCacheGet(SCOPE, 'k0')).toBeUndefined()
192
+ expect(runtime.tqCacheGet(SCOPE, 'k69')).toEqual([69])
193
+ })
194
+
195
+ it('asks the version endpoint for every scope on the page in ONE request', async () => {
196
+ const calls: string[] = []
197
+ const fake = createFakeWindow()
198
+ const runtime = loadEmittedModule(CODE, {
199
+ window: fake.window,
200
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
201
+ fetch: (url: string) => {
202
+ calls.push(url)
203
+ return Promise.resolve({ json: () => Promise.resolve({ versions: { [SCOPE]: 7 } }) })
204
+ },
205
+ })
206
+ runtime.tqMarkHydrated()
207
+ runtime.tqCacheSet(SCOPE, 'k', [1], 600, 1)
208
+
209
+ runtime.tqCacheRevalidate([SCOPE, 'other:table'])
210
+ await new Promise((resolve) => setImmediate(resolve))
211
+
212
+ expect(calls).toHaveLength(1)
213
+ expect(calls[0]).toContain(encodeURIComponent(SCOPE + ',other:table'))
214
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toBeUndefined()
215
+ })
216
+
217
+ it('fails open when the version endpoint is down', async () => {
218
+ const fake = createFakeWindow()
219
+ const runtime = loadEmittedModule(CODE, {
220
+ window: fake.window,
221
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
222
+ fetch: () => Promise.reject(new Error('offline')),
223
+ })
224
+ runtime.tqMarkHydrated()
225
+ runtime.tqCacheSet(SCOPE, 'k', [1], 600, 1)
226
+
227
+ runtime.tqCacheRevalidate([SCOPE])
228
+ await new Promise((resolve) => setImmediate(resolve))
229
+
230
+ expect(runtime.tqCacheGet(SCOPE, 'k')).toEqual([1])
231
+ })
232
+ })
233
+
234
+ describe('sub-scope purges', () => {
235
+ /**
236
+ * The count endpoint caches under `<scope>:count`. A products write bumps the
237
+ * PARENT scope, so a purge that only matched `<scope>|` would refresh the rows
238
+ * and leave the total behind — the grid showing one more card than the pager
239
+ * counts.
240
+ */
241
+ it('clears a :count sub-scope when the parent scope is purged', () => {
242
+ const { runtime } = load()
243
+ runtime.tqCacheSet(SCOPE, 'rows', [{ id: 1 }], 600, 1)
244
+ runtime.tqCacheSet(`${SCOPE}:count`, 'total', { total: 1 }, 600, 1)
245
+
246
+ runtime.tqCachePurge(SCOPE)
247
+
248
+ expect(runtime.tqCacheGet(SCOPE, 'rows')).toBeUndefined()
249
+ expect(runtime.tqCacheGet(`${SCOPE}:count`, 'total')).toBeUndefined()
250
+ })
251
+
252
+ it('never reaches a sibling table whose name merely starts the same', () => {
253
+ const { runtime } = load()
254
+ runtime.tqCacheSet(`${SCOPE}_archive`, 'rows', [{ id: 9 }], 600, 1)
255
+
256
+ runtime.tqCachePurge(SCOPE)
257
+
258
+ expect(runtime.tqCacheGet(`${SCOPE}_archive`, 'rows')).toEqual([{ id: 9 }])
259
+ })
260
+ })
261
+
262
+ describe('revalidation when the visitor comes back to the tab', () => {
263
+ const loadWithFetch = (versions: Record<string, number>) => {
264
+ const calls: string[] = []
265
+ const fake = createFakeWindow()
266
+ const runtime = loadEmittedModule(CODE, {
267
+ window: fake.window,
268
+ document: fake.document,
269
+ performance: { getEntriesByType: () => [{ type: 'navigate' }] },
270
+ fetch: (url: string) => {
271
+ calls.push(url)
272
+ return Promise.resolve({ json: () => Promise.resolve({ versions }) })
273
+ },
274
+ })
275
+ runtime.tqMarkHydrated()
276
+ return { runtime, fake, calls }
277
+ }
278
+
279
+ /**
280
+ * Without this, a visitor who leaves a listing open serves their own cached
281
+ * rows and never talks to the server again, so they never learn about a write
282
+ * until the entry's TTL runs out — the exact hole the shared version exists
283
+ * to close.
284
+ */
285
+ it('re-asks for versions on visibilitychange and drops what moved', async () => {
286
+ const { runtime, fake, calls } = loadWithFetch({ [SCOPE]: 9 })
287
+ runtime.tqCacheSet(SCOPE, 'rows', [{ id: 1 }], 600, 1)
288
+ runtime.tqCacheRevalidate([SCOPE])
289
+ await new Promise((resolve) => setImmediate(resolve))
290
+ expect(calls).toHaveLength(1)
291
+
292
+ // Re-seed at the version the server just reported, then move it on.
293
+ runtime.tqCacheSet(SCOPE, 'rows', [{ id: 1 }], 600, 9)
294
+ expect(runtime.tqCacheGet(SCOPE, 'rows')).toEqual([{ id: 1 }])
295
+
296
+ await new Promise((resolve) => setTimeout(resolve, 1100))
297
+ calls.length = 0
298
+ fake.documentListeners.visibilitychange.forEach((handler) => handler({}))
299
+ await new Promise((resolve) => setImmediate(resolve))
300
+
301
+ expect(calls).toHaveLength(1)
302
+ expect(calls[0]).toContain(encodeURIComponent(SCOPE))
303
+ })
304
+
305
+ it('also revalidates on window focus', async () => {
306
+ const { runtime, fake, calls } = loadWithFetch({ [SCOPE]: 9 })
307
+ runtime.tqCacheRevalidate([SCOPE])
308
+ await new Promise((resolve) => setImmediate(resolve))
309
+ calls.length = 0
310
+
311
+ await new Promise((resolve) => setTimeout(resolve, 1100))
312
+ fake.listeners.focus.forEach((handler) => handler({}))
313
+ await new Promise((resolve) => setImmediate(resolve))
314
+
315
+ expect(calls).toHaveLength(1)
316
+ })
317
+
318
+ it('throttles a flurry of focus events into one request', async () => {
319
+ const { runtime, fake, calls } = loadWithFetch({ [SCOPE]: 9 })
320
+ runtime.tqCacheRevalidate([SCOPE])
321
+ await new Promise((resolve) => setImmediate(resolve))
322
+ calls.length = 0
323
+
324
+ await new Promise((resolve) => setTimeout(resolve, 1100))
325
+ fake.listeners.focus.forEach((handler) => handler({}))
326
+ fake.listeners.focus.forEach((handler) => handler({}))
327
+ fake.listeners.focus.forEach((handler) => handler({}))
328
+ await new Promise((resolve) => setImmediate(resolve))
329
+
330
+ expect(calls).toHaveLength(1)
331
+ })
332
+
333
+ it('does nothing when the page has no scopes to watch', async () => {
334
+ const { fake, calls } = loadWithFetch({})
335
+ await new Promise((resolve) => setTimeout(resolve, 1100))
336
+ fake.listeners.focus.forEach((handler) => handler({}))
337
+ await new Promise((resolve) => setImmediate(resolve))
338
+
339
+ expect(calls).toHaveLength(0)
340
+ })
341
+ })