Skip to content

Commit 521bc84

Browse files
committed
Fix race condition in pause() / resume() system for Embla autoplay plugin
1 parent 370c58c commit 521bc84

4 files changed

Lines changed: 301 additions & 54 deletions

File tree

src/components/Carousel/client/__tests__/index.spec.ts

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,38 @@ import type { WebComponentModule } from '@components/scripts/@types/webComponent
88
import { executeRender } from '@test/unit/helpers/litRuntime'
99
import sampleCollection from '@components/Carousel/client/__fixtures__/collection.fixture'
1010

11-
const createAnimationControllerMock = vi.fn(() => ({
12-
requestPlay: vi.fn(),
13-
requestPause: vi.fn(),
14-
clearUserPreference: vi.fn(),
15-
destroy: vi.fn(),
16-
}))
11+
const createAnimationControllerMock = vi.fn((config?: { onPlay?: () => void }) => {
12+
config?.onPlay?.()
13+
return {
14+
requestPlay: vi.fn(),
15+
requestPause: vi.fn(),
16+
clearUserPreference: vi.fn(),
17+
destroy: vi.fn(),
18+
}
19+
})
20+
21+
type AutoplayPluginInstance = {
22+
play: ReturnType<typeof vi.fn>
23+
stop: ReturnType<typeof vi.fn>
24+
}
25+
26+
const autoplayPluginInstances: AutoplayPluginInstance[] = []
27+
28+
const createAutoplayPluginMock = vi.fn(() => {
29+
const instance: AutoplayPluginInstance = {
30+
play: vi.fn(),
31+
stop: vi.fn(),
32+
}
33+
autoplayPluginInstances.push(instance)
34+
return instance
35+
})
36+
37+
let mockScrollSnaps = [0, 1, 2]
38+
39+
const setMockScrollSnaps = (snapCount: number) => {
40+
const count = Math.max(1, snapCount)
41+
mockScrollSnaps = Array.from({ length: count }, (_, index) => index)
42+
}
1743

