@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.
@@ -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 { pages, addNextPage, addPrevPage, itemsPerPage } = useSearch()
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 data-fs-product-listing-results>
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
- {hasProductsLoaded ? (
318
- <Suspense fallback={GalleryPageSkeleton}>
319
- {pages.map((page) => (
320
- <ProductGalleryPage
321
- key={`gallery-page-${page}`}
322
- page={page}
323
- title={title}
324
- productCard={productCard}
325
- itemsPerPage={itemsPerPage}
326
- firstPage={pages[0]}
327
- shouldShowComparison={showComparisonProducts}
328
- compareLabel={productComparison?.labels?.compareButton}
329
- />
330
- ))}
331
- </Suspense>
332
- ) : (
333
- GalleryPageSkeleton
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">