@faststore/core 4.5.1-dev.4 → 4.5.1

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.
@@ -0,0 +1,798 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook } from '@testing-library/react'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const resetInfiniteScroll = vi.fn()
9
+ const addNextPage = vi.fn()
10
+ const routerEvents = {
11
+ on: vi.fn(),
12
+ off: vi.fn(),
13
+ }
14
+ const beforePopState = vi.fn()
15
+ const routerState = {
16
+ pathname: '/office',
17
+ }
18
+
19
+ vi.mock('next/router', () => ({
20
+ useRouter: () => ({
21
+ beforePopState,
22
+ events: routerEvents,
23
+ get pathname() {
24
+ return routerState.pathname
25
+ },
26
+ }),
27
+ }))
28
+
29
+ vi.mock('@faststore/sdk', () => ({
30
+ useSearch: () => ({
31
+ resetInfiniteScroll,
32
+ addNextPage,
33
+ pages: [0],
34
+ }),
35
+ }))
36
+
37
+ import useScrollRestoration, {
38
+ scrollRestorationTestUtils,
39
+ } from '../../../src/sdk/ui/useScrollRestoration'
40
+
41
+ const {
42
+ SCROLL_STORAGE_PREFIX,
43
+ PENDING_RESTORE_FLAG,
44
+ RESTORING_SCROLL_CLASS,
45
+ STABLE_HITS_REQUIRED,
46
+ RESTORE_MAX_ATTEMPTS,
47
+ destinationPathname,
48
+ findAnchorCard,
49
+ normalizePath,
50
+ pathMatchesAnchor,
51
+ isElementInViewport,
52
+ scrollElementIntoView,
53
+ scrollToSavedPosition,
54
+ trackAnchorInView,
55
+ isAnchorRestoreSettled,
56
+ restoreByAnchor,
57
+ restoreByCoordinates,
58
+ continueOrFinish,
59
+ finishRestore,
60
+ ensureAnchorObserver,
61
+ optimisticRestore,
62
+ scheduleRestore,
63
+ markPendingRestore,
64
+ cancelRestore,
65
+ beginRestoringPaint,
66
+ endRestoringPaint,
67
+ restoreInfiniteScrollPages,
68
+ createSession,
69
+ setActiveRestoreKey,
70
+ bumpGeneration,
71
+ reset,
72
+ } = scrollRestorationTestUtils
73
+
74
+ function mockInView(el: HTMLElement, inView: boolean) {
75
+ el.getBoundingClientRect = () =>
76
+ ({
77
+ top: inView ? 100 : -2000,
78
+ bottom: inView ? 300 : -1800,
79
+ height: 200,
80
+ width: 200,
81
+ left: 0,
82
+ right: 200,
83
+ x: 0,
84
+ y: inView ? 100 : -2000,
85
+ toJSON: () => ({}),
86
+ }) as DOMRect
87
+ }
88
+
89
+ function getRouteHandler(event: string) {
90
+ const call = routerEvents.on.mock.calls.find(([name]) => name === event)
91
+ return call?.[1] as ((...args: unknown[]) => void) | undefined
92
+ }
93
+
94
+ describe('scrollRestorationTestUtils (pure helpers)', () => {
95
+ it('normalizes paths by dropping query and trailing slash', () => {
96
+ expect(normalizePath('/office/?x=1')).toBe('/office')
97
+ expect(normalizePath('/office/')).toBe('/office')
98
+ })
99
+
100
+ it('matches product hrefs to saved anchors', () => {
101
+ expect(pathMatchesAnchor('/cool-product/p', '/cool-product/p')).toBe(true)
102
+ expect(
103
+ pathMatchesAnchor('https://example.com/cool-product/p', '/cool-product/p')
104
+ ).toBe(true)
105
+ expect(pathMatchesAnchor('/other/p', '/cool-product/p')).toBe(false)
106
+ })
107
+
108
+ it('parses destination pathnames from relative and absolute urls', () => {
109
+ expect(destinationPathname('/office')).toBe('/office')
110
+ expect(destinationPathname('https://example.com/s?q=refil')).toBe('/s')
111
+ // Relative to origin — spaces are percent-encoded by the URL constructor.
112
+ expect(destinationPathname('not a url')).toBe('/not%20a%20url')
113
+ })
114
+
115
+ it('finds the product card for a PDP anchor', () => {
116
+ document.body.innerHTML = `
117
+ <div data-fs-product-card>
118
+ <a data-testid="product-link" href="/sku-a/p">Product A</a>
119
+ </div>
120
+ <a href="/sku-b/p">Loose link</a>
121
+ `
122
+
123
+ const card = findAnchorCard('/sku-a/p')
124
+ expect(card?.getAttribute('data-fs-product-card')).toBe('')
125
+
126
+ const loose = findAnchorCard('/sku-b/p')
127
+ expect(loose?.getAttribute('href')).toBe('/sku-b/p')
128
+
129
+ expect(findAnchorCard('/missing/p')).toBeNull()
130
+ })
131
+ })
132
+
133
+ describe('useScrollRestoration', () => {
134
+ beforeEach(() => {
135
+ reset()
136
+ resetInfiniteScroll.mockClear()
137
+ addNextPage.mockClear()
138
+ routerEvents.on.mockClear()
139
+ routerEvents.off.mockClear()
140
+ beforePopState.mockClear()
141
+ routerState.pathname = '/office'
142
+ sessionStorage.clear()
143
+ document.body.innerHTML = ''
144
+ document.documentElement.classList.remove(RESTORING_SCROLL_CLASS)
145
+
146
+ Object.defineProperty(window, 'scrollX', {
147
+ configurable: true,
148
+ value: 0,
149
+ writable: true,
150
+ })
151
+ Object.defineProperty(window, 'scrollY', {
152
+ configurable: true,
153
+ value: 1200,
154
+ writable: true,
155
+ })
156
+ window.scrollTo = vi.fn()
157
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
158
+ // jsdom does not implement History.scrollRestoration — polyfill for the hook.
159
+ Object.defineProperty(window.history, 'scrollRestoration', {
160
+ configurable: true,
161
+ writable: true,
162
+ value: 'auto',
163
+ })
164
+ })
165
+
166
+ afterEach(() => {
167
+ reset()
168
+ vi.clearAllMocks()
169
+ })
170
+
171
+ it('sets manual scroll restoration and wires router listeners', () => {
172
+ renderHook(() => useScrollRestoration())
173
+
174
+ expect(window.history.scrollRestoration).toBe('manual')
175
+ expect(beforePopState).toHaveBeenCalled()
176
+ expect(routerEvents.on).toHaveBeenCalledWith(
177
+ 'routeChangeStart',
178
+ expect.any(Function)
179
+ )
180
+ expect(routerEvents.on).toHaveBeenCalledWith(
181
+ 'routeChangeComplete',
182
+ expect.any(Function)
183
+ )
184
+ expect(routerEvents.on).toHaveBeenCalledWith(
185
+ 'routeChangeError',
186
+ expect.any(Function)
187
+ )
188
+ })
189
+
190
+ it('saves scroll position and PDP anchor on product link click', () => {
191
+ renderHook(() => useScrollRestoration())
192
+
193
+ const link = document.createElement('a')
194
+ link.href = '/cool-product/p'
195
+ link.textContent = 'Product'
196
+ document.body.appendChild(link)
197
+
198
+ act(() => {
199
+ link.dispatchEvent(
200
+ new MouseEvent('click', { bubbles: true, cancelable: true })
201
+ )
202
+ })
203
+
204
+ const stored = sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}plp-key`)
205
+ expect(stored).toBeTruthy()
206
+ expect(JSON.parse(stored as string)).toMatchObject({
207
+ y: 1200,
208
+ anchor: '/cool-product/p',
209
+ })
210
+ })
211
+
212
+ it('resets infinite scroll when leaving /s for a PLP', () => {
213
+ window.history.replaceState({ key: 'search-key' }, '', '/s?q=refil')
214
+ routerState.pathname = '/s'
215
+ renderHook(() => useScrollRestoration())
216
+
217
+ const onStart = getRouteHandler('routeChangeStart')
218
+ expect(onStart).toBeTypeOf('function')
219
+
220
+ act(() => {
221
+ onStart?.('/office')
222
+ })
223
+
224
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
225
+ })
226
+
227
+ it('resets infinite scroll when leaving locale-prefixed /s for a PLP', () => {
228
+ // Browser path includes the locale; Next router.pathname does not.
229
+ window.history.replaceState({ key: 'search-key' }, '', '/pt-BR/s?q=refil')
230
+ routerState.pathname = '/s'
231
+ renderHook(() => useScrollRestoration())
232
+
233
+ const onStart = getRouteHandler('routeChangeStart')
234
+
235
+ act(() => {
236
+ onStart?.('/pt-BR/office')
237
+ })
238
+
239
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
240
+ })
241
+
242
+ it('clears a stale PDP anchor when leaving the PLP to a non-PDP page', () => {
243
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
244
+ sessionStorage.setItem(
245
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
246
+ JSON.stringify({ x: 0, y: 900, anchor: '/cool-product/p' })
247
+ )
248
+ renderHook(() => useScrollRestoration())
249
+
250
+ const onStart = getRouteHandler('routeChangeStart')
251
+ act(() => {
252
+ onStart?.('/about')
253
+ })
254
+
255
+ const stored = JSON.parse(
256
+ sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}plp-key`) as string
257
+ )
258
+ expect(stored.anchor).toBeUndefined()
259
+ expect(stored.y).toBe(1200)
260
+ })
261
+
262
+ it('does not save scroll on modified product-link clicks', () => {
263
+ renderHook(() => useScrollRestoration())
264
+
265
+ const link = document.createElement('a')
266
+ link.href = '/cool-product/p'
267
+ link.textContent = 'Product'
268
+ document.body.appendChild(link)
269
+
270
+ act(() => {
271
+ link.dispatchEvent(
272
+ new MouseEvent('click', {
273
+ bubbles: true,
274
+ cancelable: true,
275
+ metaKey: true,
276
+ button: 0,
277
+ })
278
+ )
279
+ })
280
+
281
+ expect(sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}plp-key`)).toBeNull()
282
+ })
283
+
284
+ it('resets infinite scroll when leaving a PLP to a non-PDP page', () => {
285
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
286
+ renderHook(() => useScrollRestoration())
287
+
288
+ const onStart = getRouteHandler('routeChangeStart')
289
+
290
+ act(() => {
291
+ onStart?.('/about')
292
+ })
293
+
294
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
295
+ })
296
+
297
+ it('does not treat root as same-path for every navigation', () => {
298
+ window.history.replaceState({ key: 'home-key' }, '', '/')
299
+ renderHook(() => useScrollRestoration())
300
+
301
+ const onStart = getRouteHandler('routeChangeStart')
302
+
303
+ act(() => {
304
+ onStart?.('/office')
305
+ })
306
+
307
+ // Old url.includes('/') matched every path; normalized compare must not.
308
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
309
+ })
310
+
311
+ it('does not reset infinite scroll when navigating to a PDP', () => {
312
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
313
+ renderHook(() => useScrollRestoration())
314
+
315
+ const onStart = getRouteHandler('routeChangeStart')
316
+
317
+ act(() => {
318
+ onStart?.('/cool-product/p')
319
+ })
320
+
321
+ expect(resetInfiniteScroll).not.toHaveBeenCalled()
322
+ })
323
+
324
+ it('resets infinite scroll when leaving a PDP for a different PLP', () => {
325
+ // Forward nav must not leak the previous listing's pages into the next PLP.
326
+ // Back-nav rehydrates from `__fs_gallery_page_<plp>` via restoreInfiniteScrollPages.
327
+ window.history.replaceState({ key: 'pdp-key' }, '', '/cool-product/p')
328
+ renderHook(() => useScrollRestoration())
329
+
330
+ const onStart = getRouteHandler('routeChangeStart')
331
+
332
+ act(() => {
333
+ onStart?.('/eletronicos')
334
+ })
335
+
336
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
337
+ })
338
+
339
+ it('rehydrates infinite-scroll pages from sessionStorage on popstate', () => {
340
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
341
+ sessionStorage.setItem(
342
+ '__fs_gallery_page__office',
343
+ JSON.stringify([0, 1, 2])
344
+ )
345
+ sessionStorage.setItem(
346
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
347
+ JSON.stringify({ x: 0, y: 2975, anchor: '/cool-product/p' })
348
+ )
349
+ renderHook(() => useScrollRestoration())
350
+
351
+ act(() => {
352
+ window.dispatchEvent(new PopStateEvent('popstate'))
353
+ })
354
+
355
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
356
+ expect(addNextPage).toHaveBeenCalledTimes(2)
357
+ })
358
+
359
+ it('restoreInfiniteScrollPages no-ops when memory already matches session', () => {
360
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
361
+ sessionStorage.setItem(
362
+ '__fs_gallery_page__office',
363
+ JSON.stringify([0, 1, 2])
364
+ )
365
+
366
+ restoreInfiniteScrollPages(resetInfiniteScroll, addNextPage, () => [
367
+ 0, 1, 2,
368
+ ])
369
+
370
+ expect(resetInfiniteScroll).not.toHaveBeenCalled()
371
+ expect(addNextPage).not.toHaveBeenCalled()
372
+ })
373
+
374
+ it('restoreInfiniteScrollPages rebuilds pages when memory was wiped', () => {
375
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
376
+ sessionStorage.setItem(
377
+ '__fs_gallery_page__office',
378
+ JSON.stringify([0, 1, 2])
379
+ )
380
+
381
+ restoreInfiniteScrollPages(resetInfiniteScroll, addNextPage, () => [0])
382
+
383
+ expect(resetInfiniteScroll).toHaveBeenCalledWith(0)
384
+ expect(addNextPage).toHaveBeenCalledTimes(2)
385
+ })
386
+
387
+ it('marks pending restore and cloaks paint on beforePopState when scroll exists', () => {
388
+ sessionStorage.setItem(
389
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
390
+ JSON.stringify({ x: 0, y: 900, anchor: '/cool-product/p' })
391
+ )
392
+
393
+ renderHook(() => useScrollRestoration())
394
+ const onBeforePopState = beforePopState.mock.calls[0][0] as () => boolean
395
+
396
+ act(() => {
397
+ expect(onBeforePopState()).toBe(true)
398
+ })
399
+
400
+ expect(sessionStorage.getItem(PENDING_RESTORE_FLAG)).toBe('plp-key')
401
+ expect(
402
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
403
+ ).toBe(true)
404
+ })
405
+
406
+ it('restores scroll on popstate when a saved position exists', async () => {
407
+ sessionStorage.setItem(
408
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
409
+ JSON.stringify({ x: 0, y: 900 })
410
+ )
411
+
412
+ document.body.innerHTML = `<div style="height: 4000px"></div>`
413
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
414
+ configurable: true,
415
+ get: () => 4000,
416
+ })
417
+ Object.defineProperty(window, 'innerHeight', {
418
+ configurable: true,
419
+ value: 800,
420
+ })
421
+
422
+ renderHook(() => useScrollRestoration())
423
+
424
+ await act(async () => {
425
+ window.dispatchEvent(new PopStateEvent('popstate'))
426
+ await new Promise((resolve) => setTimeout(resolve, 50))
427
+ })
428
+
429
+ expect(window.scrollTo).toHaveBeenCalled()
430
+ })
431
+
432
+ it('clears pending restore flag on routeChangeError', () => {
433
+ renderHook(() => useScrollRestoration())
434
+ sessionStorage.setItem(PENDING_RESTORE_FLAG, 'plp-key')
435
+ document.documentElement.classList.add(RESTORING_SCROLL_CLASS)
436
+
437
+ const onError = getRouteHandler('routeChangeError')
438
+ act(() => {
439
+ onError?.()
440
+ })
441
+
442
+ expect(sessionStorage.getItem(PENDING_RESTORE_FLAG)).toBeNull()
443
+ expect(
444
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
445
+ ).toBe(false)
446
+ })
447
+
448
+ it('restores after routeChangeComplete when pending restore was marked', async () => {
449
+ sessionStorage.setItem(
450
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
451
+ JSON.stringify({ x: 0, y: 500 })
452
+ )
453
+ sessionStorage.setItem(PENDING_RESTORE_FLAG, 'plp-key')
454
+
455
+ renderHook(() => useScrollRestoration())
456
+
457
+ // Remount path already consumed the flag on mount — set again for complete.
458
+ sessionStorage.setItem(PENDING_RESTORE_FLAG, 'plp-key')
459
+ const onComplete = getRouteHandler('routeChangeComplete')
460
+
461
+ await act(async () => {
462
+ onComplete?.()
463
+ await new Promise((resolve) => setTimeout(resolve, 50))
464
+ })
465
+
466
+ expect(window.scrollTo).toHaveBeenCalled()
467
+ })
468
+
469
+ it('ignores non-product clicks and same-path PDP clicks', () => {
470
+ renderHook(() => useScrollRestoration())
471
+
472
+ act(() => {
473
+ document.body.dispatchEvent(
474
+ new MouseEvent('click', { bubbles: true, cancelable: true })
475
+ )
476
+ })
477
+ expect(sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}plp-key`)).toBeNull()
478
+
479
+ const same = document.createElement('a')
480
+ same.href = '/office'
481
+ same.setAttribute('href', '/office')
482
+ document.body.appendChild(same)
483
+ // Not a /p link
484
+ act(() => {
485
+ same.dispatchEvent(
486
+ new MouseEvent('click', { bubbles: true, cancelable: true })
487
+ )
488
+ })
489
+ expect(sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}plp-key`)).toBeNull()
490
+ })
491
+
492
+ it('does not reset on same-path navigations', () => {
493
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
494
+ renderHook(() => useScrollRestoration())
495
+
496
+ const onStart = getRouteHandler('routeChangeStart')
497
+ act(() => {
498
+ onStart?.('/office?page=2')
499
+ })
500
+
501
+ expect(resetInfiniteScroll).not.toHaveBeenCalled()
502
+ })
503
+
504
+ it('unregisters listeners on unmount', () => {
505
+ const { unmount } = renderHook(() => useScrollRestoration())
506
+ unmount()
507
+ expect(routerEvents.off).toHaveBeenCalledWith(
508
+ 'routeChangeStart',
509
+ expect.any(Function)
510
+ )
511
+ expect(beforePopState).toHaveBeenCalledWith(expect.any(Function))
512
+ })
513
+ })
514
+
515
+ describe('scroll restore session helpers', () => {
516
+ beforeEach(() => {
517
+ reset()
518
+ sessionStorage.clear()
519
+ document.body.innerHTML = ''
520
+ window.scrollTo = vi.fn()
521
+ window.history.replaceState({ key: 'plp-key' }, '', '/office')
522
+ Object.defineProperty(window, 'innerHeight', {
523
+ configurable: true,
524
+ value: 800,
525
+ })
526
+ Object.defineProperty(window, 'scrollY', {
527
+ configurable: true,
528
+ value: 0,
529
+ writable: true,
530
+ })
531
+ })
532
+
533
+ afterEach(() => {
534
+ reset()
535
+ })
536
+
537
+ it('begin/end restoring paint toggles the html class', () => {
538
+ beginRestoringPaint()
539
+ expect(
540
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
541
+ ).toBe(true)
542
+ endRestoringPaint()
543
+ expect(
544
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
545
+ ).toBe(false)
546
+ })
547
+
548
+ it('markPendingRestore cloaks only when stored scroll exists', () => {
549
+ markPendingRestore()
550
+ expect(
551
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
552
+ ).toBe(false)
553
+
554
+ sessionStorage.setItem(
555
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
556
+ JSON.stringify({ x: 0, y: 10 })
557
+ )
558
+ markPendingRestore()
559
+ expect(
560
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
561
+ ).toBe(true)
562
+ })
563
+
564
+ it('detects viewport membership and scrolls elements into view', () => {
565
+ const el = document.createElement('div')
566
+ document.body.appendChild(el)
567
+ mockInView(el, true)
568
+ expect(isElementInViewport(el)).toBe(true)
569
+ mockInView(el, false)
570
+ expect(isElementInViewport(el)).toBe(false)
571
+
572
+ mockInView(el, true)
573
+ Object.defineProperty(window, 'scrollY', {
574
+ configurable: true,
575
+ value: 500,
576
+ })
577
+ scrollElementIntoView(el)
578
+ expect(window.scrollTo).toHaveBeenCalled()
579
+ })
580
+
581
+ it('scrollToSavedPosition clamps to document max scroll', () => {
582
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
583
+ configurable: true,
584
+ get: () => 1000,
585
+ })
586
+ scrollToSavedPosition({ x: 0, y: 99999 })
587
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 200)
588
+ })
589
+
590
+ it('tracks anchor stability until settled', () => {
591
+ const el = document.createElement('div')
592
+ document.body.appendChild(el)
593
+ mockInView(el, true)
594
+
595
+ const session = createSession()
596
+ expect(trackAnchorInView(session, el)).toBe(false)
597
+ expect(session.stableHits).toBe(1)
598
+ expect(session.defenseUntil).toBeGreaterThan(0)
599
+
600
+ session.stableHits = STABLE_HITS_REQUIRED
601
+ expect(isAnchorRestoreSettled(session)).toBe(true)
602
+
603
+ mockInView(el, false)
604
+ expect(trackAnchorInView(session, el)).toBe(false)
605
+ expect(session.stableHits).toBe(0)
606
+ })
607
+
608
+ it('restoreByAnchor finishes when the card is stably in view', () => {
609
+ document.body.innerHTML = `
610
+ <div data-fs-product-card>
611
+ <a data-testid="product-link" href="/cool-product/p">Product</a>
612
+ </div>
613
+ `
614
+ const card = findAnchorCard('/cool-product/p') as HTMLElement
615
+ mockInView(card, true)
616
+
617
+ const scheduleNext = vi.fn()
618
+ const tryRestore = vi.fn()
619
+ const session = createSession({
620
+ stored: { x: 0, y: 400, anchor: '/cool-product/p' },
621
+ stableHits: STABLE_HITS_REQUIRED - 1,
622
+ })
623
+ setActiveRestoreKey(session.key)
624
+
625
+ expect(restoreByAnchor(session, scheduleNext, tryRestore)).toBe(true)
626
+ expect(scheduleNext).not.toHaveBeenCalled()
627
+ })
628
+
629
+ it('restoreByAnchor schedules retry when card is missing', () => {
630
+ const scheduleNext = vi.fn()
631
+ const tryRestore = vi.fn()
632
+ const session = createSession({
633
+ stored: { x: 0, y: 400, anchor: '/missing/p' },
634
+ attempts: 1,
635
+ })
636
+
637
+ expect(restoreByAnchor(session, scheduleNext, tryRestore)).toBe(true)
638
+ expect(session.observer).not.toBeNull()
639
+ expect(scheduleNext).toHaveBeenCalled()
640
+ })
641
+
642
+ it('restoreByAnchor returns false without an anchor', () => {
643
+ const session = createSession({ stored: { x: 0, y: 10 } })
644
+ expect(restoreByAnchor(session, vi.fn(), vi.fn())).toBe(false)
645
+ })
646
+
647
+ it('continueOrFinish finishes after max attempts', () => {
648
+ beginRestoringPaint()
649
+ const session = createSession({ attempts: RESTORE_MAX_ATTEMPTS })
650
+ setActiveRestoreKey(session.key)
651
+ const scheduleNext = vi.fn()
652
+ continueOrFinish(session, scheduleNext)
653
+ expect(scheduleNext).not.toHaveBeenCalled()
654
+ expect(
655
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
656
+ ).toBe(false)
657
+ })
658
+
659
+ it('finishRestore no-ops for stale generations', () => {
660
+ beginRestoringPaint()
661
+ const session = createSession({ generation: 1 })
662
+ bumpGeneration()
663
+ bumpGeneration()
664
+ finishRestore(session)
665
+ expect(
666
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
667
+ ).toBe(true)
668
+ endRestoringPaint()
669
+ })
670
+
671
+ it('restoreByCoordinates finishes when already near target', async () => {
672
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
673
+ configurable: true,
674
+ get: () => 2000,
675
+ })
676
+ Object.defineProperty(window, 'scrollY', {
677
+ configurable: true,
678
+ value: 500,
679
+ })
680
+ const session = createSession({
681
+ stored: { x: 0, y: 500 },
682
+ attempts: 1,
683
+ })
684
+ setActiveRestoreKey(session.key)
685
+ beginRestoringPaint()
686
+
687
+ await act(async () => {
688
+ restoreByCoordinates(session, vi.fn())
689
+ await new Promise((resolve) => setTimeout(resolve, 120))
690
+ })
691
+
692
+ expect(
693
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
694
+ ).toBe(false)
695
+ })
696
+
697
+ it('restoreByCoordinates finishes immediately when y is 0', () => {
698
+ beginRestoringPaint()
699
+ const session = createSession({ stored: { x: 0, y: 0 }, attempts: 1 })
700
+ setActiveRestoreKey(session.key)
701
+ restoreByCoordinates(session, vi.fn())
702
+ expect(
703
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
704
+ ).toBe(false)
705
+ })
706
+
707
+ it('optimisticRestore scrolls to an anchor card when present', () => {
708
+ document.body.innerHTML = `
709
+ <div data-fs-product-card>
710
+ <a data-testid="product-link" href="/cool-product/p">Product</a>
711
+ </div>
712
+ `
713
+ const card = findAnchorCard('/cool-product/p') as HTMLElement
714
+ mockInView(card, true)
715
+ sessionStorage.setItem(
716
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
717
+ JSON.stringify({ x: 0, y: 300, anchor: '/cool-product/p' })
718
+ )
719
+
720
+ optimisticRestore()
721
+ expect(window.scrollTo).toHaveBeenCalled()
722
+ })
723
+
724
+ it('optimisticRestore ends paint when nothing is stored', () => {
725
+ beginRestoringPaint()
726
+ optimisticRestore()
727
+ expect(
728
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
729
+ ).toBe(false)
730
+ })
731
+
732
+ it('scheduleRestore is a no-op without stored scroll', () => {
733
+ beginRestoringPaint()
734
+ scheduleRestore()
735
+ expect(
736
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
737
+ ).toBe(false)
738
+ })
739
+
740
+ it('scheduleRestore restores by anchor across frames', async () => {
741
+ document.body.innerHTML = `
742
+ <div data-fs-product-card>
743
+ <a data-testid="product-link" href="/cool-product/p">Product</a>
744
+ </div>
745
+ `
746
+ const card = findAnchorCard('/cool-product/p') as HTMLElement
747
+ mockInView(card, true)
748
+ sessionStorage.setItem(
749
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
750
+ JSON.stringify({ x: 0, y: 400, anchor: '/cool-product/p' })
751
+ )
752
+
753
+ await act(async () => {
754
+ scheduleRestore()
755
+ // Enough frames for stable hits to accumulate.
756
+ for (let i = 0; i < 6; i++) {
757
+ await new Promise((resolve) => requestAnimationFrame(resolve))
758
+ await new Promise((resolve) => setTimeout(resolve, 0))
759
+ }
760
+ })
761
+
762
+ expect(window.scrollTo).toHaveBeenCalled()
763
+ })
764
+
765
+ it('scheduleRestore does not restart for the same active key', () => {
766
+ sessionStorage.setItem(
767
+ `${SCROLL_STORAGE_PREFIX}plp-key`,
768
+ JSON.stringify({ x: 0, y: 100 })
769
+ )
770
+ setActiveRestoreKey('plp-key')
771
+ const callsBefore = (window.scrollTo as ReturnType<typeof vi.fn>).mock.calls
772
+ .length
773
+ scheduleRestore()
774
+ expect(
775
+ (window.scrollTo as ReturnType<typeof vi.fn>).mock.calls.length
776
+ ).toBe(callsBefore)
777
+ })
778
+
779
+ it('cancelRestore clears active restore and paint cloak', () => {
780
+ beginRestoringPaint()
781
+ setActiveRestoreKey('plp-key')
782
+ cancelRestore()
783
+ expect(
784
+ document.documentElement.classList.contains(RESTORING_SCROLL_CLASS)
785
+ ).toBe(false)
786
+ })
787
+
788
+ it('ensureAnchorObserver only creates one observer', () => {
789
+ const session = createSession({
790
+ stored: { x: 0, y: 1, anchor: '/x/p' },
791
+ })
792
+ const tryRestore = vi.fn()
793
+ ensureAnchorObserver(session, '/x/p', tryRestore)
794
+ const first = session.observer
795
+ ensureAnchorObserver(session, '/x/p', tryRestore)
796
+ expect(session.observer).toBe(first)
797
+ })
798
+ })