1844
vi.mock('@components/scripts/store', () => ({
1945
createAnimationController: createAnimationControllerMock,
@@ -35,7 +61,7 @@ vi.mock('embla-carousel', () => {
3561
on: vi.fn(() => createEmbla.mock.results.at(-1)?.value ?? {}),
3662
off: vi.fn(() => createEmbla.mock.results.at(-1)?.value ?? {}),
3763
destroy: vi.fn(),
38-
scrollSnapList: vi.fn(() => [0, 1, 2]),
64+
scrollSnapList: vi.fn(() => mockScrollSnaps),
3965
selectedScrollSnap: vi.fn(() => 0),
4066
scrollTo: vi.fn(),
4167
}))
@@ -47,10 +73,7 @@ vi.mock('embla-carousel', () => {
4773

4874
vi.mock('embla-carousel-autoplay', () => ({
4975
__esModule: true,
50-
default: vi.fn(() => ({
51-
play: vi.fn(),
52-
stop: vi.fn(),
53-
})),
76+
default: createAutoplayPluginMock,
5477
}))
5578

5679
const defaultCarouselProps: ConcreteCarouselProps = {
@@ -90,6 +113,9 @@ describe('Carousel component (server output)', () => {
90113
beforeEach(() => {
91114
vi.clearAllMocks()
92115
createAnimationControllerMock.mockClear()
116+
autoplayPluginInstances.length = 0
117+
createAutoplayPluginMock.mockClear()
118+
setMockScrollSnaps(3)
93119
})
94120

95121
it('renders the provided title and respects the requested limit', async () => {
@@ -145,6 +171,30 @@ describe('Carousel component (server output)', () => {
145171
it('registers an animation lifecycle controller', async () => {
146172
await renderCarousel(() => {
147173
expect(createAnimationControllerMock).toHaveBeenCalled()
148-
})
174+
}, { currentSlug: 'article-four' })
175+
})
176+
177+
it('defers autoplay activation until Embla is ready', async () => {
178+
vi.useFakeTimers()
179+
try {
180+
await renderCarousel(async () => {
181+
const pluginInstance = autoplayPluginInstances.at(-1)
182+
expect(pluginInstance).toBeDefined()
183+
expect(pluginInstance?.play).not.toHaveBeenCalled()
184+
await vi.runAllTimersAsync()
185+
expect(pluginInstance?.play).toHaveBeenCalled()
186+
}, { currentSlug: 'article-four' })
187+
} finally {
188+
vi.useRealTimers()
189+
}
190+
})
191+
192+
it('skips autoplay wiring when Embla reports a single snap', async () => {
193+
setMockScrollSnaps(1)
194+
await renderCarousel(({ root }) => {
195+
expect(createAnimationControllerMock).not.toHaveBeenCalled()
196+
expect(root.getAttribute('data-carousel-autoplay')).toBe('paused')
197+
expect(autoplayPluginInstances.at(-1)?.play).not.toHaveBeenCalled()
198+
}, { currentSlug: 'article-four' })
149199
})
150200
})

src/components/Carousel/client/index.ts

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { defineCustomElement } from '@components/scripts/utils'
77
import {
88
createAnimationController,
99
type AnimationControllerHandle,
10+
type AnimationPlayState,
1011
} from '@components/scripts/store'
1112
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
1213

@@ -23,6 +24,7 @@ const AUTOPLAY_OPTIONS = {
2324
delay: 4000,
2425
stopOnInteraction: true,
2526
stopOnMouseEnter: true,
27+
playOnInit: false,
2628
}
2729

2830
type DebugEmblaElement = HTMLElement & { __emblaApi__?: EmblaCarouselType }
@@ -37,6 +39,11 @@ export class CarouselElement extends HTMLElement {
3739
private nextBtn: HTMLButtonElement | null = null
3840
private initialized = false
3941
private animationController: AnimationControllerHandle | undefined
42+
private pendingAutoplayState: AnimationPlayState | null = null
43+
private hasAutoplaySupport = false
44+
private autoplayReady = false
45+
private autoplayReadyScheduled = false
46+
private autoplayReadyTimer: ReturnType<typeof setTimeout> | null = null
4047
private readonly animationInstanceId: string
4148
private readonly domReadyHandler = () => {
4249
document.removeEventListener('DOMContentLoaded', this.domReadyHandler)
@@ -93,24 +100,37 @@ export class CarouselElement extends HTMLElement {
93100
throw new ClientScriptError('CarouselElement: Missing required DOM parts for initialization.')
94101
}
95102

96-
this.autoplayPlugin = Autoplay({ ...AUTOPLAY_OPTIONS })
97-
this.emblaApi = EmblaCarousel(this.viewport, EMBLA_OPTIONS, [this.autoplayPlugin])
103+
const slideCount = this.querySelectorAll('[data-carousel-slide]').length
104+
const requestedAutoplay = slideCount > 1 ? Autoplay({ ...AUTOPLAY_OPTIONS }) : null
105+
this.autoplayReady = false
106+
this.autoplayReadyScheduled = false
107+
const plugins = requestedAutoplay ? [requestedAutoplay] : []
108+
this.emblaApi = EmblaCarousel(this.viewport, EMBLA_OPTIONS, plugins)
98109
this.emblaRoot.__emblaApi__ = this.emblaApi
110+
const emblaSnapCount = this.emblaApi.scrollSnapList().length
111+
const supportsAutoplay = slideCount > 1 && emblaSnapCount > 1
112+
this.hasAutoplaySupport = supportsAutoplay
113+
this.autoplayPlugin = supportsAutoplay ? requestedAutoplay : null
99114
this.setAutoplayState('paused')
100115

101-
const emblaWithEvents = this.emblaApi as EmblaCarouselType & {
102-
on: (_event: string, _handler: () => void) => EmblaCarouselType
103-
}
116+
if (this.autoplayPlugin) {
117+
const emblaWithEvents = this.emblaApi as EmblaCarouselType & {
118+
on: (_event: string, _handler: () => void) => EmblaCarouselType
119+
}
104120

105-
emblaWithEvents.on('autoplay:play', this.autoplayPlayHandler)
106-
emblaWithEvents.on('autoplay:stop', this.autoplayStopHandler)
121+
emblaWithEvents.on('autoplay:play', this.autoplayPlayHandler)
122+
emblaWithEvents.on('autoplay:stop', this.autoplayStopHandler)
123+
}
107124

108125
this.setupNavigationButtons()
109126
this.setupDotsNavigation()
110127

111128
this.initialized = true
112129
this.setAttribute('data-carousel-ready', 'true')
113-
this.registerAnimationLifecycle()
130+
if (this.hasAutoplaySupport) {
131+
this.registerAnimationLifecycle()
132+
this.scheduleAutoplayReady()
133+
}
114134
} catch (error) {
115135
this.teardown()
116136
handleScriptError(error, context)
@@ -123,8 +143,10 @@ export class CarouselElement extends HTMLElement {
123143
off: (_event: string, _handler: () => void) => EmblaCarouselType
124144
}
125145

126-
emblaWithEvents.off('autoplay:play', this.autoplayPlayHandler)
127-
emblaWithEvents.off('autoplay:stop', this.autoplayStopHandler)
146+
if (this.autoplayPlugin) {
147+
emblaWithEvents.off('autoplay:play', this.autoplayPlayHandler)
148+
emblaWithEvents.off('autoplay:stop', this.autoplayStopHandler)
149+
}
128150
this.emblaApi.destroy()
129151
}
130152
if (this.emblaRoot && '__emblaApi__' in this.emblaRoot) {
@@ -138,6 +160,14 @@ export class CarouselElement extends HTMLElement {
138160
this.removeAttribute('data-carousel-autoplay')
139161
this.animationController?.destroy()
140162
this.animationController = undefined
163+
this.pendingAutoplayState = null
164+
this.hasAutoplaySupport = false
165+
if (this.autoplayReadyTimer) {
166+
clearTimeout(this.autoplayReadyTimer)
167+
this.autoplayReadyTimer = null
168+
}
169+
this.autoplayReadyScheduled = false
170+
this.autoplayReady = false
141171

142172
if (this.dotsContainer) {
143173
this.dotsContainer.innerHTML = ''
@@ -249,17 +279,15 @@ export class CarouselElement extends HTMLElement {
249279

250280
pause(): void {
251281
try {
252-
this.autoplayPlugin?.stop()
253-
this.setAutoplayState('paused')
282+
this.updateAutoplayState('paused')
254283
} catch (error) {
255284
handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'pause' })
256285
}
257286
}
258287

259288
resume(): void {
260289
try {
261-
this.autoplayPlugin?.play()
262-
this.setAutoplayState('playing')
290+
this.updateAutoplayState('playing')
263291
} catch (error) {
264292
handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'resume' })
265293
}
@@ -269,8 +297,42 @@ export class CarouselElement extends HTMLElement {
269297
this.setAttribute('data-carousel-autoplay', state)
270298
}
271299

300+
private updateAutoplayState(state: AnimationPlayState): void {
301+
if (!this.hasAutoplaySupport || !this.autoplayPlugin || !this.emblaApi || !this.initialized || !this.autoplayReady) {
302+
if (this.hasAutoplaySupport) {
303+
this.pendingAutoplayState = state
304+
this.setAutoplayState(state)
305+
} else {
306+
this.pendingAutoplayState = null
307+
this.setAutoplayState('paused')
308+
}
309+
return
310+
}
311+
312+
this.pendingAutoplayState = null
313+
if (state === 'playing') {
314+
this.autoplayPlugin.play()
315+
} else {
316+
this.autoplayPlugin.stop()
317+
}
318+
this.setAutoplayState(state)
319+
}
320+
321+
private flushPendingAutoplayState(): void {
322+
if (!this.pendingAutoplayState || !this.autoplayReady || !this.autoplayPlugin) return
323+
324+
const pendingState = this.pendingAutoplayState
325+
this.pendingAutoplayState = null
326+
327+
try {
328+
this.updateAutoplayState(pendingState)
329+
} catch (error) {
330+
handleScriptError(error, { scriptName: SCRIPT_NAME, operation: 'flushAutoplay' })
331+
}
332+
}
333+
272334
private registerAnimationLifecycle(): void {
273-
if (this.animationController) return
335+
if (this.animationController || !this.hasAutoplaySupport) return
274336
this.animationController = createAnimationController({
275337
animationId: 'carousel',
276338
instanceId: this.animationInstanceId,
@@ -283,6 +345,26 @@ export class CarouselElement extends HTMLElement {
283345
},
284346
})
285347
}
348+
349+
private scheduleAutoplayReady(): void {
350+
if (this.autoplayReadyScheduled || !this.autoplayPlugin) return
351+
this.autoplayReadyScheduled = true
352+
353+
const markReady = () => {
354+
this.autoplayReadyScheduled = false
355+
this.autoplayReadyTimer = null
356+
if (!this.initialized || !this.autoplayPlugin) return
357+
this.autoplayReady = true
358+
this.flushPendingAutoplayState()
359+
}
360+
361+
if (typeof window === 'undefined') {
362+
markReady()
363+
return
364+
}
365+
366+
this.autoplayReadyTimer = window.setTimeout(markReady, 0)
367+
}
286368
}
287369

288370
declare global {

src/components/Testimonials/client/__tests__/index.spec.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,31 @@ import type { WebComponentModule } from '@components/scripts/@types/webComponent
88
import { executeRender } from '@test/unit/helpers/litRuntime'
99
import testimonialsCollection from '@components/Testimonials/client/__fixtures__/collection.fixture'
1010

11-
const createAnimationControllerMock = vi.fn(() => ({
12-
requestPlay: vi.fn(),
13-
requestPause: vi.fn(),
14-
clearUserPreference: vi.fn(),
15-
destroy: vi.fn(),
16-
}))
11+
const createAnimationControllerMock = vi.fn((config?: { onPlay?: () => void }) => {
12+
config?.onPlay?.()
13+
return {
14+
requestPlay: vi.fn(),
15+
requestPause: vi.fn(),
16+
clearUserPreference: vi.fn(),
17+
destroy: vi.fn(),
18+
}
19+
})
20+
21+
type AutoplayPluginInstance = {
22+
play: ReturnType<typeof vi.fn>
23+
stop: ReturnType<typeof vi.fn>
24+
}
25+
26+
const autoplayPluginInstances: AutoplayPluginInstance[] = []
27+
28+
const createAutoplayPluginMock = vi.fn(() => {
29+
const instance: AutoplayPluginInstance = {
30+
play: vi.fn(),
31+
stop: vi.fn(),
32+
}
33+
autoplayPluginInstances.push(instance)
34+
return instance
35+
})
1736

1837
vi.mock('@components/scripts/store', () => ({
1938
createAnimationController: createAnimationControllerMock,
@@ -53,10 +72,7 @@ vi.mock('embla-carousel', () => {
5372

5473
vi.mock('embla-carousel-autoplay', () => ({
5574
__esModule: true,
56-
default: vi.fn(() => ({
57-
play: vi.fn(),
58-
stop: vi.fn(),
59-
})),
75+
default: createAutoplayPluginMock,
6076
}))
6177

6278
const defaultProps: TestimonialsProps = {
@@ -89,6 +105,8 @@ describe('Testimonials component', () => {
89105
beforeEach(() => {
90106
vi.clearAllMocks()
91107
createAnimationControllerMock.mockClear()
108+
autoplayPluginInstances.length = 0
109+
createAutoplayPluginMock.mockClear()
92110
})
93111

94112
it('renders the supplied title and respects the limit', async () => {
@@ -113,7 +131,7 @@ describe('Testimonials component', () => {
113131
const dots = root.querySelectorAll('.embla__dot')
114132

115133
expect(root.getAttribute('data-carousel-ready')).toBe('true')
116-
expect(root.getAttribute('data-carousel-autoplay')).toBe('paused')
134+
expect(root.getAttribute('data-carousel-autoplay')).toBe('playing')
117135
expect(dots.length).toBeGreaterThan(0)
118136
})
119137
})
@@ -123,4 +141,18 @@ describe('Testimonials component', () => {
123141
expect(createAnimationControllerMock).toHaveBeenCalled()
124142
})
125143
})
144+
145+
it('defers autoplay activation until Embla is ready', async () => {
146+
vi.useFakeTimers()
147+
try {
148+
await renderTestimonials(async () => {
149+
const pluginInstance = autoplayPluginInstances.at(-1)
150+
expect(pluginInstance?.play).not.toHaveBeenCalled()
151+
await vi.runAllTimersAsync()
152+
expect(pluginInstance?.play).toHaveBeenCalled()
153+
})
154+
} finally {
155+
vi.useRealTimers()
156+
}
157+
})
126158
})

0 commit comments

Comments
 (0)