@faststore/core 4.5.1-dev.3 → 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.
- package/.turbo/turbo-test.log +82 -79
- package/CHANGELOG.md +17 -9
- package/cypress/integration/seo.test.js +5 -35
- package/next-seo.config.ts +0 -3
- package/package.json +6 -6
- package/src/components/product/ProductGrid/ProductGrid.tsx +1 -0
- package/src/components/skeletons/ProductGridSkeleton/ProductGridSkeleton.tsx +4 -1
- package/src/components/templates/SearchPage/SearchWrapper.tsx +15 -5
- package/src/components/ui/ProductGallery/GalleryPageHeightLock.tsx +238 -0
- package/src/components/ui/ProductGallery/ProductGallery.tsx +69 -24
- package/src/sdk/ui/useScrollRestoration.tsx +616 -32
- package/src/styles/global/index.scss +12 -0
- package/test/components/skeletons/ProductGridSkeleton.browser.test.tsx +32 -0
- package/test/components/templates/SearchPage/SearchWrapper.browser.test.tsx +137 -0
- package/test/components/ui/ProductGallery/GalleryPageHeightLock.browser.test.tsx +230 -0
- package/test/sdk/ui/useScrollRestoration.browser.test.ts +798 -0
|
@@ -1,65 +1,649 @@
|
|
|
1
|
-
import { useEffect } from 'react'
|
|
1
|
+
import { useEffect, useRef } 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
|
+
|
|
6
454
|
export default function useScrollRestoration() {
|
|
7
455
|
const router = useRouter()
|
|
8
|
-
const { resetInfiniteScroll } = useSearch()
|
|
456
|
+
const { resetInfiniteScroll, addNextPage, pages } = useSearch()
|
|
457
|
+
const pagesRef = useRef(pages)
|
|
458
|
+
pagesRef.current = pages
|
|
9
459
|
|
|
10
460
|
useEffect(() => {
|
|
11
|
-
let isPopState = false
|
|
12
|
-
|
|
13
461
|
if ('scrollRestoration' in history) {
|
|
14
462
|
history.scrollRestoration = 'manual'
|
|
15
463
|
}
|
|
16
464
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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()
|
|
24
478
|
}
|
|
479
|
+
} catch {
|
|
480
|
+
// ignore
|
|
25
481
|
}
|
|
26
482
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
|
33
489
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
37
501
|
}
|
|
38
|
-
|
|
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
|
|
39
512
|
|
|
40
|
-
|
|
513
|
+
// Only when leaving the current listing page toward a PDP.
|
|
514
|
+
if (destPath === globalThis.location.pathname) return
|
|
515
|
+
|
|
516
|
+
saveScrollPos(destPath)
|
|
517
|
+
}
|
|
41
518
|
|
|
42
519
|
const onRouteChangeStart = (url: string) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
520
|
+
if (pendingPopRestore) return
|
|
521
|
+
|
|
522
|
+
cancelRestore()
|
|
46
523
|
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
535
|
+
|
|
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.
|
|
49
541
|
resetInfiniteScroll(0)
|
|
50
542
|
}
|
|
51
543
|
|
|
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
|
+
|
|
52
570
|
router.beforePopState(onBeforePopState)
|
|
53
571
|
router.events.on('routeChangeStart', onRouteChangeStart)
|
|
54
|
-
|
|
572
|
+
router.events.on('routeChangeComplete', onRouteChangeComplete)
|
|
573
|
+
router.events.on('routeChangeError', onRouteChangeError)
|
|
574
|
+
globalThis.addEventListener('popstate', onPopState)
|
|
575
|
+
document.addEventListener('click', onClickCapture, true)
|
|
55
576
|
|
|
56
577
|
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.
|
|
57
580
|
router.beforePopState(() => true)
|
|
58
581
|
router.events.off('routeChangeStart', onRouteChangeStart)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
582
|
+
router.events.off('routeChangeComplete', onRouteChangeComplete)
|
|
583
|
+
router.events.off('routeChangeError', onRouteChangeError)
|
|
584
|
+
globalThis.removeEventListener('popstate', onPopState)
|
|
585
|
+
document.removeEventListener('click', onClickCapture, true)
|
|
63
586
|
}
|
|
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
|
|
64
590
|
}, [router])
|
|
65
591
|
}
|
|
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
|
+
}
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
// Establish a layer order up-front, FS UI base styles have the lowest priority
|
|
4
4
|
@layer base, components, theme;
|
|
5
5
|
|
|
6
|
+
// Hide painted pixels during PLP scroll restore so the footer does not flash
|
|
7
|
+
// at the top before the viewport jumps back to the saved product card.
|
|
8
|
+
html.fs-restoring-scroll {
|
|
9
|
+
overflow-x: hidden;
|
|
10
|
+
|
|
11
|
+
body {
|
|
12
|
+
// Hide from hit-testing and the accessibility tree while scroll jumps —
|
|
13
|
+
// opacity: 0 would keep the page interactive/AT-visible.
|
|
14
|
+
visibility: hidden;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
// FastStore UI's base styles
|
|
7
19
|
@include meta.load-css("~@faststore/ui/src/styles/global.scss");
|
|
8
20
|
|