@akinon/pz-theme 2.0.93-rc.0 → 2.0.94-beta.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.
- package/CHANGELOG.md +22 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,27 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
- Updated dependencies [
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.93-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.93-rc.0
|
|
3
|
+
## 2.0.94-beta.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.94-beta.0
|
|
9
|
+
- @akinon/theme-contract@2.0.94-beta.0
|
|
10
|
+
|
|
11
|
+
## 2.0.93
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- fc6b433f: ZERO-5000: Server-render carousel slides so sliders have height before hydration
|
|
16
|
+
|
|
17
|
+
Both carousel call sites rendered `<Carousel>` without `ssr`/`deviceType`, so the library returned no slides on the server and every slider shipped an empty track — zero height at first paint, full height once hydrated. On a storefront home page that meant the page opened on its second section and measured a CLS of 0.70.
|
|
18
|
+
|
|
19
|
+
Includes a behaviour change merchants can see: `slider-block`'s responsive map switched item counts at 768px while the block's own stylesheet, its `slidesToSlide` picker and the sibling posts slider all drew the line at 1024. It now draws it at 1024 too, so a slider between 768px and 1023px shows the mobile item count rather than the desktop one.
|
|
20
|
+
|
|
21
|
+
Also stops a video's native control bar appearing at first paint: `controls` and `poster` are stored per breakpoint and were being read raw, so controls were on for every video whatever the merchant chose and a poster they typed was silently dropped.
|
|
22
|
+
|
|
23
|
+
- @akinon/next@2.0.93
|
|
24
|
+
- @akinon/theme-contract@2.0.93
|
|
36
25
|
|
|
37
26
|
## 2.0.92
|
|
38
27
|
|
package/jest/setup.ts
CHANGED
|
@@ -17,6 +17,56 @@ if (!window.matchMedia) {
|
|
|
17
17
|
|
|
18
18
|
window.scrollTo = (() => {}) as typeof window.scrollTo;
|
|
19
19
|
|
|
20
|
+
// `react-dom/server` builds a MessageChannel at module scope, and jsdom has
|
|
21
|
+
// none — importing it in a jsdom suite throws before a single test runs. Node's
|
|
22
|
+
// own MessageChannel is not the answer: its ports keep the event loop alive, so
|
|
23
|
+
// jest hangs after the last test instead of exiting.
|
|
24
|
+
//
|
|
25
|
+
// `renderToString` is synchronous and never waits on this channel; it only
|
|
26
|
+
// needs the constructor to exist. So the shim delivers through a macrotask and
|
|
27
|
+
// holds nothing. Needed by any test that asserts what a component emits on the
|
|
28
|
+
// SERVER, which is the only way to catch a component that renders nothing until
|
|
29
|
+
// it hydrates.
|
|
30
|
+
class ShimMessagePort {
|
|
31
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
32
|
+
peer: ShimMessagePort | null = null;
|
|
33
|
+
|
|
34
|
+
postMessage(data: unknown) {
|
|
35
|
+
const peer = this.peer;
|
|
36
|
+
if (!peer) return;
|
|
37
|
+
setTimeout(() => peer.onmessage?.({ data }), 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
start() {}
|
|
41
|
+
close() {}
|
|
42
|
+
addEventListener() {}
|
|
43
|
+
removeEventListener() {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class ShimMessageChannel {
|
|
47
|
+
port1 = new ShimMessagePort();
|
|
48
|
+
port2 = new ShimMessagePort();
|
|
49
|
+
|
|
50
|
+
constructor() {
|
|
51
|
+
this.port1.peer = this.port2;
|
|
52
|
+
this.port2.peer = this.port1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const globals = globalThis as unknown as Record<string, unknown>;
|
|
57
|
+
if (typeof globals.MessageChannel === 'undefined') {
|
|
58
|
+
globals.MessageChannel = ShimMessageChannel;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The other half of the same gap: the server renderer encodes its output, and
|
|
62
|
+
// jsdom exposes no TextEncoder/TextDecoder. Node's are the real implementations
|
|
63
|
+
// and hold nothing open.
|
|
64
|
+
if (typeof globals.TextEncoder === 'undefined') {
|
|
65
|
+
const { TextEncoder, TextDecoder } = require('util');
|
|
66
|
+
globals.TextEncoder = TextEncoder;
|
|
67
|
+
globals.TextDecoder = TextDecoder;
|
|
68
|
+
}
|
|
69
|
+
|
|
20
70
|
class NoopObserver {
|
|
21
71
|
observe() {}
|
|
22
72
|
unobserve() {}
|
package/package.json
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.94-beta.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.
|
|
11
|
+
"@akinon/next": "2.0.94-beta.0",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.
|
|
16
|
+
"@akinon/theme-contract": "2.0.94-beta.0",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
|
+
"react-multi-carousel": "2.8.4",
|
|
19
20
|
"tailwind-merge": "^2.5.4",
|
|
20
21
|
"zod": "4.4.1"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
|
-
"@akinon/next": "2.0.
|
|
24
|
+
"@akinon/next": "2.0.94-beta.0",
|
|
24
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
25
26
|
"@testing-library/react": "16.3.2",
|
|
26
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slides that exist before hydration — asserted on the markup, with the REAL
|
|
3
|
+
* carousel library.
|
|
4
|
+
*
|
|
5
|
+
* Every other slider test in this package mocks `react-multi-carousel` away,
|
|
6
|
+
* which is right for testing what this package decides and useless for testing
|
|
7
|
+
* what the library emits. The bug being pinned here lived entirely inside the
|
|
8
|
+
* library: without `ssr`/`deviceType` its `CarouselItems` returns `null` on the
|
|
9
|
+
* server, so the track shipped empty, every slider band was 0px at first paint
|
|
10
|
+
* and the page opened on its second section. A test that stubbed the library
|
|
11
|
+
* would have gone on passing throughout.
|
|
12
|
+
*
|
|
13
|
+
* So: no carousel mock. Only `ThemeBlock` is stubbed, because what a slide
|
|
14
|
+
* CONTAINS is another file's subject and pulling the real one in would drag the
|
|
15
|
+
* whole renderer into a test about `<li>` elements.
|
|
16
|
+
*
|
|
17
|
+
* Note the jsdom caveat that makes these assertions possible at all: jsdom
|
|
18
|
+
* reports `offsetWidth: 0`, so `domFullyLoaded` is never true here and the
|
|
19
|
+
* library stays on its SSR path for the whole test. The gate this package owns
|
|
20
|
+
* is independent of that — it is React state, and it drops on its own effect,
|
|
21
|
+
* which is what "the gate is gone after mount" below actually measures.
|
|
22
|
+
*/
|
|
23
|
+
import React from 'react';
|
|
24
|
+
import { act, render } from '@testing-library/react';
|
|
25
|
+
import { renderToString } from 'react-dom/server';
|
|
26
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
27
|
+
|
|
28
|
+
jest.mock('../../theme-block', () => ({
|
|
29
|
+
__esModule: true,
|
|
30
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
31
|
+
<div data-testid="slide-content" data-id={block.id} />
|
|
32
|
+
)
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import SliderBlock from '../slider-block';
|
|
36
|
+
import PostsSliderSection from '../../sections/posts-slider-section';
|
|
37
|
+
import {
|
|
38
|
+
CAROUSEL_SSR_GATE_ATTRIBUTE,
|
|
39
|
+
CAROUSEL_SSR_SCOPE_PREFIX,
|
|
40
|
+
useCarouselSsr
|
|
41
|
+
} from '../../utils/carousel-ssr';
|
|
42
|
+
import { DESKTOP_MIN_WIDTH } from '../../utils/breakpoints';
|
|
43
|
+
|
|
44
|
+
const slide = (id: string): any => ({
|
|
45
|
+
id,
|
|
46
|
+
type: 'text',
|
|
47
|
+
label: id,
|
|
48
|
+
value: '',
|
|
49
|
+
properties: {},
|
|
50
|
+
styles: {},
|
|
51
|
+
order: 0,
|
|
52
|
+
hidden: false
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const sliderProps = (
|
|
56
|
+
properties: Record<string, unknown> = {},
|
|
57
|
+
over: Record<string, unknown> = {}
|
|
58
|
+
) =>
|
|
59
|
+
({
|
|
60
|
+
block: {
|
|
61
|
+
id: 'featured-brands',
|
|
62
|
+
type: 'slider',
|
|
63
|
+
label: 'slider',
|
|
64
|
+
value: '',
|
|
65
|
+
properties,
|
|
66
|
+
styles: {},
|
|
67
|
+
order: 0,
|
|
68
|
+
hidden: false,
|
|
69
|
+
blocks: ['a', 'b', 'c', 'd', 'e', 'f'].map(slide)
|
|
70
|
+
},
|
|
71
|
+
placeholderId: 'home',
|
|
72
|
+
sectionId: 'sec',
|
|
73
|
+
isDesigner: false,
|
|
74
|
+
...over
|
|
75
|
+
} as any);
|
|
76
|
+
|
|
77
|
+
const postsProps = (over: Record<string, unknown> = {}) =>
|
|
78
|
+
({
|
|
79
|
+
section: {
|
|
80
|
+
id: 'complete-the-look',
|
|
81
|
+
type: 'posts-slider',
|
|
82
|
+
label: 'strip',
|
|
83
|
+
properties: { 'items-per-slide': { desktop: 4, tablet: 3, mobile: 2 } },
|
|
84
|
+
styles: {},
|
|
85
|
+
blocks: ['p', 'q', 'r', 's', 't'].map(slide)
|
|
86
|
+
},
|
|
87
|
+
placeholderId: 'home',
|
|
88
|
+
isDesigner: false,
|
|
89
|
+
...over
|
|
90
|
+
} as any);
|
|
91
|
+
|
|
92
|
+
/** The server's markup, parsed so it can be queried the way a browser would. */
|
|
93
|
+
const serverDom = (element: React.ReactElement): HTMLElement => {
|
|
94
|
+
const host = document.createElement('div');
|
|
95
|
+
host.innerHTML = renderToString(element);
|
|
96
|
+
return host;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const slides = (root: ParentNode) =>
|
|
100
|
+
root.querySelectorAll('ul.react-multi-carousel-track > li');
|
|
101
|
+
|
|
102
|
+
describe('the server emits an <li> per slide — it emitted none', () => {
|
|
103
|
+
it('does so for the slider block', () => {
|
|
104
|
+
expect(slides(serverDom(<SliderBlock {...sliderProps()} />))).toHaveLength(
|
|
105
|
+
6
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does so for the posts slider section', () => {
|
|
110
|
+
expect(
|
|
111
|
+
slides(serverDom(<PostsSliderSection {...postsProps()} />))
|
|
112
|
+
).toHaveLength(5);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('sizes each of them, rather than leaving the track to collapse', () => {
|
|
116
|
+
const dom = serverDom(<SliderBlock {...sliderProps()} />);
|
|
117
|
+
// Four per view is the block's default, so the library's own SSR basis.
|
|
118
|
+
slides(dom).forEach((item) => {
|
|
119
|
+
expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('the style, the gate and the element the selector targets', () => {
|
|
125
|
+
/**
|
|
126
|
+
* The point of resolving the selector against the real markup rather than
|
|
127
|
+
* comparing two strings: a rule can be emitted perfectly and still match
|
|
128
|
+
* nothing, and nothing about the output says so.
|
|
129
|
+
*/
|
|
130
|
+
const assertSheetReachesItsTrack = (dom: HTMLElement) => {
|
|
131
|
+
const sheet = dom.querySelector('style');
|
|
132
|
+
expect(sheet).not.toBeNull();
|
|
133
|
+
|
|
134
|
+
const selector = (sheet as HTMLStyleElement).textContent!.split('{')[0];
|
|
135
|
+
expect(selector).toContain(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}=`);
|
|
136
|
+
|
|
137
|
+
const matched = dom.querySelector(selector);
|
|
138
|
+
expect(matched).not.toBeNull();
|
|
139
|
+
expect(matched).toBe(dom.querySelector('ul.react-multi-carousel-track'));
|
|
140
|
+
expect((matched as HTMLElement).className).toContain(
|
|
141
|
+
'react-multi-carousel-track'
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
it('resolves to the real track in the slider block', () => {
|
|
146
|
+
assertSheetReachesItsTrack(serverDom(<SliderBlock {...sliderProps()} />));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('resolves to the real track in the posts slider section', () => {
|
|
150
|
+
assertSheetReachesItsTrack(
|
|
151
|
+
serverDom(<PostsSliderSection {...postsProps()} />)
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('carries the gate on an ancestor and the scope on the carousel itself', () => {
|
|
156
|
+
const dom = serverDom(<SliderBlock {...sliderProps()} />);
|
|
157
|
+
|
|
158
|
+
const gated = dom.querySelector(
|
|
159
|
+
`[${CAROUSEL_SSR_GATE_ATTRIBUTE}="featured-brands"]`
|
|
160
|
+
);
|
|
161
|
+
const scoped = dom.querySelector(
|
|
162
|
+
`.${CAROUSEL_SSR_SCOPE_PREFIX}featured-brands`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(gated).not.toBeNull();
|
|
166
|
+
expect(scoped).not.toBeNull();
|
|
167
|
+
expect(gated!.contains(scoped!)).toBe(true);
|
|
168
|
+
expect(scoped!.className).toContain('react-multi-carousel-list');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps the scope class on the carousel, not the gate', () => {
|
|
172
|
+
// The scope is what stops a slider nested in another slider's slide being
|
|
173
|
+
// restyled by its parent's item count, so it must not vanish with the gate.
|
|
174
|
+
const dom = serverDom(<PostsSliderSection {...postsProps()} />);
|
|
175
|
+
const scoped = dom.querySelector(
|
|
176
|
+
`.${CAROUSEL_SSR_SCOPE_PREFIX}complete-the-look`
|
|
177
|
+
);
|
|
178
|
+
expect(scoped!.className).toContain('posts-slider-carousel');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('the client agrees with the server, then lets go', () => {
|
|
183
|
+
const hydrationErrors = (element: React.ReactElement) => {
|
|
184
|
+
const html = renderToString(element);
|
|
185
|
+
const host = document.createElement('div');
|
|
186
|
+
host.innerHTML = html;
|
|
187
|
+
document.body.appendChild(host);
|
|
188
|
+
|
|
189
|
+
const seen: string[] = [];
|
|
190
|
+
const spy = jest
|
|
191
|
+
.spyOn(console, 'error')
|
|
192
|
+
.mockImplementation((...args: unknown[]) => {
|
|
193
|
+
seen.push(args.map(String).join(' '));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
197
|
+
act(() => {
|
|
198
|
+
root = hydrateRoot(host, element);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
spy.mockRestore();
|
|
202
|
+
return {
|
|
203
|
+
host,
|
|
204
|
+
mismatches: seen.filter((message) =>
|
|
205
|
+
/hydrat|did not match/i.test(message)
|
|
206
|
+
),
|
|
207
|
+
unmount: () => act(() => root.unmount())
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
it('hydrates the slider block with no mismatch', () => {
|
|
212
|
+
const { mismatches, unmount } = hydrationErrors(
|
|
213
|
+
<SliderBlock {...sliderProps()} />
|
|
214
|
+
);
|
|
215
|
+
expect(mismatches).toEqual([]);
|
|
216
|
+
unmount();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('hydrates the posts slider section with no mismatch', () => {
|
|
220
|
+
const { mismatches, unmount } = hydrationErrors(
|
|
221
|
+
<PostsSliderSection {...postsProps()} />
|
|
222
|
+
);
|
|
223
|
+
expect(mismatches).toEqual([]);
|
|
224
|
+
unmount();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('drops the gate and its stylesheet once mounted', () => {
|
|
228
|
+
const { host, unmount } = hydrationErrors(
|
|
229
|
+
<SliderBlock {...sliderProps()} />
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(host.querySelector(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}]`)).toBeNull();
|
|
233
|
+
expect(host.querySelector('style')).toBeNull();
|
|
234
|
+
// The scope class is not part of the gate and stays.
|
|
235
|
+
expect(
|
|
236
|
+
host.querySelector(`.${CAROUSEL_SSR_SCOPE_PREFIX}featured-brands`)
|
|
237
|
+
).not.toBeNull();
|
|
238
|
+
|
|
239
|
+
unmount();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('brings the gate back when the carousel remounts', () => {
|
|
243
|
+
/**
|
|
244
|
+
* `posts-slider-section` puts `currentBreakpoint` in its carousel key, so
|
|
245
|
+
* switching device in the editor REMOUNTS the library — back to
|
|
246
|
+
* `domLoaded: false`, back on the SSR path — and the gated sheet has to
|
|
247
|
+
* come back with it or the canvas paints desktop-basis slides for a frame.
|
|
248
|
+
*
|
|
249
|
+
* Asserted on the hook rather than on the DOM because the window being
|
|
250
|
+
* pinned is one RENDER long: `rerender` wraps the update in `act`, which
|
|
251
|
+
* flushes the effect that closes the gate before any query could see it
|
|
252
|
+
* open. So the renders themselves are recorded. Both call sites hand the
|
|
253
|
+
* hook the same `carouselKey` const they hand `<Carousel key>`, which is
|
|
254
|
+
* what makes this lifecycle theirs.
|
|
255
|
+
*/
|
|
256
|
+
const gatedPerRender: boolean[] = [];
|
|
257
|
+
const responsive = {
|
|
258
|
+
desktop: { breakpoint: { max: 3000, min: DESKTOP_MIN_WIDTH }, items: 4 }
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const Probe = ({ remountKey }: { remountKey: string }) => {
|
|
262
|
+
const ssr = useCarouselSsr({ id: 'strip', remountKey, responsive });
|
|
263
|
+
gatedPerRender.push(ssr.gateStyle !== null);
|
|
264
|
+
return null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const { rerender } = render(<Probe remountKey="4-4-desktop" />);
|
|
268
|
+
expect(gatedPerRender).toEqual([true, false]);
|
|
269
|
+
|
|
270
|
+
rerender(<Probe remountKey="4-4-mobile" />);
|
|
271
|
+
expect(gatedPerRender).toEqual([true, false, true, false]);
|
|
272
|
+
|
|
273
|
+
// A re-render that does not change the key does not reopen it.
|
|
274
|
+
rerender(<Probe remountKey="4-4-mobile" />);
|
|
275
|
+
expect(gatedPerRender).toEqual([true, false, true, false, false]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('deviceType is a constant, never the currentBreakpoint prop', () => {
|
|
280
|
+
/**
|
|
281
|
+
* Asserted through the width the library writes on each slide rather than
|
|
282
|
+
* through the prop, because the prop is only the mechanism. The block's
|
|
283
|
+
* default is 4 items on desktop and 1 on mobile; if `currentBreakpoint` were
|
|
284
|
+
* handed to `deviceType`, a mobile render would size its slides 100.0% and a
|
|
285
|
+
* desktop one 25.0%, and the two would disagree across hydration.
|
|
286
|
+
*/
|
|
287
|
+
it.each(['desktop', 'tablet', 'mobile'])(
|
|
288
|
+
'sizes slides from the desktop band at currentBreakpoint=%s',
|
|
289
|
+
(breakpoint) => {
|
|
290
|
+
const dom = serverDom(
|
|
291
|
+
<SliderBlock {...sliderProps({}, { currentBreakpoint: breakpoint })} />
|
|
292
|
+
);
|
|
293
|
+
expect(slides(dom).length).toBe(6);
|
|
294
|
+
slides(dom).forEach((item) => {
|
|
295
|
+
expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
it('survives a breakpoint the responsive map has no band for', () => {
|
|
301
|
+
/**
|
|
302
|
+
* `slider-block`'s map has `desktop` and `mobile` and nothing else. With
|
|
303
|
+
* `currentBreakpoint` passed as `deviceType`, a slide peek sends the
|
|
304
|
+
* library into `getPartialVisibilityGutter`, which reads
|
|
305
|
+
* `responsive['tablet'].paritialVisibilityGutter` with no null check and
|
|
306
|
+
* throws — a blank section, in the editor, on a tablet.
|
|
307
|
+
*/
|
|
308
|
+
expect(() =>
|
|
309
|
+
serverDom(
|
|
310
|
+
<SliderBlock
|
|
311
|
+
{...sliderProps(
|
|
312
|
+
{ 'slide-peek': { desktop: 40, mobile: 24 } },
|
|
313
|
+
{ currentBreakpoint: 'tablet' }
|
|
314
|
+
)}
|
|
315
|
+
/>
|
|
316
|
+
)
|
|
317
|
+
).not.toThrow();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* THE OTHER CALL SITE, and the one a maintainer is likelier to "fix": its
|
|
322
|
+
* `<Carousel key>` already carries `currentBreakpoint`, so handing the same
|
|
323
|
+
* prop to `deviceType` looks like tidying. Its map has three bands with
|
|
324
|
+
* distinct counts, so the desktop basis is the only one that can be right on
|
|
325
|
+
* the server whatever the client thinks it is looking at.
|
|
326
|
+
*/
|
|
327
|
+
it.each(['desktop', 'tablet', 'mobile'])(
|
|
328
|
+
'sizes the posts strip from the desktop band at currentBreakpoint=%s',
|
|
329
|
+
(breakpoint) => {
|
|
330
|
+
const dom = serverDom(
|
|
331
|
+
<PostsSliderSection
|
|
332
|
+
{...postsProps({ currentBreakpoint: breakpoint })}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
expect(slides(dom).length).toBe(5);
|
|
336
|
+
slides(dom).forEach((item) => {
|
|
337
|
+
expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('a carousel with no id stays off the SSR path', () => {
|
|
344
|
+
/**
|
|
345
|
+
* The width rule is scoped by the id, so without one it cannot be written —
|
|
346
|
+
* and opting into the library's SSR path WITHOUT that rule is worse than not
|
|
347
|
+
* opting in at all: the track is a flex item with an indefinite width, so
|
|
348
|
+
* the percentage basis collapses the strip. Two id-less sliders sharing a
|
|
349
|
+
* literal fallback would additionally restyle each other's slides with the
|
|
350
|
+
* wrong item count. Rendering nothing is the honest failure.
|
|
351
|
+
*/
|
|
352
|
+
it('emits no slides, no gate attribute and no stylesheet', () => {
|
|
353
|
+
const withId = sliderProps();
|
|
354
|
+
const withoutId = {
|
|
355
|
+
...withId,
|
|
356
|
+
// The same block in every other respect — six slides included — so a
|
|
357
|
+
// zero here can only come from the missing id.
|
|
358
|
+
block: { ...withId.block, id: '' }
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
expect(slides(serverDom(<SliderBlock {...withId} />)).length).toBe(6);
|
|
362
|
+
|
|
363
|
+
const dom = serverDom(<SliderBlock {...(withoutId as any)} />);
|
|
364
|
+
|
|
365
|
+
expect(slides(dom).length).toBe(0);
|
|
366
|
+
expect(dom.querySelector('[data-carousel-ssr]')).toBeNull();
|
|
367
|
+
expect(dom.querySelector('style')).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('the gated sheet switches where the responsive map switches', () => {
|
|
372
|
+
it('hands the 768-1023 band the mobile item count, not the desktop one', () => {
|
|
373
|
+
// The map used to say `desktop: { min: 768 }` while every other reader of
|
|
374
|
+
// this block drew the line at 1024. A sheet written against the old number
|
|
375
|
+
// would have applied the mobile count from 768 up while the mounted library
|
|
376
|
+
// applied the desktop count from 768 up — visible resize at hydration.
|
|
377
|
+
const sheet = serverDom(
|
|
378
|
+
<SliderBlock
|
|
379
|
+
{...sliderProps({ 'items-per-slide': { desktop: 4, mobile: 2 } })}
|
|
380
|
+
/>
|
|
381
|
+
).querySelector('style')!.textContent!;
|
|
382
|
+
|
|
383
|
+
expect(sheet).toContain(`@media (max-width:${DESKTOP_MIN_WIDTH - 1}px)`);
|
|
384
|
+
expect(sheet).toContain('flex:1 0 50.0% !important;');
|
|
385
|
+
expect(sheet).not.toContain('max-width:767px');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -448,3 +448,92 @@ describe('VideoBlock', () => {
|
|
|
448
448
|
});
|
|
449
449
|
});
|
|
450
450
|
});
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The editor stores every video property PER BREAKPOINT — `{ desktop: false }`,
|
|
454
|
+
* not `false`. Two of the reads here compared that object against a scalar and
|
|
455
|
+
* so could never see the merchant's answer.
|
|
456
|
+
*
|
|
457
|
+
* It went unnoticed because nothing painted a video before hydration. With the
|
|
458
|
+
* slides now server-rendered (ZERO-5000), a cold load paints a full-width
|
|
459
|
+
* native control bar under the header on a video the merchant switched controls
|
|
460
|
+
* off for.
|
|
461
|
+
*/
|
|
462
|
+
describe('properties the editor stores per breakpoint', () => {
|
|
463
|
+
const renderVideo = (properties: any, currentBreakpoint = 'desktop') =>
|
|
464
|
+
render(
|
|
465
|
+
<VideoBlock
|
|
466
|
+
block={
|
|
467
|
+
{ value: 'https://cdn.example.com/lookbook.mp4', properties } as any
|
|
468
|
+
}
|
|
469
|
+
placeholderId="p1"
|
|
470
|
+
sectionId="s1"
|
|
471
|
+
isDesigner={false}
|
|
472
|
+
currentBreakpoint={currentBreakpoint}
|
|
473
|
+
/>
|
|
474
|
+
).container.querySelector('video') as HTMLVideoElement;
|
|
475
|
+
|
|
476
|
+
it('reads controls off the breakpoint, not off the object', () => {
|
|
477
|
+
// `properties.controls !== false` was true here: an object is not `false`.
|
|
478
|
+
expect(renderVideo({ controls: { desktop: false } }).controls).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('still defaults controls on when the merchant set nothing', () => {
|
|
482
|
+
expect(renderVideo({}).controls).toBe(true);
|
|
483
|
+
expect(renderVideo({ controls: { desktop: true } }).controls).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('lets a merchant hide controls on the phone and keep them on desktop', () => {
|
|
487
|
+
const authored = { controls: { desktop: true, mobile: false } };
|
|
488
|
+
expect(renderVideo(authored, 'desktop').controls).toBe(true);
|
|
489
|
+
expect(renderVideo(authored, 'mobile').controls).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('keeps honouring a plain boolean, which is what older JSON holds', () => {
|
|
493
|
+
expect(renderVideo({ controls: false }).controls).toBe(false);
|
|
494
|
+
expect(renderVideo({ controls: true }).controls).toBe(true);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('reaches a poster the merchant typed, instead of dropping it', () => {
|
|
498
|
+
// `typeof properties.poster === 'string'` is false for the object the
|
|
499
|
+
// editor writes, so the poster was stored, shown in the panel and lost.
|
|
500
|
+
const poster = renderVideo({
|
|
501
|
+
poster: { desktop: 'https://cdn.example.com/cover.jpg' }
|
|
502
|
+
}).poster;
|
|
503
|
+
expect(poster).toContain('cover.jpg');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('falls back to the desktop poster on a breakpoint that authored none', () => {
|
|
507
|
+
expect(
|
|
508
|
+
renderVideo(
|
|
509
|
+
{ poster: { desktop: 'https://cdn.example.com/cover.jpg' } },
|
|
510
|
+
'mobile'
|
|
511
|
+
).poster
|
|
512
|
+
).toContain('cover.jpg');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('sets no poster when there is none, rather than an empty one', () => {
|
|
516
|
+
expect(renderVideo({}).getAttribute('poster')).toBeNull();
|
|
517
|
+
expect(
|
|
518
|
+
renderVideo({ poster: { desktop: '' } }).getAttribute('poster')
|
|
519
|
+
).toBeNull();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('leaves autoplay, loop and muted exactly as they behave today', () => {
|
|
523
|
+
/**
|
|
524
|
+
* Their stored value is an object too, and an object is truthy — so the
|
|
525
|
+
* bug is what makes them true. Reading them correctly would switch
|
|
526
|
+
* autoplay and loop OFF on every composition already published, which is a
|
|
527
|
+
* separate decision. This locks the current behaviour so the change cannot
|
|
528
|
+
* arrive by accident alongside the two above.
|
|
529
|
+
*/
|
|
530
|
+
const video = renderVideo({
|
|
531
|
+
autoplay: { desktop: false },
|
|
532
|
+
loop: { desktop: false },
|
|
533
|
+
muted: { desktop: false }
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
expect(video.autoplay).toBe(true);
|
|
537
|
+
expect(video.loop).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
});
|