@akinon/pz-theme 2.0.93-rc.0 → 2.0.93
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/CHANGELOG.md +14 -33
- package/jest/setup.ts +50 -0
- package/package.json +5 -4
- package/src/blocks/__tests__/carousel-ssr-render.test.tsx +387 -0
- package/src/blocks/__tests__/video-block.test.tsx +89 -0
- package/src/blocks/slider-block.tsx +95 -8
- package/src/blocks/video-block.tsx +43 -4
- package/src/sections/posts-slider-section.tsx +53 -3
- package/src/utils/__tests__/breakpoint-cascade.test.ts +42 -0
- package/src/utils/__tests__/carousel-ssr.test.ts +180 -0
- package/src/utils/carousel-ssr.tsx +275 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { devWarn } from './dev-warn';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Making `react-multi-carousel` emit its slides on the server.
|
|
7
|
+
*
|
|
8
|
+
* ## The bug this exists to end
|
|
9
|
+
*
|
|
10
|
+
* The library only renders slides at all when it believes it can size them.
|
|
11
|
+
* `getInitialState` computes
|
|
12
|
+
*
|
|
13
|
+
* shouldRenderOnSSR = ssr && deviceType && !domFullyLoaded && flexBisis
|
|
14
|
+
* shouldRenderAtAll = shouldRenderOnSSR || domFullyLoaded
|
|
15
|
+
*
|
|
16
|
+
* and `CarouselItems` returns `null` unless `shouldRenderAtAll`. On the server
|
|
17
|
+
* `domFullyLoaded` is false — there is no container to measure — so without
|
|
18
|
+
* `ssr` and `deviceType` every slider shipped an empty
|
|
19
|
+
* `<ul class="react-multi-carousel-track">`. Measured on a live storefront at
|
|
20
|
+
* 1440x900: the hero band was 0px at `domcontentloaded` and 850px once
|
|
21
|
+
* hydrated, so the page opened on its SECOND section and five home-page
|
|
22
|
+
* carousels were blank at first paint. CLS 0.70 desktop, 1.03 on Fast 3G.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the stylesheet below is the fix, not a detail
|
|
25
|
+
*
|
|
26
|
+
* Handing the library `ssr`/`deviceType` alone is not enough. It sizes SSR
|
|
27
|
+
* slides with `flex: 1 0 <100/items>%` — a PERCENTAGE of the track. But
|
|
28
|
+
* `.react-multi-carousel-list` is `display:flex` and the track is a flex item
|
|
29
|
+
* with the default `flex: 0 1 auto`, so the track's base size is its content,
|
|
30
|
+
* and its content is asking for a percentage of the track. Circular: the
|
|
31
|
+
* browser resolves the percentage against an indefinite basis and the whole
|
|
32
|
+
* thing collapses to the widest slide's intrinsic width. Measured with the
|
|
33
|
+
* `width:100%` rule below: track 1440, hero 1440. Without it: track 300,
|
|
34
|
+
* hero 300 — the original bug plus a broken hero.
|
|
35
|
+
*
|
|
36
|
+
* The library cannot fix this itself because it has no stylesheet of its own
|
|
37
|
+
* to gate; it reverts to measured pixel widths at mount, at which point
|
|
38
|
+
* `width:100%` is wrong (the track is wider than the viewport by design, it is
|
|
39
|
+
* what `translate3d` slides). So the rule has to exist for exactly the window
|
|
40
|
+
* between first paint and mount, and then stop existing. That is the gate
|
|
41
|
+
* attribute: it is written into the server markup, survives the client's first
|
|
42
|
+
* (hydrating) render unchanged, and is dropped in an effect once the library's
|
|
43
|
+
* own `componentDidMount` has measured.
|
|
44
|
+
*
|
|
45
|
+
* ## Why `deviceType` is a constant and never `currentBreakpoint`
|
|
46
|
+
*
|
|
47
|
+
* `deviceType` names a key of the responsive map, and the library reads
|
|
48
|
+
* `responsive[deviceType]` on paths that do not null-check it. It must be the
|
|
49
|
+
* same value on the server and in the client's first render, and there is
|
|
50
|
+
* exactly one such value: a literal.
|
|
51
|
+
*
|
|
52
|
+
* The `currentBreakpoint` prop is NOT that value. It moves on mount —
|
|
53
|
+
* `theme-placeholder-wrapper` measures `window.innerWidth` outside an iframe
|
|
54
|
+
* and calls `setCurrentBreakpoint`, and the editor drives it directly. Passing
|
|
55
|
+
* it would hand the library a `deviceType` that changes underneath it, and on
|
|
56
|
+
* a map that has no `tablet` band (`slider-block` has only `desktop` and
|
|
57
|
+
* `mobile`) `getPartialVisibilityGutter` then reads
|
|
58
|
+
* `responsive['tablet'].paritialVisibilityGutter` and throws — a blank section
|
|
59
|
+
* for any slider that authored a slide peek, on a tablet, in the editor.
|
|
60
|
+
* `carousel-ssr.test.tsx` fails if anyone substitutes the prop.
|
|
61
|
+
*
|
|
62
|
+
* ## Side effects that are real and deliberately not hidden
|
|
63
|
+
*
|
|
64
|
+
* - The library sets `overflow:hidden` on the track only while
|
|
65
|
+
* `shouldRenderOnSSR`, reverting to `unset` at mount. In that window,
|
|
66
|
+
* deliberately overflowing slide content — a peeking card, an absolutely
|
|
67
|
+
* positioned badge — is clipped.
|
|
68
|
+
* - `shouldShowArrows` is gated on `shouldRenderAtAll`, so arrows now appear in
|
|
69
|
+
* the SSR markup. Only the right one: `isInLeftEnd` is true at slide 0, so
|
|
70
|
+
* the left arrow is suppressed exactly as it is after mount.
|
|
71
|
+
* - `Dots` computes `ceil((slides - slidesToShow) / slidesToSlide) + 1`, and
|
|
72
|
+
* `slidesToShow` is 0 until mount, so the server emits one dot per slide and
|
|
73
|
+
* the mounted carousel emits fewer. The dot list is `position:absolute`, so
|
|
74
|
+
* this costs no layout — it is a count that settles, not a shift.
|
|
75
|
+
* - Every `<li>` carries `aria-hidden="true"` in the SSR markup, because
|
|
76
|
+
* `getIfSlideIsVisbile` compares against a `slidesToShow` of 0. It settles at
|
|
77
|
+
* mount, and it is the same markup the library ships for every SSR consumer.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/** Where the carousel's `containerClass` scope is written. */
|
|
81
|
+
export const CAROUSEL_SSR_SCOPE_PREFIX = 'pz-carousel-ssr-';
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The gate. Present in the server markup and in the client's first render,
|
|
85
|
+
* gone once mounted — which is what makes the stylesheet below stop applying
|
|
86
|
+
* without anything having to rewrite it.
|
|
87
|
+
*/
|
|
88
|
+
export const CAROUSEL_SSR_GATE_ATTRIBUTE = 'data-carousel-ssr';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The `deviceType` every call site hands the library, and the responsive-map
|
|
92
|
+
* key it therefore has to name. A literal on purpose — see the header.
|
|
93
|
+
*/
|
|
94
|
+
export const CAROUSEL_SSR_DEVICE_TYPE = 'desktop';
|
|
95
|
+
|
|
96
|
+
/** One band of a `react-multi-carousel` responsive map. */
|
|
97
|
+
export interface CarouselResponsiveBand {
|
|
98
|
+
breakpoint: { min: number; max: number };
|
|
99
|
+
items: number;
|
|
100
|
+
slidesToSlide?: number;
|
|
101
|
+
partialVisibilityGutter?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type CarouselResponsiveMap = Record<string, CarouselResponsiveBand>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* An id safe to sit inside an attribute selector and a class name, or null
|
|
108
|
+
* when there is nothing usable to scope with. Block ids are authored data, so
|
|
109
|
+
* they are sanitised rather than trusted — the same treatment
|
|
110
|
+
* `announcement-bar-block` gives the id it scopes its own inline `<style>`
|
|
111
|
+
* with.
|
|
112
|
+
*
|
|
113
|
+
* NULL RATHER THAN A SHARED CONSTANT. A literal fallback would hand every
|
|
114
|
+
* id-less carousel on a page the same gate value AND the same scope class, so
|
|
115
|
+
* each one's stylesheet would restyle the other's slides with the wrong item
|
|
116
|
+
* count — a wrong layout, which is worse than the empty track this module
|
|
117
|
+
* exists to remove. Refusing to scope is the safe failure: the carousel then
|
|
118
|
+
* behaves exactly as it did before this module existed.
|
|
119
|
+
*/
|
|
120
|
+
export const carouselSsrScopeId = (raw: unknown): string | null => {
|
|
121
|
+
const cleaned = String(raw ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
122
|
+
return cleaned || null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The library's own SSR width, formatted the way the library formats it, so
|
|
127
|
+
* the rule this module emits and the inline style the library writes cannot
|
|
128
|
+
* express the same number differently.
|
|
129
|
+
*/
|
|
130
|
+
const flexBasisForItems = (items: number): string => (100 / items).toFixed(1);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A band's media query, built from the band itself.
|
|
134
|
+
*
|
|
135
|
+
* This is the whole reason the stylesheet is generated from the responsive map
|
|
136
|
+
* rather than from the breakpoint constants directly: a query written at one
|
|
137
|
+
* width while the library switches at another would apply the mobile item
|
|
138
|
+
* count across a range where the mounted carousel applies the desktop one, and
|
|
139
|
+
* the slides would visibly resize at hydration. Reading both from one object
|
|
140
|
+
* makes that disagreement unrepresentable.
|
|
141
|
+
*/
|
|
142
|
+
const bandMediaQuery = (band: CarouselResponsiveBand): string | null => {
|
|
143
|
+
const { min, max } = band.breakpoint || ({} as { min: number; max: number });
|
|
144
|
+
const parts: string[] = [];
|
|
145
|
+
if (Number.isFinite(min) && min > 0) parts.push(`(min-width:${min}px)`);
|
|
146
|
+
if (Number.isFinite(max)) parts.push(`(max-width:${max}px)`);
|
|
147
|
+
return parts.length > 0 ? `@media ${parts.join(' and ')}` : null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The gated stylesheet for one carousel.
|
|
152
|
+
*
|
|
153
|
+
* Two selectors, both anchored on the gate attribute so the whole sheet dies
|
|
154
|
+
* at mount, and both narrowed by this carousel's own scope class so a slider
|
|
155
|
+
* nested inside another slider's slide is not restyled by its parent's item
|
|
156
|
+
* count. The `!important` on the flex override is not decoration: the value it
|
|
157
|
+
* overrides is an inline style the library writes on every `<li>`, and nothing
|
|
158
|
+
* short of `!important` outranks that.
|
|
159
|
+
*/
|
|
160
|
+
export const buildCarouselSsrCss = (
|
|
161
|
+
scopeId: string,
|
|
162
|
+
responsive: CarouselResponsiveMap,
|
|
163
|
+
deviceType: string = CAROUSEL_SSR_DEVICE_TYPE
|
|
164
|
+
): string => {
|
|
165
|
+
const track = `[${CAROUSEL_SSR_GATE_ATTRIBUTE}="${scopeId}"] .${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}>.react-multi-carousel-track`;
|
|
166
|
+
|
|
167
|
+
let css = `${track}{width:100%;}`;
|
|
168
|
+
|
|
169
|
+
Object.keys(responsive || {}).forEach((name) => {
|
|
170
|
+
// The SSR band needs no rule: its width is the one the library already
|
|
171
|
+
// wrote inline on each slide.
|
|
172
|
+
if (name === deviceType) return;
|
|
173
|
+
|
|
174
|
+
const band = responsive[name];
|
|
175
|
+
const items = Number(band?.items);
|
|
176
|
+
if (!Number.isFinite(items) || items <= 0) return;
|
|
177
|
+
|
|
178
|
+
const query = bandMediaQuery(band);
|
|
179
|
+
if (!query) return;
|
|
180
|
+
|
|
181
|
+
css += `${query}{${track}>li{flex:1 0 ${flexBasisForItems(
|
|
182
|
+
items
|
|
183
|
+
)}% !important;}}`;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return css;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export interface CarouselSsr {
|
|
190
|
+
/**
|
|
191
|
+
* Spread onto `<Carousel>`. Together these are what make the library emit
|
|
192
|
+
* slides instead of `null` — and EMPTY when there is no id to scope the
|
|
193
|
+
* width rule with, because opting in without that rule collapses the strip.
|
|
194
|
+
*/
|
|
195
|
+
carouselProps: { ssr: true; deviceType: string } | Record<string, never>;
|
|
196
|
+
/** Spread onto an ancestor of the carousel. Empty once mounted. */
|
|
197
|
+
gateProps: Record<string, string>;
|
|
198
|
+
/** Appended to the carousel's `containerClass`. Empty when unscoped. */
|
|
199
|
+
scopeClass: string;
|
|
200
|
+
/** The gated stylesheet, or `null` once mounted. */
|
|
201
|
+
gateStyle: React.ReactElement | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Everything one carousel call site needs, so the two of them cannot drift.
|
|
206
|
+
*
|
|
207
|
+
* `remountKey` must be the SAME string the call site hands `<Carousel key>`.
|
|
208
|
+
* That is not a convenience: `posts-slider-section` puts `currentBreakpoint`
|
|
209
|
+
* in its key, so switching device in the editor REMOUNTS the carousel, and a
|
|
210
|
+
* fresh mount is a fresh `domLoaded: false` — the library is back in its SSR
|
|
211
|
+
* state for one render. A gate that only tracked "has this component mounted
|
|
212
|
+
* once" would already be false by then, and the remounted carousel would paint
|
|
213
|
+
* desktop-basis slides for a frame at every breakpoint switch. Keying the gate
|
|
214
|
+
* to the same string means the gate comes back exactly when the SSR state
|
|
215
|
+
* does.
|
|
216
|
+
*/
|
|
217
|
+
export const useCarouselSsr = (options: {
|
|
218
|
+
id: unknown;
|
|
219
|
+
remountKey: string;
|
|
220
|
+
responsive: CarouselResponsiveMap;
|
|
221
|
+
}): CarouselSsr => {
|
|
222
|
+
const { id, remountKey, responsive } = options;
|
|
223
|
+
|
|
224
|
+
const scopeId = carouselSsrScopeId(id);
|
|
225
|
+
|
|
226
|
+
// `null` rather than `false`, so the very first client render is compared
|
|
227
|
+
// against a key it cannot equal. That render has to match the server's
|
|
228
|
+
// byte for byte or React discards the markup it just hydrated — which would
|
|
229
|
+
// reintroduce the blank first paint this module exists to remove.
|
|
230
|
+
const [mountedKey, setMountedKey] = React.useState<string | null>(null);
|
|
231
|
+
React.useEffect(() => {
|
|
232
|
+
// A passive effect, deliberately: the library's `componentDidMount` runs
|
|
233
|
+
// during commit, before this, so by the time the gate is dropped the
|
|
234
|
+
// carousel has already measured its container and written real pixel
|
|
235
|
+
// widths. Dropping it in a layout effect would race that measurement.
|
|
236
|
+
setMountedKey(remountKey);
|
|
237
|
+
}, [remountKey]);
|
|
238
|
+
|
|
239
|
+
const gated = mountedKey !== remountKey;
|
|
240
|
+
|
|
241
|
+
if (gated && !responsive?.[CAROUSEL_SSR_DEVICE_TYPE]) {
|
|
242
|
+
// Not fatal — the library simply falls back to rendering nothing until
|
|
243
|
+
// mount, which is the behaviour that predates this module. Worth saying
|
|
244
|
+
// out loud, because the failure is silent and looks like the old bug.
|
|
245
|
+
devWarn(
|
|
246
|
+
'carousel-ssr',
|
|
247
|
+
`Responsive map has no "${CAROUSEL_SSR_DEVICE_TYPE}" band, so slides cannot be server-rendered`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (scopeId === null) {
|
|
252
|
+
// No id to scope with, so the width rule cannot be written. Opting into
|
|
253
|
+
// the library's SSR path WITHOUT that rule is the worst of the three
|
|
254
|
+
// outcomes: the track is a flex item with an indefinite width, so the
|
|
255
|
+
// percentage basis the library writes on each slide collapses the whole
|
|
256
|
+
// strip to a few hundred pixels. Staying off the SSR path leaves the empty
|
|
257
|
+
// track this module exists to remove, which at least looks like the bug it
|
|
258
|
+
// is rather than a broken layout.
|
|
259
|
+
return {
|
|
260
|
+
carouselProps: {},
|
|
261
|
+
gateProps: {},
|
|
262
|
+
scopeClass: '',
|
|
263
|
+
gateStyle: null
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
carouselProps: { ssr: true, deviceType: CAROUSEL_SSR_DEVICE_TYPE },
|
|
269
|
+
gateProps: gated ? { [CAROUSEL_SSR_GATE_ATTRIBUTE]: scopeId } : {},
|
|
270
|
+
scopeClass: `${CAROUSEL_SSR_SCOPE_PREFIX}${scopeId}`,
|
|
271
|
+
gateStyle: gated ? (
|
|
272
|
+
<style>{buildCarouselSsrCss(scopeId, responsive)}</style>
|
|
273
|
+
) : null
|
|
274
|
+
};
|
|
275
|
+
};
|