@faststore/core 4.6.0-dev.0 → 4.6.0-dev.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-generate.log +3 -3
- package/.turbo/turbo-test.log +85 -77
- package/CHANGELOG.md +36 -0
- 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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type PropsWithChildren,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import { useSearch } from '@faststore/sdk'
|
|
10
|
+
|
|
11
|
+
/** Coarse layout buckets — page height differs a lot across these. */
|
|
12
|
+
export function getGalleryViewportBucket() {
|
|
13
|
+
if (globalThis.window === undefined) return 'ssr'
|
|
14
|
+
const width = globalThis.innerWidth
|
|
15
|
+
if (width <= 420) return 'mobile'
|
|
16
|
+
if (width <= 768) return 'tablet'
|
|
17
|
+
if (width < 1280) return 'notebook'
|
|
18
|
+
return 'desktop'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 32-bit string hash — avoids truncating long facet/path keys into collisions. */
|
|
22
|
+
export function hashGalleryPageHeightKey(value: string) {
|
|
23
|
+
let hash = 0
|
|
24
|
+
for (const char of value) {
|
|
25
|
+
hash = Math.trunc(Math.imul(31, hash) + (char.codePointAt(0) ?? 0))
|
|
26
|
+
}
|
|
27
|
+
return (hash >>> 0).toString(36)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildGalleryPageHeightKey(
|
|
31
|
+
page: number,
|
|
32
|
+
search: {
|
|
33
|
+
path: string
|
|
34
|
+
term: string | null
|
|
35
|
+
sort: string | null
|
|
36
|
+
selectedFacets: unknown
|
|
37
|
+
viewport?: string
|
|
38
|
+
}
|
|
39
|
+
) {
|
|
40
|
+
const viewport = search.viewport ?? getGalleryViewportBucket()
|
|
41
|
+
const raw = JSON.stringify({
|
|
42
|
+
path: search.path,
|
|
43
|
+
term: search.term,
|
|
44
|
+
sort: search.sort,
|
|
45
|
+
selectedFacets: search.selectedFacets ?? [],
|
|
46
|
+
page,
|
|
47
|
+
viewport,
|
|
48
|
+
})
|
|
49
|
+
// Hash the full payload so long paths/facets never truncate page/viewport away.
|
|
50
|
+
return `__fs_gallery_page_h_p${page}_${viewport}_${hashGalleryPageHeightKey(raw)}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readGalleryPageHeight(key: string): number | null {
|
|
54
|
+
try {
|
|
55
|
+
const raw = sessionStorage.getItem(key)
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
const height = Number(raw)
|
|
58
|
+
return Number.isFinite(height) && height > 0 ? height : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeGalleryPageHeight(key: string, height: number) {
|
|
65
|
+
try {
|
|
66
|
+
sessionStorage.setItem(key, String(Math.round(height)))
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore quota / private mode
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sum of last-measured heights for the active infinite-scroll pages.
|
|
74
|
+
* Used to reserve space for the whole gallery before individual pages mount.
|
|
75
|
+
*/
|
|
76
|
+
export function getReservedGalleryHeight(
|
|
77
|
+
pages: number[],
|
|
78
|
+
search: {
|
|
79
|
+
path: string
|
|
80
|
+
term: string | null
|
|
81
|
+
sort: string | null
|
|
82
|
+
selectedFacets: unknown
|
|
83
|
+
viewport?: string
|
|
84
|
+
}
|
|
85
|
+
) {
|
|
86
|
+
return pages.reduce((sum, page) => {
|
|
87
|
+
const height = readGalleryPageHeight(
|
|
88
|
+
buildGalleryPageHeightKey(page, search)
|
|
89
|
+
)
|
|
90
|
+
return sum + (height ?? 0)
|
|
91
|
+
}, 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Client-only reserved height for the results column while products load. */
|
|
95
|
+
export function resolveReservedGalleryHeight(
|
|
96
|
+
hasProductsLoaded: boolean,
|
|
97
|
+
pages: number[],
|
|
98
|
+
search: {
|
|
99
|
+
path: string
|
|
100
|
+
term: string | null
|
|
101
|
+
sort: string | null
|
|
102
|
+
selectedFacets: unknown
|
|
103
|
+
viewport?: string
|
|
104
|
+
}
|
|
105
|
+
) {
|
|
106
|
+
if (hasProductsLoaded) return 0
|
|
107
|
+
return getReservedGalleryHeight(pages, search)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Props = PropsWithChildren<{
|
|
111
|
+
page: number
|
|
112
|
+
}>
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Locks the gallery page slot to the last measured height **only while the
|
|
116
|
+
* skeleton is showing**. Once real product cards are mounted, the lock is
|
|
117
|
+
* released so a viewport resize cannot leave huge empty gaps between pages.
|
|
118
|
+
*/
|
|
119
|
+
function GalleryPageHeightLock({ page, children }: Props) {
|
|
120
|
+
const {
|
|
121
|
+
state: { term, sort, selectedFacets },
|
|
122
|
+
} = useSearch()
|
|
123
|
+
|
|
124
|
+
const [viewport, setViewport] = useState(getGalleryViewportBucket)
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const onResize = () => setViewport(getGalleryViewportBucket())
|
|
128
|
+
globalThis.addEventListener('resize', onResize)
|
|
129
|
+
return () => globalThis.removeEventListener('resize', onResize)
|
|
130
|
+
}, [])
|
|
131
|
+
|
|
132
|
+
const storageKey = useMemo(() => {
|
|
133
|
+
const path =
|
|
134
|
+
globalThis.window === undefined ? '' : globalThis.location.pathname
|
|
135
|
+
return buildGalleryPageHeightKey(page, {
|
|
136
|
+
path,
|
|
137
|
+
term: term ?? null,
|
|
138
|
+
sort: sort ?? null,
|
|
139
|
+
selectedFacets,
|
|
140
|
+
viewport,
|
|
141
|
+
})
|
|
142
|
+
}, [term, sort, selectedFacets, page, viewport])
|
|
143
|
+
|
|
144
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
145
|
+
/** Height applied only while this page has no product cards yet. */
|
|
146
|
+
// Start null on SSR and first client paint; storageKey effect applies the
|
|
147
|
+
// sessionStorage value after mount to avoid hydration mismatches.
|
|
148
|
+
const [pendingMinHeight, setPendingMinHeight] = useState<number | null>(null)
|
|
149
|
+
const [isLocked, setIsLocked] = useState(true)
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
setPendingMinHeight(readGalleryPageHeight(storageKey))
|
|
153
|
+
// Re-lock briefly when the viewport bucket / search key changes so back-nav
|
|
154
|
+
// skeletons on a new breakpoint still get the right reserved height.
|
|
155
|
+
setIsLocked(true)
|
|
156
|
+
}, [storageKey])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const el = ref.current
|
|
160
|
+
if (!el) return
|
|
161
|
+
|
|
162
|
+
const syncFromDom = () => {
|
|
163
|
+
const hasProducts = Boolean(
|
|
164
|
+
el.querySelector(
|
|
165
|
+
'[data-fs-product-card], a[data-testid="product-link"]'
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if (!hasProducts) {
|
|
170
|
+
// Skeleton / empty — keep (or restore) the reserved minHeight.
|
|
171
|
+
setIsLocked(true)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Products are visible: drop the lock so natural height can shrink on
|
|
176
|
+
// resize, then persist the unconstrained content height for next back-nav.
|
|
177
|
+
setIsLocked(false)
|
|
178
|
+
|
|
179
|
+
// Measure after minHeight is cleared (next frame).
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
const height = el.getBoundingClientRect().height
|
|
182
|
+
if (height < 80) return
|
|
183
|
+
const rounded = Math.round(height)
|
|
184
|
+
writeGalleryPageHeight(storageKey, rounded)
|
|
185
|
+
setPendingMinHeight(rounded)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
syncFromDom()
|
|
190
|
+
|
|
191
|
+
const mutationObserver = new MutationObserver(syncFromDom)
|
|
192
|
+
mutationObserver.observe(el, { childList: true, subtree: true })
|
|
193
|
+
|
|
194
|
+
let resizeObserver: ResizeObserver | null = null
|
|
195
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
196
|
+
resizeObserver = new ResizeObserver(() => {
|
|
197
|
+
// Only persist when unlocked (real layout). Observing while locked
|
|
198
|
+
// would re-read the artificial minHeight and never shrink.
|
|
199
|
+
if (
|
|
200
|
+
!el.querySelector(
|
|
201
|
+
'[data-fs-product-card], a[data-testid="product-link"]'
|
|
202
|
+
)
|
|
203
|
+
) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
const height = el.getBoundingClientRect().height
|
|
207
|
+
if (height < 80) return
|
|
208
|
+
const rounded = Math.round(height)
|
|
209
|
+
writeGalleryPageHeight(storageKey, rounded)
|
|
210
|
+
setPendingMinHeight(rounded)
|
|
211
|
+
})
|
|
212
|
+
resizeObserver.observe(el)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
mutationObserver.disconnect()
|
|
217
|
+
resizeObserver?.disconnect()
|
|
218
|
+
}
|
|
219
|
+
// Re-sync when children swap (e.g. skeleton → product grid). MutationObserver
|
|
220
|
+
// alone is not reliable across React rerenders in jsdom/CI.
|
|
221
|
+
}, [storageKey, children])
|
|
222
|
+
|
|
223
|
+
const style: CSSProperties | undefined =
|
|
224
|
+
isLocked && pendingMinHeight ? { minHeight: pendingMinHeight } : undefined
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
ref={ref}
|
|
229
|
+
data-fs-gallery-page-height-lock
|
|
230
|
+
data-fs-gallery-page={page}
|
|
231
|
+
style={style}
|
|
232
|
+
>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default GalleryPageHeightLock
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextSeo } from 'next-seo'
|
|
2
2
|
import dynamic from 'next/dynamic'
|
|
3
|
-
import { Suspense, useState, type MouseEvent } from 'react'
|
|
3
|
+
import { Suspense, useEffect, useState, type MouseEvent } from 'react'
|
|
4
4
|
|
|
5
5
|
import { useSearch } from '@faststore/sdk'
|
|
6
6
|
import { useUI } from '@faststore/ui'
|
|
@@ -24,6 +24,10 @@ import useScreenResize from 'src/sdk/ui/useScreenResize'
|
|
|
24
24
|
|
|
25
25
|
import { useFormattedPrice } from 'src/sdk/product/useFormattedPrice'
|
|
26
26
|
import styles from '../../sections/ProductGallery/section.module.scss'
|
|
27
|
+
import GalleryPageHeightLock, {
|
|
28
|
+
getGalleryViewportBucket,
|
|
29
|
+
resolveReservedGalleryHeight,
|
|
30
|
+
} from './GalleryPageHeightLock'
|
|
27
31
|
import ProductGalleryPage from './ProductGalleryPage'
|
|
28
32
|
const FilterSkeleton = dynamic(
|
|
29
33
|
() =>
|
|
@@ -33,8 +37,6 @@ const FilterSkeleton = dynamic(
|
|
|
33
37
|
)
|
|
34
38
|
)
|
|
35
39
|
|
|
36
|
-
const GalleryPageSkeleton = <ProductGridSkeleton loading />
|
|
37
|
-
|
|
38
40
|
export interface ProductGalleryProps {
|
|
39
41
|
title?: string
|
|
40
42
|
searchTerm?: string
|
|
@@ -131,7 +133,13 @@ function ProductGallery({
|
|
|
131
133
|
} = useOverrideComponents<'ProductGallery'>()
|
|
132
134
|
|
|
133
135
|
const { openFilter, filter: displayFilter } = useUI()
|
|
134
|
-
const {
|
|
136
|
+
const {
|
|
137
|
+
pages,
|
|
138
|
+
addNextPage,
|
|
139
|
+
addPrevPage,
|
|
140
|
+
itemsPerPage,
|
|
141
|
+
state: { term, sort, selectedFacets },
|
|
142
|
+
} = useSearch()
|
|
135
143
|
const context = usePage<SearchPageContext | PLPContext>()
|
|
136
144
|
const data = context?.data
|
|
137
145
|
const facets = useDelayedFacets(data) ?? []
|
|
@@ -151,6 +159,25 @@ function ProductGallery({
|
|
|
151
159
|
(data as PLPContext['data'])?.collection?.meta?.selectedFacets ?? []
|
|
152
160
|
const filter = useFilter(facets, initialSelectedFacets)
|
|
153
161
|
|
|
162
|
+
// Keep the results column tall on PDP→PLP back nav before pages remount.
|
|
163
|
+
// Only while products are loading — once grids paint, natural height wins
|
|
164
|
+
// (avoids huge gaps after a viewport resize with a stale reserved minHeight).
|
|
165
|
+
// Populate after mount so SSR/hydration always start at 0 (sessionStorage is
|
|
166
|
+
// client-only and would otherwise mismatch).
|
|
167
|
+
const [reservedGalleryHeight, setReservedGalleryHeight] = useState(0)
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
setReservedGalleryHeight(
|
|
171
|
+
resolveReservedGalleryHeight(hasProductsLoaded, pages, {
|
|
172
|
+
path: globalThis.location.pathname,
|
|
173
|
+
term: term ?? null,
|
|
174
|
+
sort: sort ?? null,
|
|
175
|
+
selectedFacets,
|
|
176
|
+
viewport: getGalleryViewportBucket(),
|
|
177
|
+
})
|
|
178
|
+
)
|
|
179
|
+
}, [pages, term, sort, selectedFacets, hasProductsLoaded])
|
|
180
|
+
|
|
154
181
|
return (
|
|
155
182
|
<section data-testid="product-gallery" data-fs-product-listing>
|
|
156
183
|
{searchTerm && (
|
|
@@ -272,7 +299,14 @@ function ProductGallery({
|
|
|
272
299
|
)}
|
|
273
300
|
</FilterButtonSkeleton.Component>
|
|
274
301
|
</div>
|
|
275
|
-
<div
|
|
302
|
+
<div
|
|
303
|
+
data-fs-product-listing-results
|
|
304
|
+
style={
|
|
305
|
+
reservedGalleryHeight > 0
|
|
306
|
+
? { minHeight: reservedGalleryHeight }
|
|
307
|
+
: undefined
|
|
308
|
+
}
|
|
309
|
+
>
|
|
276
310
|
{/* Add link to previous page. This helps on SEO */}
|
|
277
311
|
{!!prev && (
|
|
278
312
|
<div data-fs-product-listing-pagination="top">
|
|
@@ -313,25 +347,36 @@ function ProductGallery({
|
|
|
313
347
|
</LinkButtonPrev.Component>
|
|
314
348
|
</div>
|
|
315
349
|
)}
|
|
316
|
-
{/* Render ALL products
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
350
|
+
{/* Render ALL products.
|
|
351
|
+
Each page has its own Suspense boundary so a refetch on page N
|
|
352
|
+
does not unmount pages that already have data (avoids CLS when
|
|
353
|
+
returning from a PDP with infinite-scroll pages restored).
|
|
354
|
+
GalleryPageHeightLock keeps the last measured page height while
|
|
355
|
+
skeletons are shown so the list does not collapse — including
|
|
356
|
+
the initial !hasProductsLoaded frame. */}
|
|
357
|
+
{pages.map((page) => (
|
|
358
|
+
<GalleryPageHeightLock key={`gallery-page-${page}`} page={page}>
|
|
359
|
+
{hasProductsLoaded ? (
|
|
360
|
+
<Suspense
|
|
361
|
+
fallback={
|
|
362
|
+
<ProductGridSkeleton loading count={itemsPerPage} />
|
|
363
|
+
}
|
|
364
|
+
>
|
|
365
|
+
<ProductGalleryPage
|
|
366
|
+
page={page}
|
|
367
|
+
title={title}
|
|
368
|
+
productCard={productCard}
|
|
369
|
+
itemsPerPage={itemsPerPage}
|
|
370
|
+
firstPage={pages[0]}
|
|
371
|
+
shouldShowComparison={showComparisonProducts}
|
|
372
|
+
compareLabel={productComparison?.labels?.compareButton}
|
|
373
|
+
/>
|
|
374
|
+
</Suspense>
|
|
375
|
+
) : (
|
|
376
|
+
<ProductGridSkeleton loading count={itemsPerPage} />
|
|
377
|
+
)}
|
|
378
|
+
</GalleryPageHeightLock>
|
|
379
|
+
))}
|
|
335
380
|
{/* Add link to next page. This helps on SEO */}
|
|
336
381
|
{next !== false && (
|
|
337
382
|
<div data-fs-product-listing-pagination="bottom">
|