@faststore/core 4.5.1 → 4.6.0-dev.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.
@@ -1,649 +1,65 @@
1
- import { useEffect, useRef } from 'react'
1
+ import { useEffect } from 'react'
2
2
  import { useRouter } from 'next/router'
3
3
 
4
4
  import { useSearch } from '@faststore/sdk'
5
5
 
6
- const SCROLL_STORAGE_PREFIX = '__fs_scroll_'
7
- const PENDING_RESTORE_FLAG = '__fs_scroll_pending_restore'
8
- const RESTORING_SCROLL_CLASS = 'fs-restoring-scroll'
9
- const RESTORE_MAX_ATTEMPTS = 80
10
- const RESTORE_RETRY_MS = 100
11
- /** After the card is in view, briefly defend against Next/layout scroll resets. */
12
- const DEFENSE_WINDOW_MS = 900
13
- /** Consecutive in-view checks before we stop early (snappier than full defense). */
14
- const STABLE_HITS_REQUIRED = 3
15
- /** Safety: never leave the page hidden if restore never settles. */
16
- const RESTORING_PAINT_TIMEOUT_MS = 3500
17
-
18
- type StoredScroll = {
19
- x: number
20
- y: number
21
- /** PDP path the user opened from this PLP, e.g. `/slug/p`. */
22
- anchor?: string
23
- }
24
-
25
- /**
26
- * Module-level state — must survive React effect remounts during back nav.
27
- */
28
- let pendingPopRestore = false
29
- let restoreGeneration = 0
30
- /** History entry currently being restored; prevents popstate+complete from restarting. */
31
- let activeRestoreKey: string | null = null
32
- let restoringPaintTimer: ReturnType<typeof setTimeout> | undefined
33
-
34
- /**
35
- * Hide painted pixels while scroll jumps from top/footer to the saved product.
36
- * Applied before PLP paints so the user never sees the footer flash.
37
- */
38
- function beginRestoringPaint() {
39
- document.documentElement.classList.add(RESTORING_SCROLL_CLASS)
40
- if (restoringPaintTimer !== undefined) {
41
- globalThis.clearTimeout(restoringPaintTimer)
42
- }
43
- restoringPaintTimer = globalThis.setTimeout(
44
- endRestoringPaint,
45
- RESTORING_PAINT_TIMEOUT_MS
46
- )
47
- }
48
-
49
- function endRestoringPaint() {
50
- if (restoringPaintTimer !== undefined) {
51
- globalThis.clearTimeout(restoringPaintTimer)
52
- }
53
- restoringPaintTimer = undefined
54
- document.documentElement.classList.remove(RESTORING_SCROLL_CLASS)
55
- }
56
-
57
- function historyStorageKey() {
58
- return globalThis.history.state?.key ?? `path:${globalThis.location.pathname}`
59
- }
60
-
61
- function readStoredScroll(key: string): StoredScroll | null {
62
- const stored = sessionStorage.getItem(`${SCROLL_STORAGE_PREFIX}${key}`)
63
- if (!stored) return null
64
-
65
- try {
66
- return JSON.parse(stored) as StoredScroll
67
- } catch {
68
- return null
69
- }
70
- }
71
-
72
- function writeStoredScroll(key: string, payload: StoredScroll) {
73
- sessionStorage.setItem(
74
- `${SCROLL_STORAGE_PREFIX}${key}`,
75
- JSON.stringify(payload)
76
- )
77
- }
78
-
79
- function normalizePath(path: string) {
80
- return path.split('?')[0].replace(/\/$/, '')
81
- }
82
-
83
- function pathMatchesAnchor(href: string, anchor: string) {
84
- const normalizedAnchor = normalizePath(anchor)
85
- try {
86
- return (
87
- normalizePath(new URL(href, globalThis.location.origin).pathname) ===
88
- normalizedAnchor
89
- )
90
- } catch {
91
- return href.includes(normalizedAnchor)
92
- }
93
- }
94
-
95
- function destinationPathname(url: string) {
96
- try {
97
- return new URL(url, globalThis.location.origin).pathname
98
- } catch {
99
- return url.split('?')[0]
100
- }
101
- }
102
-
103
- /**
104
- * Resolve the product *card* for a PDP path so we scroll the whole tile into
105
- * view (image + title), not only the small title link.
106
- */
107
- function findAnchorCard(anchor: string): HTMLElement | null {
108
- const links = Array.from(
109
- document.querySelectorAll<HTMLAnchorElement>(
110
- 'a[data-testid="product-link"], a[href*="/p"]'
111
- )
112
- )
113
-
114
- for (const link of links) {
115
- const href = link.getAttribute('href')
116
- if (!href || !pathMatchesAnchor(href, anchor)) continue
117
-
118
- const card = link.closest('[data-fs-product-card]')
119
- if (card instanceof HTMLElement) return card
120
- return link
121
- }
122
-
123
- return null
124
- }
125
-
126
- function isElementInViewport(el: HTMLElement) {
127
- const rect = el.getBoundingClientRect()
128
- const vh = globalThis.innerHeight
129
- return rect.top < vh * 0.75 && rect.bottom > vh * 0.25 && rect.height > 40
130
- }
131
-
132
- function scrollElementIntoView(el: HTMLElement) {
133
- const rect = el.getBoundingClientRect()
134
- const absoluteTop = rect.top + globalThis.scrollY
135
- const targetY = Math.max(
136
- 0,
137
- absoluteTop - globalThis.innerHeight / 2 + rect.height / 2
138
- )
139
- globalThis.scrollTo({ top: targetY, left: 0, behavior: 'auto' })
140
- }
141
-
142
- function scrollToSavedPosition(stored: StoredScroll) {
143
- const maxScroll = Math.max(
144
- document.documentElement.scrollHeight - globalThis.innerHeight,
145
- 0
146
- )
147
- globalThis.scrollTo(stored.x, Math.min(stored.y, maxScroll))
148
- }
149
-
150
- function cancelRestore() {
151
- restoreGeneration += 1
152
- activeRestoreKey = null
153
- endRestoringPaint()
154
- }
155
-
156
- type RestoreSession = {
157
- generation: number
158
- key: string
159
- stored: StoredScroll
160
- attempts: number
161
- observer: MutationObserver | null
162
- defenseUntil: number
163
- stableHits: number
164
- }
165
-
166
- function isStaleGeneration(generation: number) {
167
- return generation !== restoreGeneration
168
- }
169
-
170
- function cleanupRestoreObserver(session: RestoreSession) {
171
- session.observer?.disconnect()
172
- session.observer = null
173
- }
174
-
175
- function finishRestore(session: RestoreSession) {
176
- if (isStaleGeneration(session.generation)) return
177
- cleanupRestoreObserver(session)
178
- if (activeRestoreKey === session.key) activeRestoreKey = null
179
- endRestoringPaint()
180
- }
181
-
182
- function isAnchorRestoreSettled(session: RestoreSession) {
183
- return (
184
- session.stableHits >= STABLE_HITS_REQUIRED ||
185
- (session.defenseUntil > 0 && Date.now() >= session.defenseUntil)
186
- )
187
- }
188
-
189
- function trackAnchorInView(session: RestoreSession, el: HTMLElement) {
190
- if (!isElementInViewport(el)) {
191
- session.stableHits = 0
192
- session.defenseUntil = 0
193
- return false
194
- }
195
-
196
- session.stableHits += 1
197
- if (!session.defenseUntil) {
198
- session.defenseUntil = Date.now() + DEFENSE_WINDOW_MS
199
- }
200
- return isAnchorRestoreSettled(session)
201
- }
202
-
203
- function continueOrFinish(session: RestoreSession, scheduleNext: () => void) {
204
- if (session.attempts < RESTORE_MAX_ATTEMPTS) {
205
- scheduleNext()
206
- return
207
- }
208
- finishRestore(session)
209
- }
210
-
211
- function ensureAnchorObserver(
212
- session: RestoreSession,
213
- anchor: string,
214
- tryRestore: () => void
215
- ) {
216
- if (session.observer) return
217
-
218
- session.observer = new MutationObserver(() => {
219
- if (isStaleGeneration(session.generation)) return
220
- if (findAnchorCard(anchor)) tryRestore()
221
- })
222
- session.observer.observe(document.body, {
223
- childList: true,
224
- subtree: true,
225
- })
226
- }
227
-
228
- function restoreByAnchor(
229
- session: RestoreSession,
230
- scheduleNext: () => void,
231
- tryRestore: () => void
232
- ) {
233
- const anchor = session.stored.anchor
234
- if (!anchor) return false
235
-
236
- const el = findAnchorCard(anchor)
237
- if (el) {
238
- scrollElementIntoView(el)
239
- if (trackAnchorInView(session, el)) {
240
- finishRestore(session)
241
- return true
242
- }
243
- continueOrFinish(session, scheduleNext)
244
- return true
245
- }
246
-
247
- // Card not in DOM yet — watch mutations and keep the optimistic Y.
248
- ensureAnchorObserver(session, anchor, tryRestore)
249
- scrollToSavedPosition(session.stored)
250
- continueOrFinish(session, scheduleNext)
251
- return true
252
- }
253
-
254
- function restoreByCoordinates(session: RestoreSession, tryRestore: () => void) {
255
- const { y } = session.stored
256
- scrollToSavedPosition(session.stored)
257
-
258
- if (!(y > 0 && session.attempts < RESTORE_MAX_ATTEMPTS)) {
259
- finishRestore(session)
260
- return
261
- }
262
-
263
- globalThis.setTimeout(() => {
264
- if (isStaleGeneration(session.generation)) return
265
-
266
- const maxScroll = Math.max(
267
- document.documentElement.scrollHeight - globalThis.innerHeight,
268
- 0
269
- )
270
- const targetY = Math.min(y, maxScroll)
271
- if (Math.abs(globalThis.scrollY - targetY) > 40) {
272
- tryRestore()
273
- return
274
- }
275
- finishRestore(session)
276
- }, RESTORE_RETRY_MS)
277
- }
278
-
279
- /**
280
- * Instant approximate restore — call synchronously on popstate so the user is
281
- * not left at the top of the PLP while product cards finish mounting.
282
- */
283
- function optimisticRestore() {
284
- const key = historyStorageKey()
285
- const stored = readStoredScroll(key)
286
- if (!stored) {
287
- endRestoringPaint()
288
- return
289
- }
290
-
291
- scrollToSavedPosition(stored)
292
- if (stored.anchor) {
293
- const el = findAnchorCard(stored.anchor)
294
- if (el) scrollElementIntoView(el)
295
- }
296
- }
297
-
298
- /**
299
- * Start (or no-op if already running for this history entry) a restore session.
300
- */
301
- function scheduleRestore() {
302
- const key = historyStorageKey()
303
- const stored = readStoredScroll(key)
304
- if (!stored) {
305
- endRestoringPaint()
306
- return
307
- }
308
-
309
- // Same back-navigation can fire popstate + routeChangeComplete — do not
310
- // restart and cancel an in-flight restore for the same history entry.
311
- if (activeRestoreKey === key) return
312
-
313
- activeRestoreKey = key
314
- const session: RestoreSession = {
315
- generation: ++restoreGeneration,
316
- key,
317
- stored,
318
- attempts: 0,
319
- observer: null,
320
- defenseUntil: 0,
321
- stableHits: 0,
322
- }
323
-
324
- // Paint the saved position immediately (before waiting on network/DOM).
325
- scrollToSavedPosition(stored)
326
-
327
- const scheduleNext = () => {
328
- if (isStaleGeneration(session.generation)) return
329
- // First frames: rAF for snappy refine once the card exists.
330
- // Later: 100ms while waiting on infinite-scroll pages / GraphQL.
331
- if (session.attempts < 8) {
332
- requestAnimationFrame(() => {
333
- globalThis.setTimeout(tryRestore, 0)
334
- })
335
- return
336
- }
337
- globalThis.setTimeout(tryRestore, RESTORE_RETRY_MS)
338
- }
339
-
340
- const tryRestore = () => {
341
- if (isStaleGeneration(session.generation)) {
342
- cleanupRestoreObserver(session)
343
- return
344
- }
345
-
346
- session.attempts += 1
347
-
348
- if (restoreByAnchor(session, scheduleNext, tryRestore)) return
349
- restoreByCoordinates(session, tryRestore)
350
- }
351
-
352
- // Kick off on the next frame so we run after Next's scroll-to-top.
353
- requestAnimationFrame(() => {
354
- globalThis.setTimeout(tryRestore, 0)
355
- })
356
- }
357
-
358
- function markPendingRestore() {
359
- pendingPopRestore = true
360
- const key = historyStorageKey()
361
- try {
362
- sessionStorage.setItem(PENDING_RESTORE_FLAG, key)
363
- } catch {
364
- // ignore quota / private mode
365
- }
366
- // Only hide paint when this history entry has a saved PLP position —
367
- // otherwise every unrelated browser-back would flash blank.
368
- if (readStoredScroll(key)) {
369
- beginRestoringPaint()
370
- }
371
- }
372
-
373
- function consumePendingRestore() {
374
- const fromFlag = pendingPopRestore
375
- pendingPopRestore = false
376
-
377
- let flaggedKey: string | null = null
378
- try {
379
- flaggedKey = sessionStorage.getItem(PENDING_RESTORE_FLAG)
380
- sessionStorage.removeItem(PENDING_RESTORE_FLAG)
381
- } catch {
382
- // ignore
383
- }
384
-
385
- return fromFlag || flaggedKey !== null
386
- }
387
-
388
- function saveScrollPos(anchor?: string) {
389
- const key = historyStorageKey()
390
- const payload: StoredScroll = {
391
- x: globalThis.scrollX,
392
- y: globalThis.scrollY,
393
- }
394
- // Only attach an anchor for real PDP navigations. Carrying a previous
395
- // anchor forward on non-PDP exits would restore a stale product on Back.
396
- if (anchor) {
397
- payload.anchor = anchor
398
- }
399
-
400
- writeStoredScroll(key, payload)
401
- }
402
-
403
- /** Session key used by `@faststore/sdk` infinite-scroll page persistence. */
404
- function infiniteScrollPagesStorageKey() {
405
- const sanitizedKey = globalThis.location.pathname.replaceAll(/\W/g, '_')
406
- return sanitizedKey ? `__fs_gallery_page_${sanitizedKey}` : null
407
- }
408
-
409
- /**
410
- * Rehydrate Zustand `pages` from sessionStorage before scroll restore.
411
- * Navigating PDP → elsewhere calls `resetInfiniteScroll(0)` and wipes memory
412
- * even though the PLP key (e.g. `__fs_gallery_page__office`) still has
413
- * `[0,1,2]`. Without this, Back lands mid-page with only page 0 mounted and
414
- * the viewport sits in empty reserved height / near the footer.
415
- */
416
- function restoreInfiniteScrollPages(
417
- resetInfiniteScroll: (page: number) => void,
418
- addNextPage: () => void,
419
- getPages: () => number[]
420
- ) {
421
- try {
422
- const storageKey = infiniteScrollPagesStorageKey()
423
- if (!storageKey) return
424
-
425
- const raw = sessionStorage.getItem(storageKey)
426
- if (!raw) return
427
-
428
- const stored = JSON.parse(raw) as unknown
429
- if (
430
- !Array.isArray(stored) ||
431
- stored.length === 0 ||
432
- !stored.every((page) => typeof page === 'number')
433
- ) {
434
- return
435
- }
436
-
437
- const current = getPages()
438
- if (
439
- current.length === stored.length &&
440
- current.every((page, index) => page === stored[index])
441
- ) {
442
- return
443
- }
444
-
445
- resetInfiniteScroll(stored[0])
446
- for (let i = 1; i < stored.length; i++) {
447
- addNextPage()
448
- }
449
- } catch {
450
- // ignore quota / private mode / bad JSON
451
- }
452
- }
453
-
454
6
  export default function useScrollRestoration() {
455
7
  const router = useRouter()
456
- const { resetInfiniteScroll, addNextPage, pages } = useSearch()
457
- const pagesRef = useRef(pages)
458
- pagesRef.current = pages
8
+ const { resetInfiniteScroll } = useSearch()
459
9
 
460
10
  useEffect(() => {
11
+ let isPopState = false
12
+
461
13
  if ('scrollRestoration' in history) {
462
14
  history.scrollRestoration = 'manual'
463
15
  }
464
16
 
465
- const restorePages = () =>
466
- restoreInfiniteScrollPages(
467
- resetInfiniteScroll,
468
- addNextPage,
469
- () => pagesRef.current
470
- )
471
-
472
- // Resume a restore interrupted by effect remount mid back-navigation.
473
- try {
474
- if (sessionStorage.getItem(PENDING_RESTORE_FLAG)) {
475
- consumePendingRestore()
476
- restorePages()
477
- scheduleRestore()
17
+ const saveScrollPos = () => {
18
+ const key = window.history.state?.key
19
+ if (key) {
20
+ sessionStorage.setItem(
21
+ `__fs_scroll_${key}`,
22
+ JSON.stringify({ x: window.scrollX, y: window.scrollY })
23
+ )
478
24
  }
479
- } catch {
480
- // ignore
481
25
  }
482
26
 
483
- // Next types `BeforePopStateCallback` as NextHistoryState (url/as/options),
484
- // while runtime also carries `key`. Use history.state via markPendingRestore.
485
- const onBeforePopState = () => {
486
- markPendingRestore()
487
- return true
488
- }
27
+ const restoreScrollPos = async () => {
28
+ const key = window.history.state?.key
29
+ if (key) {
30
+ const stored = sessionStorage.getItem(`__fs_scroll_${key}`)
31
+ if (stored) {
32
+ const { x, y } = await JSON.parse(stored)
489
33
 
490
- /**
491
- * Capture product navigations as early as possible — more reliable than
492
- * waiting for routeChangeStart (and works if the click target is nested).
493
- */
494
- const onClickCapture = (event: MouseEvent) => {
495
- if (pendingPopRestore) return
496
- // Ignore modified / non-primary clicks — they open a new tab or do not
497
- // navigate, and must not overwrite the PLP scroll anchor.
498
- if (event.defaultPrevented || event.button !== 0) return
499
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
500
- return
34
+ // Products rendering delay
35
+ setTimeout(() => window.scrollTo(x, y), 800)
36
+ }
501
37
  }
502
- if (!(event.target instanceof Element)) return
503
-
504
- const link = event.target.closest('a')
505
- if (!(link instanceof HTMLAnchorElement)) return
506
-
507
- const href = link.getAttribute('href')
508
- if (!href) return
509
-
510
- const destPath = destinationPathname(href)
511
- if (!destPath.endsWith('/p')) return
512
-
513
- // Only when leaving the current listing page toward a PDP.
514
- if (destPath === globalThis.location.pathname) return
515
-
516
- saveScrollPos(destPath)
517
38
  }
518
39
 
519
- const onRouteChangeStart = (url: string) => {
520
- if (pendingPopRestore) return
521
-
522
- cancelRestore()
40
+ const onBeforePopState = () => (isPopState = true)
523
41
 
524
- const currentPath = globalThis.location.pathname
525
- const destPath = destinationPathname(url)
526
- const isSamePathNavigation =
527
- normalizePath(currentPath) === normalizePath(destPath)
528
- const isPdpNavigation = destPath.endsWith('/p')
529
-
530
- if (!isSamePathNavigation) {
531
- saveScrollPos(isPdpNavigation ? destPath : undefined)
532
- }
533
-
534
- if (isSamePathNavigation || isPdpNavigation) return
42
+ const onRouteChangeStart = (url: string) => {
43
+ // Save scroll position only when navigating to PDPs
44
+ if (isPopState || url.includes(window.location.pathname)) return
45
+ saveScrollPos()
535
46
 
536
- // Forward navigations away from a listing/PDP must start the next
537
- // listing at page 0. Back-nav rehydrates pages via
538
- // restoreInfiniteScrollPages (sessionStorage is keyed by pathname).
539
- // SO-651 (IS redirect hang) is covered by SearchWrapper's effect-based
540
- // replace — do not skip reset here or stale `pages` leak into the next PLP.
47
+ // Reset each PLP's infinite scroll when navigating to other pages than PDPs
48
+ if (url.includes(window.location.pathname) || url.endsWith('/p')) return
541
49
  resetInfiniteScroll(0)
542
50
  }
543
51
 
544
- const onRouteChangeComplete = () => {
545
- if (!consumePendingRestore()) return
546
- restorePages()
547
- scheduleRestore()
548
- }
549
-
550
- const onRouteChangeError = () => {
551
- pendingPopRestore = false
552
- endRestoringPaint()
553
- try {
554
- sessionStorage.removeItem(PENDING_RESTORE_FLAG)
555
- } catch {
556
- // ignore
557
- }
558
- }
559
-
560
- const onPopState = () => {
561
- // Ensure flag is set even if beforePopState was replaced during remount.
562
- markPendingRestore()
563
- // Remount pages before measuring/scrolling so the PDP anchor can exist.
564
- restorePages()
565
- // Sync paint first — avoids a visible flash at the top of the PLP.
566
- optimisticRestore()
567
- scheduleRestore()
568
- }
569
-
570
52
  router.beforePopState(onBeforePopState)
571
53
  router.events.on('routeChangeStart', onRouteChangeStart)
572
- router.events.on('routeChangeComplete', onRouteChangeComplete)
573
- router.events.on('routeChangeError', onRouteChangeError)
574
- globalThis.addEventListener('popstate', onPopState)
575
- document.addEventListener('click', onClickCapture, true)
54
+ window.addEventListener('popstate', restoreScrollPos)
576
55
 
577
56
  return () => {
578
- // Do not cancel in-flight restore or flip scrollRestoration to `auto` —
579
- // effect remounts during back-nav must not abort or fight the restore.
580
57
  router.beforePopState(() => true)
581
58
  router.events.off('routeChangeStart', onRouteChangeStart)
582
- router.events.off('routeChangeComplete', onRouteChangeComplete)
583
- router.events.off('routeChangeError', onRouteChangeError)
584
- globalThis.removeEventListener('popstate', onPopState)
585
- document.removeEventListener('click', onClickCapture, true)
59
+ window.removeEventListener('popstate', restoreScrollPos)
60
+ if ('scrollRestoration' in history) {
61
+ history.scrollRestoration = 'auto'
62
+ }
586
63
  }
587
- // resetInfiniteScroll is a stable Zustand action; omit from deps to avoid
588
- // re-binding router listeners on unrelated search state updates.
589
- // eslint-disable-next-line react-hooks/exhaustive-deps
590
64
  }, [router])
591
65
  }
592
-
593
- /** @internal — exported for unit tests only. */
594
- export const scrollRestorationTestUtils = {
595
- SCROLL_STORAGE_PREFIX,
596
- PENDING_RESTORE_FLAG,
597
- RESTORING_SCROLL_CLASS,
598
- STABLE_HITS_REQUIRED,
599
- RESTORE_MAX_ATTEMPTS,
600
- destinationPathname,
601
- findAnchorCard,
602
- normalizePath,
603
- pathMatchesAnchor,
604
- isElementInViewport,
605
- scrollElementIntoView,
606
- scrollToSavedPosition,
607
- trackAnchorInView,
608
- isAnchorRestoreSettled,
609
- restoreByAnchor,
610
- restoreByCoordinates,
611
- continueOrFinish,
612
- finishRestore,
613
- ensureAnchorObserver,
614
- optimisticRestore,
615
- scheduleRestore,
616
- markPendingRestore,
617
- cancelRestore,
618
- beginRestoringPaint,
619
- endRestoringPaint,
620
- restoreInfiniteScrollPages,
621
- createSession(
622
- overrides: Partial<RestoreSession> & { stored?: StoredScroll } = {}
623
- ): RestoreSession {
624
- const { stored: storedOverride, ...rest } = overrides
625
- return {
626
- generation: restoreGeneration,
627
- key: 'plp-key',
628
- attempts: 1,
629
- observer: null,
630
- defenseUntil: 0,
631
- stableHits: 0,
632
- ...rest,
633
- stored: storedOverride ?? { x: 0, y: 100 },
634
- }
635
- },
636
- setActiveRestoreKey(key: string | null) {
637
- activeRestoreKey = key
638
- },
639
- bumpGeneration() {
640
- restoreGeneration += 1
641
- return restoreGeneration
642
- },
643
- reset() {
644
- pendingPopRestore = false
645
- restoreGeneration = 0
646
- activeRestoreKey = null
647
- endRestoringPaint()
648
- },
649
- }