fontdue-js 3.5.1 → 3.7.0

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 (151) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -0
  3. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
  4. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +1 -1
  5. package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.d.ts +24 -0
  6. package/dist/__generated__/CustomerSignInRequestLoginCodeMutation.graphql.js +71 -0
  7. package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.d.ts +30 -0
  8. package/dist/__generated__/CustomerSignInVerifyLoginCodeMutation.graphql.js +98 -0
  9. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +4 -1
  10. package/dist/__generated__/FontFamiliesQuery.graphql.js +46 -39
  11. package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.d.ts +3 -1
  12. package/dist/__generated__/NewsletterSignupUpdateCustomerMutation.graphql.js +1 -1
  13. package/dist/__generated__/PriceBar_node.graphql.d.ts +4 -1
  14. package/dist/__generated__/PriceBar_node.graphql.js +23 -16
  15. package/dist/__generated__/StoreModalFamily_collection.graphql.d.ts +3 -2
  16. package/dist/__generated__/StoreModalFamily_collection.graphql.js +3 -3
  17. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.d.ts +23 -0
  18. package/dist/__generated__/StoreModalIndexPaginationQuery.graphql.js +303 -0
  19. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +4 -9
  20. package/dist/__generated__/StoreModalIndexQuery.graphql.js +90 -53
  21. package/dist/__generated__/StoreModalIndex_viewer.graphql.d.ts +24 -0
  22. package/dist/__generated__/StoreModalIndex_viewer.graphql.js +134 -0
  23. package/dist/__generated__/StoreModalProductContent_collection.graphql.d.ts +4 -1
  24. package/dist/__generated__/StoreModalProductContent_collection.graphql.js +12 -5
  25. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.d.ts +2 -1
  26. package/dist/__generated__/StoreModalProductLicenseSelection_collection.graphql.js +27 -24
  27. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +2 -19
  28. package/dist/__generated__/StoreModalProductQuery.graphql.js +143 -174
  29. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  30. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +54 -53
  31. package/dist/__generated__/StoreModalProductSlugQuery.graphql.d.ts +38 -0
  32. package/dist/__generated__/StoreModalProductSlugQuery.graphql.js +750 -0
  33. package/dist/__generated__/StoreModalProductViewer.graphql.d.ts +34 -0
  34. package/dist/__generated__/StoreModalProductViewer.graphql.js +149 -0
  35. package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.d.ts +3 -1
  36. package/dist/__generated__/StoreModalUnifiedCheckoutUpdateCustomerMutation.graphql.js +1 -1
  37. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +3 -1
  38. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +1 -1
  39. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  40. package/dist/__generated__/TypeTestersIDQuery.graphql.js +17 -10
  41. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  42. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +17 -10
  43. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  44. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +17 -10
  45. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.d.ts +41 -0
  46. package/dist/__generated__/storefrontEventsTrackStorefrontEventMutation.graphql.js +65 -0
  47. package/dist/__generated__/useCurrentCustomerQuery.graphql.d.ts +23 -0
  48. package/dist/__generated__/{ServerConfigProviderQuery.graphql.js → useCurrentCustomerQuery.graphql.js} +24 -39
  49. package/dist/__tests__/bundleSelectionOrder.test.js +92 -0
  50. package/dist/__tests__/collectionBundleSelection.test.js +441 -1446
  51. package/dist/__tests__/customerSession.test.js +456 -0
  52. package/dist/__tests__/infiniteScroll.test.js +180 -0
  53. package/dist/__tests__/licenseExclusions.test.js +2 -1
  54. package/dist/__tests__/storeModalRoutes.test.js +118 -0
  55. package/dist/__tests__/storefrontEvents.test.js +167 -0
  56. package/dist/__tests__/stripeElements.test.js +173 -0
  57. package/dist/__tests__/typeTesterAutofitFeatures.test.js +138 -0
  58. package/dist/__tests__/typeTesterInitialSize.test.js +82 -0
  59. package/dist/__tests__/typeTesterTruncate.test.js +218 -0
  60. package/dist/auth/session.d.ts +42 -0
  61. package/dist/auth/session.js +139 -0
  62. package/dist/auth/tokenStore.d.ts +61 -0
  63. package/dist/auth/tokenStore.js +162 -0
  64. package/dist/components/Cart/Checkout.js +19 -9
  65. package/dist/components/Cart/storefrontEvents.d.ts +34 -0
  66. package/dist/components/Cart/storefrontEvents.js +138 -0
  67. package/dist/components/ConfigContext.d.ts +2 -2
  68. package/dist/components/CustomerSignIn/index.d.ts +33 -0
  69. package/dist/components/CustomerSignIn/index.js +170 -0
  70. package/dist/components/FontFamilies/index.js +4 -3
  71. package/dist/components/NewsletterSignup/index.js +5 -1
  72. package/dist/components/Precart/index.js +6 -0
  73. package/dist/components/PriceBar/index.js +5 -3
  74. package/dist/components/Root/index.js +4 -34
  75. package/dist/components/Root/productState.d.ts +2 -8
  76. package/dist/components/Root/productState.js +46 -57
  77. package/dist/components/SKUPrice/index.js +7 -8
  78. package/dist/components/StoreModal/StoreModalFamily.js +4 -2
  79. package/dist/components/StoreModal/StoreModalIndex.js +48 -7
  80. package/dist/components/StoreModal/StoreModalProduct.d.ts +5 -1
  81. package/dist/components/StoreModal/StoreModalProduct.js +69 -16
  82. package/dist/components/StoreModal/index.js +14 -14
  83. package/dist/components/StoreModal/routes.js +9 -1
  84. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.d.ts +2 -1
  85. package/dist/components/StoreModalProductLicenseSelection/StoreModalProductLicense.js +8 -3
  86. package/dist/components/StoreModalProductLicenseSelection/index.js +3 -2
  87. package/dist/components/TestFontsForm/index.js +5 -1
  88. package/dist/components/TypeTester/TypeTesterContent.js +9 -2
  89. package/dist/components/TypeTester/TypeTesterContext.js +17 -4
  90. package/dist/components/TypeTester/TypeTesterFitter.js +7 -2
  91. package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -0
  92. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -0
  93. package/dist/components/TypeTester/TypeTesterStandaloneElement.d.ts +1 -0
  94. package/dist/components/TypeTester/TypeTesterStandaloneElement.js +4 -1
  95. package/dist/components/TypeTester/TypeTesterVariableAxes.js +16 -10
  96. package/dist/components/TypeTester/index.d.ts +3 -1
  97. package/dist/components/TypeTester/index.js +2 -1
  98. package/dist/components/TypeTester/truncate.d.ts +12 -0
  99. package/dist/components/TypeTester/truncate.js +95 -0
  100. package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
  101. package/dist/components/TypeTester/useTypeTesterStyler.js +36 -7
  102. package/dist/components/TypeTesters/TypeTestersElement.d.ts +2 -1
  103. package/dist/components/TypeTesters/TypeTestersElement.js +4 -1
  104. package/dist/components/TypeTesters/index.d.ts +2 -0
  105. package/dist/components/elements/StoreModalContainer/StoreModalContainerContext.d.ts +1 -0
  106. package/dist/components/elements/StoreModalContainer/index.js +9 -4
  107. package/dist/components/elements/StoreModalIndexLoadMore/index.d.ts +8 -0
  108. package/dist/components/elements/StoreModalIndexLoadMore/index.js +21 -0
  109. package/dist/components/elements/StoreModalUnifiedCheckout.d.ts +1 -1
  110. package/dist/components/elements/StoreModalUnifiedCheckout.js +20 -17
  111. package/dist/components/useInfiniteScroll.d.ts +28 -0
  112. package/dist/components/useInfiniteScroll.js +50 -0
  113. package/dist/fontdue.css +63 -0
  114. package/dist/hooks/useAutofit.d.ts +2 -1
  115. package/dist/hooks/useAutofit.js +10 -4
  116. package/dist/hooks/useCurrentCustomer.d.ts +34 -0
  117. package/dist/hooks/useCurrentCustomer.js +93 -0
  118. package/dist/reducer.d.ts +5 -5
  119. package/dist/reducer.js +9 -87
  120. package/dist/relay/environment.js +91 -50
  121. package/dist/selection.d.ts +40 -0
  122. package/dist/selection.js +494 -0
  123. package/dist/storeModalRoutes.d.ts +38 -0
  124. package/dist/storeModalRoutes.js +110 -0
  125. package/dist/stripeElements.d.ts +18 -0
  126. package/dist/stripeElements.js +49 -0
  127. package/dist/utils.d.ts +1 -12
  128. package/dist/utils.js +1 -36
  129. package/dist/version.js +1 -1
  130. package/package.json +1 -1
  131. package/dist/__generated__/NodePasswordFormIDQuery.graphql.d.ts +0 -21
  132. package/dist/__generated__/NodePasswordFormIDQuery.graphql.js +0 -86
  133. package/dist/__generated__/NodePasswordFormSlugQuery.graphql.d.ts +0 -25
  134. package/dist/__generated__/NodePasswordFormSlugQuery.graphql.js +0 -108
  135. package/dist/__generated__/ServerConfigProviderQuery.graphql.d.ts +0 -24
  136. package/dist/__generated__/TestModeBannerQuery.graphql.d.ts +0 -20
  137. package/dist/__generated__/TestModeBannerQuery.graphql.js +0 -87
  138. package/dist/__generated__/ThemeConfigQuery.graphql.d.ts +0 -20
  139. package/dist/__generated__/ThemeConfigQuery.graphql.js +0 -87
  140. package/dist/components/CartButton/index.server.d.ts +0 -3
  141. package/dist/components/CartButton/index.server.js +0 -18
  142. package/dist/components/CorsErrorModal.d.ts +0 -1
  143. package/dist/components/CorsErrorModal.js +0 -92
  144. package/dist/components/FontdueProvider/FontdueProviderClientComponent.d.ts +0 -14
  145. package/dist/components/FontdueProvider/FontdueProviderClientComponent.js +0 -61
  146. package/dist/components/ThemeConfig/index.server.d.ts +0 -2
  147. package/dist/components/ThemeConfig/index.server.js +0 -17
  148. package/dist/components/TypeTester/features.d.ts +0 -5
  149. package/dist/components/TypeTester/features.js +0 -143
  150. package/dist/data/unicodeData.d.ts +0 -4
  151. package/dist/data/unicodeData.js +0 -1
@@ -0,0 +1,456 @@
1
+ /**
2
+ * The customer session: where the tokens live, and what the network layer does
3
+ * with them.
4
+ *
5
+ * Every part of this is browser-only by design — on the server the store
6
+ * reports "signed out", so one visitor's session can never be rendered into
7
+ * another's page. Rather than a DOM environment, the browser is stubbed with
8
+ * exactly what the code touches (`window.localStorage`), which lets the same
9
+ * file also test the server case by leaving it unstubbed. Both modules read
10
+ * `typeof window` once at load, so the stub goes up before the dynamic import.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+ beforeEach(() => {
15
+ vi.resetModules();
16
+ vi.unstubAllEnvs();
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ /** A memory-backed `window.localStorage`, or one that refuses to store. */
21
+ function browser() {
22
+ let {
23
+ throws = false
24
+ } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
25
+ const data = new Map();
26
+ const guard = () => {
27
+ if (throws) throw new Error('SecurityError');
28
+ };
29
+ const localStorage = {
30
+ getItem: key => {
31
+ guard();
32
+ return data.get(key) ?? null;
33
+ },
34
+ setItem: (key, value) => {
35
+ guard();
36
+ data.set(key, value);
37
+ },
38
+ removeItem: key => {
39
+ guard();
40
+ data.delete(key);
41
+ }
42
+ };
43
+
44
+ // `addEventListener` is not used by the session code, but the modules under
45
+ // test import corsError, which registers a `beforeunload` listener at load.
46
+ vi.stubGlobal('window', {
47
+ localStorage,
48
+ addEventListener: () => {},
49
+ removeEventListener: () => {}
50
+ });
51
+ return data;
52
+ }
53
+ const request = {
54
+ name: 'TestQuery',
55
+ text: 'query TestQuery { viewer { id } }'
56
+ };
57
+ const tokens = {
58
+ accessToken: 'access-1',
59
+ refreshToken: 'refresh-1'
60
+ };
61
+ async function tokenStore() {
62
+ const store = await import("../auth/tokenStore.js");
63
+ store.resetTokenStore();
64
+ return store;
65
+ }
66
+ function stored(data, url) {
67
+ const raw = data.get(`fontdue:auth:${url}`);
68
+ return raw == null ? null : JSON.parse(raw);
69
+ }
70
+ function ok(body) {
71
+ return {
72
+ ok: true,
73
+ status: 200,
74
+ json: async () => body
75
+ };
76
+ }
77
+
78
+ // The mocked endpoint answers whichever operation it is handed, so one mock
79
+ // covers a query, the refresh mutation it provokes, and the retry.
80
+ function endpoint(responses) {
81
+ const queries = [...responses.query];
82
+
83
+ // The `Authorization` header as each request actually went out. It has to be
84
+ // read here rather than from `mock.calls` afterwards: the fetch options are
85
+ // one object reused across the retry, so renewing the token rewrites the
86
+ // header the first call appears to have been made with.
87
+ const sent = [];
88
+ const mock = vi.fn(async (_url, init) => {
89
+ var _init$headers;
90
+ sent.push((_init$headers = init.headers) === null || _init$headers === void 0 ? void 0 : _init$headers.Authorization);
91
+ const body = JSON.parse(String(init.body));
92
+ if (body.query.includes('FontdueRefreshAccessToken')) {
93
+ return ok(responses.refresh ?? {
94
+ data: {
95
+ refreshAccessToken: null
96
+ }
97
+ });
98
+ }
99
+ return ok(queries.shift() ?? {
100
+ data: {}
101
+ });
102
+ });
103
+ return Object.assign(mock, {
104
+ sent
105
+ });
106
+ }
107
+ describe('token store', () => {
108
+ it('persists tokens under a key naming the Fontdue site', async () => {
109
+ const data = browser();
110
+ const store = await tokenStore();
111
+ store.setTokens(tokens, 'https://acme.fontdue.com');
112
+ expect(store.tokenStorageKey('https://acme.fontdue.com')).toBe('fontdue:auth:https://acme.fontdue.com');
113
+ expect(stored(data, 'https://acme.fontdue.com')).toEqual(tokens);
114
+ });
115
+ it('reads tokens a previous page load stored', async () => {
116
+ const data = browser();
117
+ data.set('fontdue:auth:https://acme.fontdue.com', JSON.stringify(tokens));
118
+ const store = await tokenStore();
119
+ expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
120
+ });
121
+ it('keeps two foundries apart, and ignores a trailing slash or casing', async () => {
122
+ browser();
123
+ const store = await tokenStore();
124
+ store.setTokens(tokens, 'https://acme.fontdue.com');
125
+
126
+ // Same site, spelled differently — one session, not three.
127
+ expect(store.getTokens('https://acme.fontdue.com/')).toEqual(tokens);
128
+ expect(store.getTokens('https://ACME.fontdue.com')).toEqual(tokens);
129
+
130
+ // A different foundry must never be handed this token.
131
+ expect(store.getTokens('https://other.fontdue.com')).toBeNull();
132
+ });
133
+ it('signs out everywhere, storage included', async () => {
134
+ const data = browser();
135
+ const store = await tokenStore();
136
+ store.setTokens(tokens, 'https://acme.fontdue.com');
137
+ store.clearTokens('https://acme.fontdue.com');
138
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
139
+ expect(stored(data, 'https://acme.fontdue.com')).toBeNull();
140
+ });
141
+ it('tells subscribers, which is how one island hears about another', async () => {
142
+ browser();
143
+ const store = await tokenStore();
144
+ const listener = vi.fn();
145
+ const unsubscribe = store.subscribeToTokens(listener);
146
+ store.setTokens(tokens, 'https://acme.fontdue.com');
147
+ expect(listener).toHaveBeenCalledTimes(1);
148
+ store.clearTokens('https://acme.fontdue.com');
149
+ expect(listener).toHaveBeenCalledTimes(2);
150
+ unsubscribe();
151
+ store.setTokens(tokens, 'https://acme.fontdue.com');
152
+ expect(listener).toHaveBeenCalledTimes(2);
153
+ });
154
+ it('survives a browser that refuses to store anything', async () => {
155
+ // Private windows, blocked site data and partitioned third-party storage
156
+ // all throw here rather than returning null. The session should last the
157
+ // tab rather than taking the page down.
158
+ browser({
159
+ throws: true
160
+ });
161
+ const store = await tokenStore();
162
+ expect(() => store.setTokens(tokens, 'https://acme.fontdue.com')).not.toThrow();
163
+ // Held in memory, so this tab stays signed in even though nothing persisted.
164
+ expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
165
+ });
166
+ it('ignores a stored value that is not a token pair', async () => {
167
+ const data = browser();
168
+ data.set('fontdue:auth:https://acme.fontdue.com', 'not json');
169
+ let store = await tokenStore();
170
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
171
+ data.set('fontdue:auth:https://acme.fontdue.com', JSON.stringify({
172
+ accessToken: 'only-half'
173
+ }));
174
+ store = await tokenStore();
175
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
176
+ });
177
+ it('reports signed out on the server, whatever is stored', async () => {
178
+ // No `window` stub: this is the SSR pass. Returning a token here would put
179
+ // one visitor's session into another's prerendered page, and would have the
180
+ // first client render disagree with the server HTML.
181
+ const store = await tokenStore();
182
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
183
+ expect(() => store.setTokens(tokens, 'https://acme.fontdue.com')).not.toThrow();
184
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
185
+ });
186
+ });
187
+ describe('createNetworkFetch (customer session)', () => {
188
+ it('sends no Authorization header when signed out', async () => {
189
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
190
+ browser();
191
+ await tokenStore();
192
+ const fetchMock = endpoint({
193
+ query: [{
194
+ data: {}
195
+ }]
196
+ });
197
+ vi.stubGlobal('fetch', fetchMock);
198
+ const {
199
+ createNetworkFetch
200
+ } = await import("../relay/environment.js");
201
+ await createNetworkFetch()(request, {});
202
+ expect(fetchMock.sent[0]).toBeUndefined();
203
+ });
204
+ it('sends the access token once the customer is signed in', async () => {
205
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
206
+ browser();
207
+ const store = await tokenStore();
208
+ store.setTokens(tokens, 'https://acme.fontdue.com');
209
+ const fetchMock = endpoint({
210
+ query: [{
211
+ data: {}
212
+ }]
213
+ });
214
+ vi.stubGlobal('fetch', fetchMock);
215
+ const {
216
+ createNetworkFetch
217
+ } = await import("../relay/environment.js");
218
+ await createNetworkFetch()(request, {});
219
+ expect(fetchMock.sent[0]).toBe('Bearer access-1');
220
+ });
221
+ it('does not let a customer session look like an admin preview', async () => {
222
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
223
+ browser();
224
+ const store = await tokenStore();
225
+ store.setTokens(tokens, 'https://acme.fontdue.com');
226
+ const fetchMock = endpoint({
227
+ query: [{
228
+ data: {}
229
+ }]
230
+ });
231
+ vi.stubGlobal('fetch', fetchMock);
232
+ const {
233
+ createNetworkFetch
234
+ } = await import("../relay/environment.js");
235
+ await createNetworkFetch()(request, {});
236
+
237
+ // Preview is decided by the marker cookie, never by the presence of an
238
+ // Authorization header — otherwise signing in would reveal unpublished
239
+ // fonts to every customer.
240
+ const init = fetchMock.mock.calls[0][1];
241
+ expect(init.headers['fontdue-preview']).toBe('false');
242
+ });
243
+ it('leaves a forwarded preview token alone', async () => {
244
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
245
+ browser();
246
+ const store = await tokenStore();
247
+ store.setTokens(tokens, 'https://acme.fontdue.com');
248
+ const fetchMock = endpoint({
249
+ query: [{
250
+ data: {}
251
+ }]
252
+ });
253
+ vi.stubGlobal('fetch', fetchMock);
254
+ const {
255
+ createNetworkFetch
256
+ } = await import("../relay/environment.js");
257
+ await createNetworkFetch({
258
+ headers: {
259
+ Authorization: 'Bearer admin-token'
260
+ }
261
+ })(request, {});
262
+ expect(fetchMock.sent[0]).toBe('Bearer admin-token');
263
+ });
264
+ it('renews an expired token and repeats the request', async () => {
265
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
266
+ browser();
267
+ const store = await tokenStore();
268
+ store.setTokens(tokens, 'https://acme.fontdue.com');
269
+ const fetchMock = endpoint({
270
+ query: [{
271
+ data: {
272
+ viewer: null
273
+ },
274
+ errors: [{
275
+ extensions: {
276
+ code: 'token:expired'
277
+ }
278
+ }]
279
+ }, {
280
+ data: {
281
+ viewer: {
282
+ id: '1'
283
+ }
284
+ }
285
+ }],
286
+ refresh: {
287
+ data: {
288
+ refreshAccessToken: {
289
+ accessToken: 'access-2',
290
+ refreshToken: 'refresh-2'
291
+ }
292
+ }
293
+ }
294
+ });
295
+ vi.stubGlobal('fetch', fetchMock);
296
+ const {
297
+ createNetworkFetch
298
+ } = await import("../relay/environment.js");
299
+ const result = await createNetworkFetch()(request, {});
300
+
301
+ // Query, refresh, query again.
302
+ expect(fetchMock).toHaveBeenCalledTimes(3);
303
+ expect(fetchMock.sent[0]).toBe('Bearer access-1');
304
+ expect(fetchMock.sent[2]).toBe('Bearer access-2');
305
+
306
+ // The caller sees the successful retry, not the expiry.
307
+ expect(result).toEqual({
308
+ data: {
309
+ viewer: {
310
+ id: '1'
311
+ }
312
+ }
313
+ });
314
+ expect(store.getTokens('https://acme.fontdue.com')).toEqual({
315
+ accessToken: 'access-2',
316
+ refreshToken: 'refresh-2'
317
+ });
318
+ });
319
+ it('renews at most once, so a still-expired answer cannot loop', async () => {
320
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
321
+ browser();
322
+ const store = await tokenStore();
323
+ store.setTokens(tokens, 'https://acme.fontdue.com');
324
+ const expired = {
325
+ data: null,
326
+ errors: [{
327
+ extensions: {
328
+ code: 'token:expired'
329
+ }
330
+ }]
331
+ };
332
+ const fetchMock = endpoint({
333
+ query: [expired, expired],
334
+ refresh: {
335
+ data: {
336
+ refreshAccessToken: {
337
+ accessToken: 'access-2',
338
+ refreshToken: 'refresh-2'
339
+ }
340
+ }
341
+ }
342
+ });
343
+ vi.stubGlobal('fetch', fetchMock);
344
+ const {
345
+ createNetworkFetch
346
+ } = await import("../relay/environment.js");
347
+ await createNetworkFetch()(request, {});
348
+ expect(fetchMock).toHaveBeenCalledTimes(3);
349
+ });
350
+ it('signs the customer out when the credential will never work again', async () => {
351
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
352
+ browser();
353
+ const store = await tokenStore();
354
+ store.setTokens(tokens, 'https://acme.fontdue.com');
355
+ const fetchMock = endpoint({
356
+ query: [{
357
+ data: null,
358
+ errors: [{
359
+ extensions: {
360
+ code: 'token:invalid'
361
+ }
362
+ }]
363
+ }]
364
+ });
365
+ vi.stubGlobal('fetch', fetchMock);
366
+ const {
367
+ createNetworkFetch
368
+ } = await import("../relay/environment.js");
369
+ await createNetworkFetch()(request, {});
370
+ expect(fetchMock).toHaveBeenCalledTimes(1);
371
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
372
+ });
373
+ it('signs the customer out when the refresh token has expired too', async () => {
374
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
375
+ browser();
376
+ const store = await tokenStore();
377
+ store.setTokens(tokens, 'https://acme.fontdue.com');
378
+ const fetchMock = endpoint({
379
+ query: [{
380
+ data: null,
381
+ errors: [{
382
+ extensions: {
383
+ code: 'token:expired'
384
+ }
385
+ }]
386
+ }],
387
+ refresh: {
388
+ data: {
389
+ refreshAccessToken: null
390
+ },
391
+ errors: [{
392
+ message: 'Invalid'
393
+ }]
394
+ }
395
+ });
396
+ vi.stubGlobal('fetch', fetchMock);
397
+ const {
398
+ createNetworkFetch
399
+ } = await import("../relay/environment.js");
400
+ await createNetworkFetch()(request, {});
401
+ expect(store.getTokens('https://acme.fontdue.com')).toBeNull();
402
+ });
403
+ it('keeps the session when the renewal request itself fails', async () => {
404
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
405
+ browser();
406
+ const store = await tokenStore();
407
+ store.setTokens(tokens, 'https://acme.fontdue.com');
408
+ const fetchMock = vi.fn(async (_url, init) => {
409
+ const body = JSON.parse(String(init.body));
410
+ if (body.query.includes('FontdueRefreshAccessToken')) {
411
+ throw new TypeError('Failed to fetch');
412
+ }
413
+ return ok({
414
+ data: null,
415
+ errors: [{
416
+ extensions: {
417
+ code: 'token:expired'
418
+ }
419
+ }]
420
+ });
421
+ });
422
+ vi.stubGlobal('fetch', fetchMock);
423
+ const {
424
+ createNetworkFetch
425
+ } = await import("../relay/environment.js");
426
+ await createNetworkFetch()(request, {});
427
+
428
+ // A flaky network says nothing about the credential — the next request
429
+ // tries again rather than the visitor being signed out by a dropped packet.
430
+ expect(store.getTokens('https://acme.fontdue.com')).toEqual(tokens);
431
+ });
432
+ it('ignores an auth error when nobody is signed in', async () => {
433
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
434
+ browser();
435
+ await tokenStore();
436
+ const fetchMock = endpoint({
437
+ query: [{
438
+ data: null,
439
+ errors: [{
440
+ extensions: {
441
+ code: 'token:missing'
442
+ }
443
+ }]
444
+ }]
445
+ });
446
+ vi.stubGlobal('fetch', fetchMock);
447
+ const {
448
+ createNetworkFetch
449
+ } = await import("../relay/environment.js");
450
+ await createNetworkFetch()(request, {});
451
+
452
+ // No renewal, no retry: the request simply asked for something that needs
453
+ // a customer, and there isn't one.
454
+ expect(fetchMock).toHaveBeenCalledTimes(1);
455
+ });
456
+ });
@@ -0,0 +1,180 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useRef } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import useInfiniteScroll from '../components/useInfiniteScroll.js';
6
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
7
+ class FakeIntersectionObserver {
8
+ static instances = [];
9
+ observed = [];
10
+ disconnected = false;
11
+ constructor(callback, options) {
12
+ this.callback = callback;
13
+ this.options = options;
14
+ FakeIntersectionObserver.instances.push(this);
15
+ }
16
+ observe(element) {
17
+ this.observed.push(element);
18
+ }
19
+ unobserve() {}
20
+ disconnect() {
21
+ this.disconnected = true;
22
+ }
23
+
24
+ // Report every observed element as having come into view.
25
+ enter() {
26
+ this.callback(this.observed.map(target => ({
27
+ target,
28
+ isIntersecting: true
29
+ })), this);
30
+ }
31
+ }
32
+ const observers = () => FakeIntersectionObserver.instances;
33
+ const live = () => observers().filter(observer => !observer.disconnected);
34
+
35
+ // The shape the store modal index renders: the sentinel stays mounted while a
36
+ // page is in flight (`enabled` false) and only goes away once the list is
37
+ // exhausted, and the scroller is an ancestor whose ref is still null while the
38
+ // sentinel renders.
39
+ function Harness(_ref) {
40
+ let {
41
+ enabled,
42
+ hasMore = true,
43
+ onLoadMore
44
+ } = _ref;
45
+ const scrollContainer = useRef(null);
46
+ return /*#__PURE__*/React.createElement("div", {
47
+ ref: scrollContainer,
48
+ "data-testid": "scroller"
49
+ }, /*#__PURE__*/React.createElement(Sentinel, {
50
+ enabled: enabled,
51
+ hasMore: hasMore,
52
+ onLoadMore: onLoadMore,
53
+ rootRef: scrollContainer
54
+ }));
55
+ }
56
+ function Sentinel(_ref2) {
57
+ let {
58
+ enabled,
59
+ hasMore,
60
+ onLoadMore,
61
+ rootRef
62
+ } = _ref2;
63
+ const sentinelRef = useInfiniteScroll({
64
+ enabled,
65
+ onLoadMore,
66
+ rootRef,
67
+ rootMargin: '0px 0px 600px 0px'
68
+ });
69
+ return hasMore ? /*#__PURE__*/React.createElement("div", {
70
+ ref: sentinelRef,
71
+ "data-testid": "sentinel"
72
+ }) : null;
73
+ }
74
+ let container;
75
+ let root;
76
+ const render = element => {
77
+ act(() => {
78
+ root.render(element);
79
+ });
80
+ };
81
+ beforeEach(() => {
82
+ FakeIntersectionObserver.instances = [];
83
+ vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
84
+ container = document.createElement('div');
85
+ document.body.appendChild(container);
86
+ root = createRoot(container);
87
+ });
88
+ afterEach(() => {
89
+ act(() => root.unmount());
90
+ container.remove();
91
+ vi.unstubAllGlobals();
92
+ });
93
+ describe('useInfiniteScroll', () => {
94
+ it('loads more when the sentinel comes into view', () => {
95
+ const onLoadMore = vi.fn();
96
+ render( /*#__PURE__*/React.createElement(Harness, {
97
+ enabled: true,
98
+ onLoadMore: onLoadMore
99
+ }));
100
+ expect(live()).toHaveLength(1);
101
+ act(() => live()[0].enter());
102
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
103
+ });
104
+ it('watches the scrolling ancestor, whose ref is only attached after render', () => {
105
+ var _options, _options2;
106
+ render( /*#__PURE__*/React.createElement(Harness, {
107
+ enabled: true,
108
+ onLoadMore: vi.fn()
109
+ }));
110
+ const [observer] = live();
111
+ expect((_options = observer.options) === null || _options === void 0 ? void 0 : _options.root).toBe(container.querySelector('[data-testid="scroller"]'));
112
+ expect((_options2 = observer.options) === null || _options2 === void 0 ? void 0 : _options2.rootMargin).toBe('0px 0px 600px 0px');
113
+ expect(observer.observed).toEqual([container.querySelector('[data-testid="sentinel"]')]);
114
+ });
115
+ it('stops watching while a page is in flight, and picks it up again after', () => {
116
+ const onLoadMore = vi.fn();
117
+ render( /*#__PURE__*/React.createElement(Harness, {
118
+ enabled: true,
119
+ onLoadMore: onLoadMore
120
+ }));
121
+ act(() => live()[0].enter());
122
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
123
+
124
+ // The sentinel is still on screen, but `loadNext` is running: nothing
125
+ // should be watching it, so the load isn't asked for again on every frame.
126
+ render( /*#__PURE__*/React.createElement(Harness, {
127
+ enabled: false,
128
+ onLoadMore: onLoadMore
129
+ }));
130
+ expect(live()).toHaveLength(0);
131
+
132
+ // Page arrived, more to come: back to watching, and still in view.
133
+ render( /*#__PURE__*/React.createElement(Harness, {
134
+ enabled: true,
135
+ onLoadMore: onLoadMore
136
+ }));
137
+ expect(live()).toHaveLength(1);
138
+ act(() => live()[0].enter());
139
+ expect(onLoadMore).toHaveBeenCalledTimes(2);
140
+ });
141
+ it('never observes when there is nothing left to load', () => {
142
+ render( /*#__PURE__*/React.createElement(Harness, {
143
+ enabled: false,
144
+ hasMore: false,
145
+ onLoadMore: vi.fn()
146
+ }));
147
+ expect(observers()).toHaveLength(0);
148
+ });
149
+ it('disconnects when the sentinel is removed', () => {
150
+ render( /*#__PURE__*/React.createElement(Harness, {
151
+ enabled: true,
152
+ onLoadMore: vi.fn()
153
+ }));
154
+ const [observer] = observers();
155
+ render( /*#__PURE__*/React.createElement(Harness, {
156
+ enabled: true,
157
+ hasMore: false,
158
+ onLoadMore: vi.fn()
159
+ }));
160
+ expect(observer.disconnected).toBe(true);
161
+ });
162
+ it('keeps watching when only the callback identity changes', () => {
163
+ render( /*#__PURE__*/React.createElement(Harness, {
164
+ enabled: true,
165
+ onLoadMore: () => {}
166
+ }));
167
+ const [observer] = observers();
168
+ const onLoadMore = vi.fn();
169
+ render( /*#__PURE__*/React.createElement(Harness, {
170
+ enabled: true,
171
+ onLoadMore: onLoadMore
172
+ }));
173
+
174
+ // Same observer, and it calls the callback the latest render passed.
175
+ expect(observers()).toHaveLength(1);
176
+ expect(observer.disconnected).toBe(false);
177
+ act(() => observer.enter());
178
+ expect(onLoadMore).toHaveBeenCalledTimes(1);
179
+ });
180
+ });
@@ -10,8 +10,9 @@ function makeState() {
10
10
  cartOpen: false,
11
11
  precartOpen: false,
12
12
  selectedSkuIds: {},
13
+ requestedSkuIds: [],
13
14
  licenseOptions: [],
14
- collectionStyleSkus: {},
15
+ skuStyleIds: {},
15
16
  collectionSkus: {},
16
17
  skuPrices: {},
17
18
  fetchedCollectionIds: [],