From 7bb47d1672d59432b9ee407f472003d6ba73f77b Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Mon, 2 Mar 2026 17:21:52 +0600 Subject: [PATCH 01/17] fix: resolve slide translation and Block Tree selection issues with wide alignment --- .../carousel/useEmblaQueryLoopObserver.ts | 57 +++++++ src/blocks/carousel/useEmblaResizeObserver.ts | 36 ++++ src/blocks/carousel/viewport/edit.tsx | 159 ++++++++++++------ 3 files changed, 196 insertions(+), 56 deletions(-) create mode 100644 src/blocks/carousel/useEmblaQueryLoopObserver.ts create mode 100644 src/blocks/carousel/useEmblaResizeObserver.ts diff --git a/src/blocks/carousel/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/useEmblaQueryLoopObserver.ts new file mode 100644 index 0000000..15ee6a2 --- /dev/null +++ b/src/blocks/carousel/useEmblaQueryLoopObserver.ts @@ -0,0 +1,57 @@ +import { useEffect } from '@wordpress/element'; + +/** + * Watches for Query Loop (`.wp-block-post-template`) mutations inside the + * viewport and calls the latest `initEmbla` function when detected. + * + * `initEmblaRef` is a ref so the MutationObserver callback always reads the + * latest function without needing to tear down and re-subscribe whenever + * `carouselOptions` changes. + */ +export function useEmblaQueryLoopObserver( + viewportEl: HTMLDivElement | null, + initEmblaRef: React.RefObject<( () => void ) | undefined>, +) { + useEffect( () => { + if ( ! viewportEl ) { + return; + } + + const mutationObserver = new MutationObserver( ( mutations ) => { + let shouldReInit = false; + + for ( const mutation of mutations ) { + const target = mutation.target as HTMLElement; + + if ( target.classList.contains( 'wp-block-post-template' ) ) { + shouldReInit = true; + break; + } + + if ( + mutation.addedNodes.length > 0 && + ( target.querySelector( '.wp-block-post-template' ) || + Array.from( mutation.addedNodes ).some( + ( node ) => + node instanceof HTMLElement && + node.classList.contains( 'wp-block-post-template' ), + ) ) + ) { + shouldReInit = true; + break; + } + } + + if ( shouldReInit ) { + setTimeout( () => initEmblaRef.current?.(), 10 ); + } + } ); + + mutationObserver.observe( viewportEl, { + childList: true, + subtree: true, + } ); + + return () => mutationObserver.disconnect(); + }, [ viewportEl, initEmblaRef ] ); +} diff --git a/src/blocks/carousel/useEmblaResizeObserver.ts b/src/blocks/carousel/useEmblaResizeObserver.ts new file mode 100644 index 0000000..337c3e1 --- /dev/null +++ b/src/blocks/carousel/useEmblaResizeObserver.ts @@ -0,0 +1,36 @@ +import { useEffect } from '@wordpress/element'; +import type { EmblaCarouselType } from 'embla-carousel'; + +const RESIZE_DEBOUNCE_MS = 200; + +export function useEmblaResizeObserver( + viewportEl: HTMLDivElement | null, + emblaRef: React.MutableRefObject, +) { + useEffect( () => { + if ( ! viewportEl ) { + return; + } + + let resizeTimer: ReturnType | undefined; + let lastWidth = viewportEl.getBoundingClientRect().width; + + const resizeObserver = new ResizeObserver( ( entries ) => { + clearTimeout( resizeTimer ); + resizeTimer = setTimeout( () => { + const newWidth = entries[ 0 ]?.contentRect.width ?? 0; + if ( Math.abs( newWidth - lastWidth ) > 1 && emblaRef.current ) { + lastWidth = newWidth; + emblaRef.current.reInit(); + } + }, RESIZE_DEBOUNCE_MS ); + } ); + + resizeObserver.observe( viewportEl ); + + return () => { + clearTimeout( resizeTimer ); + resizeObserver.disconnect(); + }; + }, [ viewportEl, emblaRef ] ); +} diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 8ff0563..d6e6608 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -10,10 +10,12 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { plus } from '@wordpress/icons'; import type { CarouselViewportAttributes } from '../types'; -import { useContext, useEffect, useRef, useCallback } from '@wordpress/element'; +import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; +import { useEmblaResizeObserver } from '../useEmblaResizeObserver'; +import { useEmblaQueryLoopObserver } from '../useEmblaQueryLoopObserver'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -34,19 +36,55 @@ export default function Edit( { }, } ); - const slideCount = useSelect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ( select ) => ( select( 'core/block-editor' ) as any ).getBlockCount( clientId ) as number, + /** + * Single store subscription for slide count, IDs, and which slide (if any) + * is currently selected — including nested child-block selection. + */ + const { slideCount, selectedSlideIndex } = useSelect( + ( select ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = select( 'core/block-editor' ) as any; + const blocks: Array<{ clientId: string }> = s.getBlocks( clientId ); + const ids = blocks.map( ( b ) => b.clientId ); + const count = ids.length; + + const selectedId: string | null = s.getSelectedBlockClientId(); + let index = -1; + if ( selectedId ) { + index = ids.indexOf( selectedId ); + if ( index === -1 ) { + const parents: string[] = s.getBlockParents( selectedId ); + for ( const parentId of parents ) { + index = ids.indexOf( parentId ); + if ( index !== -1 ) { + break; + } + } + } + } + + return { slideCount: count, selectedSlideIndex: index }; + }, [ clientId ], ); const hasSlides = slideCount > 0; const emblaRef = useRef( null ); + const emblaApiRef = useRef(); + const initEmblaRef = useRef<() => void>(); const ref = useMergeRefs( [ emblaRef, blockProps.ref ] ); const { insertBlock } = useDispatch( 'core/block-editor' ); + // viewportEl is state so it triggers hook setup after the DOM mounts. + // initEmblaRef is a ref so the MutationObserver callback always reads + // the latest init function without re-subscribing. + const [ viewportEl, setViewportEl ] = useState( null ); + + useEmblaResizeObserver( viewportEl, emblaApiRef ); + useEmblaQueryLoopObserver( viewportEl, initEmblaRef ); + const addSlide = useCallback( () => { const block = createBlock( 'carousel-kit/carousel-slide' ); insertBlock( block, undefined, clientId ); @@ -80,35 +118,59 @@ export default function Edit( { ); useEffect( () => { - const api = emblaRef.current - ? ( emblaRef.current as { [ EMBLA_KEY ]?: EmblaCarouselType } )[ EMBLA_KEY ] - : null; - if ( api ) { - setTimeout( () => api.reInit(), 10 ); + if ( emblaApiRef.current ) { + setTimeout( () => emblaApiRef.current?.reInit(), 10 ); } }, [ slideCount ] ); + /** + * Scroll Embla to the selected slide when the user picks a slide from the + * Block Tree (List View) or when a block inside a slide is selected. + * + * Deferred with rAF because Gutenberg's own scrollIntoView fires + * synchronously on selection, setting native scrollLeft on the viewport. + * Our scroll-reset listener (see main init effect) clears that, and then + * this rAF fires Embla's transform-based scroll. + */ + useEffect( () => { + if ( selectedSlideIndex < 0 ) { + return; + } + const id = requestAnimationFrame( () => { + const api = emblaApiRef.current; + if ( api && api.selectedScrollSnap() !== selectedSlideIndex ) { + api.scrollTo( selectedSlideIndex ); + } + } ); + return () => cancelAnimationFrame( id ); + }, [ selectedSlideIndex ] ); + + /** + * Core Embla initialisation effect. + * Observer logic (resize + mutation) has been moved to dedicated hooks + * to keep this effect focused on Embla lifecycle only. + */ useEffect( () => { if ( ! emblaRef.current ) { return; } - const viewportEl = emblaRef.current; + const viewport = emblaRef.current; let embla: EmblaCarouselType | undefined; - const initEmbla = () => { + const init = () => { if ( embla ) { embla.destroy(); } - const queryLoopContainer = viewportEl.querySelector( + const queryLoopContainer = viewport.querySelector( '.wp-block-post-template', ) as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any const options = carouselOptions as any; - embla = EmblaCarousel( viewportEl, { + embla = EmblaCarousel( viewport, { loop: options?.loop ?? false, dragFree: options?.dragFree ?? false, containScroll: options?.containScroll || 'trimSnaps', @@ -119,16 +181,15 @@ export default function Edit( { container: queryLoopContainer || undefined, watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. - watchResize: false, // Block toolbar appearing on selection can cause a layout shift that triggers an unwanted reInit. + watchResize: false, // Replaced by a manual debounced ResizeObserver in useEmblaResizeObserver. } ); - ( viewportEl as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; + ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; + emblaApiRef.current = embla; const onSelect = () => { - const canPrev = embla!.canScrollPrev(); - const canNext = embla!.canScrollNext(); - setCanScrollPrev( canPrev ); - setCanScrollNext( canNext ); + setCanScrollPrev( embla!.canScrollPrev() ); + setCanScrollNext( embla!.canScrollNext() ); }; embla.on( 'select', onSelect ); @@ -141,51 +202,37 @@ export default function Edit( { setEmblaApi( embla ); }; - initEmbla(); - - const observer = new MutationObserver( ( mutations ) => { - let shouldReInit = false; + // Run initial setup. + init(); - for ( const mutation of mutations ) { - const target = mutation.target as HTMLElement; + // Keep ref in sync so observer hooks always call the latest init. + initEmblaRef.current = init; - if ( target.classList.contains( 'wp-block-post-template' ) ) { - shouldReInit = true; - break; - } + // Expose viewport element to observer hooks (triggers their setup once). + setViewportEl( viewport ); - if ( - mutation.addedNodes.length > 0 && - ( target.querySelector( '.wp-block-post-template' ) || - Array.from( mutation.addedNodes ).some( - ( node ) => - node instanceof HTMLElement && - node.classList.contains( 'wp-block-post-template' ), - ) ) - ) { - shouldReInit = true; - break; - } + /** + * Prevent native scroll offsets from corrupting Embla transforms. + * Gutenberg's scrollIntoView (triggered by List View / Block Tree + * selection) sets scrollLeft/scrollTop on the overflow:hidden viewport. + * Embla assumes these are always 0, so we reset them immediately. + */ + const resetNativeScroll = () => { + if ( viewport.scrollLeft !== 0 ) { + viewport.scrollLeft = 0; } - - if ( shouldReInit ) { - setTimeout( initEmbla, 10 ); + if ( viewport.scrollTop !== 0 ) { + viewport.scrollTop = 0; } - } ); + }; - observer.observe( viewportEl, { - childList: true, - subtree: true, - } ); + viewport.addEventListener( 'scroll', resetNativeScroll ); return () => { - if ( embla ) { - embla.destroy(); - } - if ( observer ) { - observer.disconnect(); - } - delete ( viewportEl as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ]; + viewport.removeEventListener( 'scroll', resetNativeScroll ); + embla?.destroy(); + emblaApiRef.current = undefined; + delete ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ]; }; }, [ setEmblaApi, setCanScrollPrev, setCanScrollNext, carouselOptions ] ); From 4c87580513b23590e1cfa388a9a986e1a0f2ba02 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 3 Mar 2026 07:45:38 +0600 Subject: [PATCH 02/17] feat: add hooks for observing DOM mutations and resize events in carousel --- .../hooks/useEmblaQueryLoopObserver.ts | 67 +++++++++++++++++++ .../{ => hooks}/useEmblaResizeObserver.ts | 9 +++ .../carousel/useEmblaQueryLoopObserver.ts | 57 ---------------- src/blocks/carousel/viewport/edit.tsx | 28 +++++--- 4 files changed, 96 insertions(+), 65 deletions(-) create mode 100644 src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts rename src/blocks/carousel/{ => hooks}/useEmblaResizeObserver.ts (72%) delete mode 100644 src/blocks/carousel/useEmblaQueryLoopObserver.ts diff --git a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts new file mode 100644 index 0000000..49707f8 --- /dev/null +++ b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts @@ -0,0 +1,67 @@ +import { useEffect } from '@wordpress/element'; + +/** + * How long to wait after a DOM mutation before re-initialising Embla. + * Gutenberg often fires multiple mutations in quick succession as posts render, + * so we debounce to avoid re-initialising on an incomplete state. + */ +const QUERY_LOOP_DEBOUNCE_MS = 150; + +/** + * Observes DOM mutations inside the carousel viewport and re-initialises Embla + * whenever the number of slides changes. + * + * Uses a ref for `initEmbla` so the observer always calls the latest version + * without needing to re-subscribe when carousel options change. + */ +export function useEmblaQueryLoopObserver( + viewportEl: HTMLDivElement | null, + initEmblaRef: React.RefObject< ( () => void ) | undefined >, +) { + useEffect( () => { + if ( ! viewportEl ) { + return; + } + + let lastSlideCount = 0; + let debounceTimer: ReturnType< typeof setTimeout > | undefined; + + const syncIfChanged = () => { + const postTemplate = viewportEl.querySelector( + '.wp-block-post-template', + ); + const currentCount = postTemplate + ? postTemplate.children.length + : 0; + + if ( currentCount !== lastSlideCount && currentCount > 0 ) { + lastSlideCount = currentCount; + initEmblaRef.current?.(); + } + }; + + const mutationObserver = new MutationObserver( () => { + // Debounce to handle Gutenberg's rapid successive mutations. + clearTimeout( debounceTimer ); + debounceTimer = setTimeout( syncIfChanged, QUERY_LOOP_DEBOUNCE_MS ); + } ); + + mutationObserver.observe( viewportEl, { + childList: true, + subtree: true, + } ); + + // Seed the initial count so the first mutation doesn't trigger a spurious reInit. + const initialTemplate = viewportEl.querySelector( + '.wp-block-post-template', + ); + lastSlideCount = initialTemplate + ? initialTemplate.children.length + : 0; + + return () => { + clearTimeout( debounceTimer ); + mutationObserver.disconnect(); + }; + }, [ viewportEl, initEmblaRef ] ); +} diff --git a/src/blocks/carousel/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts similarity index 72% rename from src/blocks/carousel/useEmblaResizeObserver.ts rename to src/blocks/carousel/hooks/useEmblaResizeObserver.ts index 337c3e1..b3f5774 100644 --- a/src/blocks/carousel/useEmblaResizeObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts @@ -1,8 +1,16 @@ import { useEffect } from '@wordpress/element'; import type { EmblaCarouselType } from 'embla-carousel'; +/** + * How long to wait after a resize event before re-initialising Embla. + * Debouncing avoids unnecessary reInits during continuous resize gestures. + */ const RESIZE_DEBOUNCE_MS = 200; +/** + * Observes width changes on the carousel viewport and re-initialises Embla + * when a meaningful resize is detected (more than 1px change). + */ export function useEmblaResizeObserver( viewportEl: HTMLDivElement | null, emblaRef: React.MutableRefObject, @@ -16,6 +24,7 @@ export function useEmblaResizeObserver( let lastWidth = viewportEl.getBoundingClientRect().width; const resizeObserver = new ResizeObserver( ( entries ) => { + // Debounce to avoid reInit on every pixel change during a resize. clearTimeout( resizeTimer ); resizeTimer = setTimeout( () => { const newWidth = entries[ 0 ]?.contentRect.width ?? 0; diff --git a/src/blocks/carousel/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/useEmblaQueryLoopObserver.ts deleted file mode 100644 index 15ee6a2..0000000 --- a/src/blocks/carousel/useEmblaQueryLoopObserver.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect } from '@wordpress/element'; - -/** - * Watches for Query Loop (`.wp-block-post-template`) mutations inside the - * viewport and calls the latest `initEmbla` function when detected. - * - * `initEmblaRef` is a ref so the MutationObserver callback always reads the - * latest function without needing to tear down and re-subscribe whenever - * `carouselOptions` changes. - */ -export function useEmblaQueryLoopObserver( - viewportEl: HTMLDivElement | null, - initEmblaRef: React.RefObject<( () => void ) | undefined>, -) { - useEffect( () => { - if ( ! viewportEl ) { - return; - } - - const mutationObserver = new MutationObserver( ( mutations ) => { - let shouldReInit = false; - - for ( const mutation of mutations ) { - const target = mutation.target as HTMLElement; - - if ( target.classList.contains( 'wp-block-post-template' ) ) { - shouldReInit = true; - break; - } - - if ( - mutation.addedNodes.length > 0 && - ( target.querySelector( '.wp-block-post-template' ) || - Array.from( mutation.addedNodes ).some( - ( node ) => - node instanceof HTMLElement && - node.classList.contains( 'wp-block-post-template' ), - ) ) - ) { - shouldReInit = true; - break; - } - } - - if ( shouldReInit ) { - setTimeout( () => initEmblaRef.current?.(), 10 ); - } - } ); - - mutationObserver.observe( viewportEl, { - childList: true, - subtree: true, - } ); - - return () => mutationObserver.disconnect(); - }, [ viewportEl, initEmblaRef ] ); -} diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index d6e6608..8f21b53 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -14,11 +14,19 @@ import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; -import { useEmblaResizeObserver } from '../useEmblaResizeObserver'; -import { useEmblaQueryLoopObserver } from '../useEmblaQueryLoopObserver'; +import { useEmblaResizeObserver } from '../hooks/useEmblaResizeObserver'; +import { useEmblaQueryLoopObserver } from '../hooks/useEmblaQueryLoopObserver'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); +/** + * Delay before re-measuring Embla after initial mount. + * Wide/Full alignment CSS and the editor sidebar may not have settled + * when `init()` first runs, so we defer a `reInit()` to pick up the + * final viewport width. + */ +const LAYOUT_SETTLE_MS = 150; + export default function Edit( { clientId, }: { @@ -54,11 +62,9 @@ export default function Edit( { index = ids.indexOf( selectedId ); if ( index === -1 ) { const parents: string[] = s.getBlockParents( selectedId ); - for ( const parentId of parents ) { - index = ids.indexOf( parentId ); - if ( index !== -1 ) { - break; - } + const parentSlideId = parents.find( ( id ) => ids.includes( id ) ); + if ( parentSlideId ) { + index = ids.indexOf( parentSlideId ); } } } @@ -119,7 +125,8 @@ export default function Edit( { useEffect( () => { if ( emblaApiRef.current ) { - setTimeout( () => emblaApiRef.current?.reInit(), 10 ); + // Defer until after React's commit phase so the new slide DOM is ready. + setTimeout( () => emblaApiRef.current?.reInit(), 0 ); } }, [ slideCount ] ); @@ -205,6 +212,10 @@ export default function Edit( { // Run initial setup. init(); + // Re-measure once the editor layout has stabilised (e.g. Wide/Full + // alignment CSS may not have been applied yet when init() ran). + const layoutTimer = setTimeout( () => embla?.reInit(), LAYOUT_SETTLE_MS ); + // Keep ref in sync so observer hooks always call the latest init. initEmblaRef.current = init; @@ -229,6 +240,7 @@ export default function Edit( { viewport.addEventListener( 'scroll', resetNativeScroll ); return () => { + clearTimeout( layoutTimer ); viewport.removeEventListener( 'scroll', resetNativeScroll ); embla?.destroy(); emblaApiRef.current = undefined; From c71406914f7b68b5a56acc1e9f31dda82060a61a Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 3 Mar 2026 07:45:54 +0600 Subject: [PATCH 03/17] fix: adjust full and wide alignment styles for carousel container --- src/blocks/carousel/styles/_core.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss index e17eea1..9928dea 100644 --- a/src/blocks/carousel/styles/_core.scss +++ b/src/blocks/carousel/styles/_core.scss @@ -4,6 +4,11 @@ --carousel-kit-slide-width: 100%; } +/* Full and Wide alignments require to break out of the container */ +:where(.carousel-kit.alignfull) { + width: 100vw; +} + :where(.carousel-kit) .embla { overflow: hidden; } From 022d87c8c49ee2ff22cdc92ae2bc3a61ab575c01 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Wed, 4 Mar 2026 12:16:06 +0600 Subject: [PATCH 04/17] fix: clarify comment on full alignment breaking out of container --- src/blocks/carousel/styles/_core.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss index 9928dea..80603f0 100644 --- a/src/blocks/carousel/styles/_core.scss +++ b/src/blocks/carousel/styles/_core.scss @@ -4,7 +4,7 @@ --carousel-kit-slide-width: 100%; } -/* Full and Wide alignments require to break out of the container */ +/* Full alignment needs to break out of the container */ :where(.carousel-kit.alignfull) { width: 100vw; } From 8fdb47b0be5c942b7e125e53c281e926e4572354 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 15:17:52 +0600 Subject: [PATCH 05/17] fix: refactor slide count change detection logic in useEmblaQueryLoopObserver --- src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts index 49707f8..ee4d262 100644 --- a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts @@ -34,8 +34,10 @@ export function useEmblaQueryLoopObserver( ? postTemplate.children.length : 0; - if ( currentCount !== lastSlideCount && currentCount > 0 ) { - lastSlideCount = currentCount; + const changed = currentCount !== lastSlideCount; + lastSlideCount = currentCount; + + if ( changed && currentCount > 0 ) { initEmblaRef.current?.(); } }; From e0c9f732968ba2b71acc9249a50b74417149d31e Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 17:00:26 +0600 Subject: [PATCH 06/17] fix: improve slide count detection and optimize viewport scroll handling --- src/blocks/carousel/viewport/edit.tsx | 70 ++++++++++++++------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 8f21b53..098781a 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -19,14 +19,6 @@ import { useEmblaQueryLoopObserver } from '../hooks/useEmblaQueryLoopObserver'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); -/** - * Delay before re-measuring Embla after initial mount. - * Wide/Full alignment CSS and the editor sidebar may not have settled - * when `init()` first runs, so we defer a `reInit()` to pick up the - * final viewport width. - */ -const LAYOUT_SETTLE_MS = 150; - export default function Edit( { clientId, }: { @@ -51,20 +43,20 @@ export default function Edit( { const { slideCount, selectedSlideIndex } = useSelect( ( select ) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const s = select( 'core/block-editor' ) as any; - const blocks: Array<{ clientId: string }> = s.getBlocks( clientId ); - const ids = blocks.map( ( b ) => b.clientId ); - const count = ids.length; + const blockEditor = select( 'core/block-editor' ) as any; + const childBlocks: Array<{ clientId: string }> = blockEditor.getBlocks( clientId ); + const slideClientIds = childBlocks.map( ( block ) => block.clientId ); + const count = slideClientIds.length; - const selectedId: string | null = s.getSelectedBlockClientId(); + const selectedBlockId: string | null = blockEditor.getSelectedBlockClientId(); let index = -1; - if ( selectedId ) { - index = ids.indexOf( selectedId ); + if ( selectedBlockId ) { + index = slideClientIds.indexOf( selectedBlockId ); if ( index === -1 ) { - const parents: string[] = s.getBlockParents( selectedId ); - const parentSlideId = parents.find( ( id ) => ids.includes( id ) ); + const ancestorIds: string[] = blockEditor.getBlockParents( selectedBlockId ); + const parentSlideId = ancestorIds.find( ( id ) => slideClientIds.includes( id ) ); if ( parentSlideId ) { - index = ids.indexOf( parentSlideId ); + index = slideClientIds.indexOf( parentSlideId ); } } } @@ -79,7 +71,14 @@ export default function Edit( { const emblaRef = useRef( null ); const emblaApiRef = useRef(); const initEmblaRef = useRef<() => void>(); - const ref = useMergeRefs( [ emblaRef, blockProps.ref ] ); + + // Callback ref to set viewportEl exactly once on mount, avoiding extra + // render cycles when useEffect re-runs due to carouselOptions changes. + const viewportCallbackRef = useCallback( ( node: HTMLDivElement | null ) => { + setViewportEl( node ); + }, [] ); + + const ref = useMergeRefs( [ emblaRef, blockProps.ref, viewportCallbackRef ] ); const { insertBlock } = useDispatch( 'core/block-editor' ); @@ -212,35 +211,40 @@ export default function Edit( { // Run initial setup. init(); - // Re-measure once the editor layout has stabilised (e.g. Wide/Full - // alignment CSS may not have been applied yet when init() ran). - const layoutTimer = setTimeout( () => embla?.reInit(), LAYOUT_SETTLE_MS ); - // Keep ref in sync so observer hooks always call the latest init. initEmblaRef.current = init; - // Expose viewport element to observer hooks (triggers their setup once). - setViewportEl( viewport ); - /** * Prevent native scroll offsets from corrupting Embla transforms. * Gutenberg's scrollIntoView (triggered by List View / Block Tree * selection) sets scrollLeft/scrollTop on the overflow:hidden viewport. * Embla assumes these are always 0, so we reset them immediately. + * + * Uses a passive listener and defers DOM writes to rAF to avoid + * blocking the compositor thread and forcing synchronous reflow. */ + let scrollResetRafId: number | undefined; const resetNativeScroll = () => { - if ( viewport.scrollLeft !== 0 ) { - viewport.scrollLeft = 0; - } - if ( viewport.scrollTop !== 0 ) { - viewport.scrollTop = 0; + if ( scrollResetRafId ) { + return; // Already scheduled } + scrollResetRafId = requestAnimationFrame( () => { + scrollResetRafId = undefined; + if ( viewport.scrollLeft !== 0 ) { + viewport.scrollLeft = 0; + } + if ( viewport.scrollTop !== 0 ) { + viewport.scrollTop = 0; + } + } ); }; - viewport.addEventListener( 'scroll', resetNativeScroll ); + viewport.addEventListener( 'scroll', resetNativeScroll, { passive: true } ); return () => { - clearTimeout( layoutTimer ); + if ( scrollResetRafId ) { + cancelAnimationFrame( scrollResetRafId ); + } viewport.removeEventListener( 'scroll', resetNativeScroll ); embla?.destroy(); emblaApiRef.current = undefined; From 63f428eac3d626ad4197a71c232bcfce0c0e31df Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 17:44:41 +0600 Subject: [PATCH 07/17] fix: add BlockEditorSelectors interface for improved type safety in edit component --- src/blocks/carousel/types.ts | 10 ++++++++++ src/blocks/carousel/viewport/edit.tsx | 11 +++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index fe34731..c59eba4 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -24,6 +24,16 @@ export type CarouselSlideAttributes = Record; export type CarouselControlsAttributes = Record; export type CarouselDotsAttributes = Record; +/** + * Typed subset of the block editor store selectors used in this plugin. + * This avoids `as any` casts while keeping dot-notation and type safety. + */ +export interface BlockEditorSelectors { + getBlocks: ( clientId: string ) => Array<{ clientId: string }>; + getSelectedBlockClientId: () => string | null; + getBlockParents: ( clientId: string ) => string[]; +} + export type CarouselContext = { options: EmblaOptionsType & { slidesToScroll?: number | 'auto'; diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 098781a..d57b26b 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -9,7 +9,7 @@ import { createBlock } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { plus } from '@wordpress/icons'; -import type { CarouselViewportAttributes } from '../types'; +import type { CarouselViewportAttributes, BlockEditorSelectors } from '../types'; import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; @@ -42,18 +42,17 @@ export default function Edit( { */ const { slideCount, selectedSlideIndex } = useSelect( ( select ) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const blockEditor = select( 'core/block-editor' ) as any; - const childBlocks: Array<{ clientId: string }> = blockEditor.getBlocks( clientId ); + const blockEditor = select( 'core/block-editor' ) as BlockEditorSelectors; + const childBlocks = blockEditor.getBlocks( clientId ); const slideClientIds = childBlocks.map( ( block ) => block.clientId ); const count = slideClientIds.length; - const selectedBlockId: string | null = blockEditor.getSelectedBlockClientId(); + const selectedBlockId = blockEditor.getSelectedBlockClientId(); let index = -1; if ( selectedBlockId ) { index = slideClientIds.indexOf( selectedBlockId ); if ( index === -1 ) { - const ancestorIds: string[] = blockEditor.getBlockParents( selectedBlockId ); + const ancestorIds = blockEditor.getBlockParents( selectedBlockId ); const parentSlideId = ancestorIds.find( ( id ) => slideClientIds.includes( id ) ); if ( parentSlideId ) { index = slideClientIds.indexOf( parentSlideId ); From 7598dc96ec5d3d07fccada1f67c64f58ad7a585c Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 17:53:03 +0600 Subject: [PATCH 08/17] fix: ensure viewportEl is set only when node is not null to avoid state updates during unmount --- src/blocks/carousel/viewport/edit.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index d57b26b..b1aba44 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -71,10 +71,11 @@ export default function Edit( { const emblaApiRef = useRef(); const initEmblaRef = useRef<() => void>(); - // Callback ref to set viewportEl exactly once on mount, avoiding extra - // render cycles when useEffect re-runs due to carouselOptions changes. + // Set viewportEl once on mount. Skips null to avoid state updates during unmount. const viewportCallbackRef = useCallback( ( node: HTMLDivElement | null ) => { - setViewportEl( node ); + if ( node ) { + setViewportEl( node ); + } }, [] ); const ref = useMergeRefs( [ emblaRef, blockProps.ref, viewportCallbackRef ] ); From 403721f2fe9b8cfbb583e888b54e3cc347e1bbf7 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 18:49:52 +0600 Subject: [PATCH 09/17] fix: add box-sizing property to ensure no horizontal scroll bar in editor --- src/blocks/carousel/editor.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss index bcb99fa..f70e3a1 100644 --- a/src/blocks/carousel/editor.scss +++ b/src/blocks/carousel/editor.scss @@ -48,6 +48,7 @@ /* Ensure selectable area */ padding: 0.625rem; border: 1px dashed #ccc; + box-sizing: border-box; &.is-selected { border-color: var(--wp-admin-theme-color); From 71ac3f014285b9e62465de62abd690b0592064a6 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 18:55:25 +0600 Subject: [PATCH 10/17] fix: improve resize observer logic to prevent unnecessary reinitializations --- .../carousel/hooks/useEmblaResizeObserver.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts index b3f5774..c6d93b7 100644 --- a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts @@ -21,17 +21,29 @@ export function useEmblaResizeObserver( } let resizeTimer: ReturnType | undefined; - let lastWidth = viewportEl.getBoundingClientRect().width; + let lastWidth: number | null = null; const resizeObserver = new ResizeObserver( ( entries ) => { - // Debounce to avoid reInit on every pixel change during a resize. + const newWidth = entries[ 0 ]?.contentRect.width ?? 0; + const previousWidth = lastWidth; + + // Always track the latest width to avoid drift from accumulated small changes. + lastWidth = newWidth; + + // Skip the first observation — just establish the baseline. + if ( previousWidth === null ) { + return; + } + + // Only reInit if change exceeds threshold. + if ( Math.abs( newWidth - previousWidth ) <= 1 ) { + return; + } + + // Debounce to batch rapid resize events. clearTimeout( resizeTimer ); resizeTimer = setTimeout( () => { - const newWidth = entries[ 0 ]?.contentRect.width ?? 0; - if ( Math.abs( newWidth - lastWidth ) > 1 && emblaRef.current ) { - lastWidth = newWidth; - emblaRef.current.reInit(); - } + emblaRef.current?.reInit(); }, RESIZE_DEBOUNCE_MS ); } ); From 1df925c38b7b75ebde91fdfe0bb11d8cb67f8ead Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Tue, 10 Mar 2026 19:03:00 +0600 Subject: [PATCH 11/17] fix: refactor viewportEl state management to prevent unnecessary reinitializations --- src/blocks/carousel/viewport/edit.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index b1aba44..326c0f6 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -71,6 +71,11 @@ export default function Edit( { const emblaApiRef = useRef(); const initEmblaRef = useRef<() => void>(); + // viewportEl is state so it triggers hook setup after the DOM mounts. + // initEmblaRef is a ref so the MutationObserver callback always reads + // the latest init function without re-subscribing. + const [ viewportEl, setViewportEl ] = useState( null ); + // Set viewportEl once on mount. Skips null to avoid state updates during unmount. const viewportCallbackRef = useCallback( ( node: HTMLDivElement | null ) => { if ( node ) { @@ -82,11 +87,6 @@ export default function Edit( { const { insertBlock } = useDispatch( 'core/block-editor' ); - // viewportEl is state so it triggers hook setup after the DOM mounts. - // initEmblaRef is a ref so the MutationObserver callback always reads - // the latest init function without re-subscribing. - const [ viewportEl, setViewportEl ] = useState( null ); - useEmblaResizeObserver( viewportEl, emblaApiRef ); useEmblaQueryLoopObserver( viewportEl, initEmblaRef ); @@ -123,10 +123,12 @@ export default function Edit( { ); useEffect( () => { - if ( emblaApiRef.current ) { - // Defer until after React's commit phase so the new slide DOM is ready. - setTimeout( () => emblaApiRef.current?.reInit(), 0 ); + if ( ! emblaApiRef.current ) { + return; } + // Defer until after React's commit phase so the new slide DOM is ready. + const timerId = setTimeout( () => emblaApiRef.current?.reInit(), 0 ); + return () => clearTimeout( timerId ); }, [ slideCount ] ); /** From cf6541ae105e6ca16f4cf83c32748bfab94c6bf0 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Wed, 11 Mar 2026 12:00:27 +0600 Subject: [PATCH 12/17] fix: update documentation for Embla observers to clarify initialization logic --- src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts | 5 +++++ src/blocks/carousel/hooks/useEmblaResizeObserver.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts index ee4d262..b6c0deb 100644 --- a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts @@ -11,6 +11,11 @@ const QUERY_LOOP_DEBOUNCE_MS = 150; * Observes DOM mutations inside the carousel viewport and re-initialises Embla * whenever the number of slides changes. * + * Uses a full destroy/recreate via `initEmblaRef` (not just `reInit()`) because + * Query Loop changes can alter the container DOM structure. Embla caches + * references to the container and slide elements, so when the `.wp-block-post-template` + * is replaced or its children change, a fresh Embla instance is required. + * * Uses a ref for `initEmbla` so the observer always calls the latest version * without needing to re-subscribe when carousel options change. */ diff --git a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts index c6d93b7..91762f3 100644 --- a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts @@ -10,6 +10,10 @@ const RESIZE_DEBOUNCE_MS = 200; /** * Observes width changes on the carousel viewport and re-initialises Embla * when a meaningful resize is detected (more than 1px change). + * + * Uses Embla's non-destructive `reInit()` because resize only affects + * measurements and scroll positions — the DOM structure remains unchanged. + * This is more efficient than a full destroy/recreate cycle. */ export function useEmblaResizeObserver( viewportEl: HTMLDivElement | null, From c1d48a2b3f50c2f73ccb1a9f7678831f01b6f9c1 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Wed, 11 Mar 2026 14:04:44 +0600 Subject: [PATCH 13/17] fix: enhance resize observer to track column size changes --- .../hooks/useEmblaQueryLoopObserver.ts | 3 + .../carousel/hooks/useEmblaResizeObserver.ts | 94 +++++++++++++++---- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts index b6c0deb..b951d65 100644 --- a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts @@ -18,6 +18,9 @@ const QUERY_LOOP_DEBOUNCE_MS = 150; * * Uses a ref for `initEmbla` so the observer always calls the latest version * without needing to re-subscribe when carousel options change. + * + * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe + * @param {React.RefObject<(() => void) | undefined>} initEmblaRef - Ref to the init function for full Embla recreate */ export function useEmblaQueryLoopObserver( viewportEl: HTMLDivElement | null, diff --git a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts index 91762f3..e47f995 100644 --- a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts @@ -8,12 +8,23 @@ import type { EmblaCarouselType } from 'embla-carousel'; const RESIZE_DEBOUNCE_MS = 200; /** - * Observes width changes on the carousel viewport and re-initialises Embla - * when a meaningful resize is detected (more than 1px change). + * How long to wait after a DOM mutation before checking for new slides. + * Shorter than resize debounce since we're just checking element existence. + */ +const MUTATION_DEBOUNCE_MS = 50; + +/** + * Observes width changes on both the viewport and the first slide, and + * re-initialises Embla when a meaningful resize is detected (>1px change). + * + * - Viewport width changes occur on alignment changes (wide/full/none) + * - Slide width changes occur on column style changes (2/3/4 columns) * * Uses Embla's non-destructive `reInit()` because resize only affects * measurements and scroll positions — the DOM structure remains unchanged. - * This is more efficient than a full destroy/recreate cycle. + * + * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe + * @param {React.MutableRefObject} emblaRef - Ref to the Embla instance for calling reInit() */ export function useEmblaResizeObserver( viewportEl: HTMLDivElement | null, @@ -25,37 +36,80 @@ export function useEmblaResizeObserver( } let resizeTimer: ReturnType | undefined; - let lastWidth: number | null = null; + let mutationTimer: ReturnType | undefined; + // Track widths per element to detect meaningful changes + const lastWidths = new WeakMap(); const resizeObserver = new ResizeObserver( ( entries ) => { - const newWidth = entries[ 0 ]?.contentRect.width ?? 0; - const previousWidth = lastWidth; + let shouldReInit = false; - // Always track the latest width to avoid drift from accumulated small changes. - lastWidth = newWidth; + // Process ALL entries to keep width tracking accurate. + // We observe at most 2 elements (viewport + first slide), so this is trivially cheap. + for ( const entry of entries ) { + const el = entry.target; + const newWidth = entry.contentRect.width; + const previousWidth = lastWidths.get( el ); - // Skip the first observation — just establish the baseline. - if ( previousWidth === null ) { - return; - } + // Always track the latest width. + lastWidths.set( el, newWidth ); + + // Skip first observation for this element — establish baseline. + if ( previousWidth === undefined ) { + continue; + } - // Only reInit if change exceeds threshold. - if ( Math.abs( newWidth - previousWidth ) <= 1 ) { - return; + // Trigger reInit if any observed element changed significantly. + if ( Math.abs( newWidth - previousWidth ) > 1 ) { + shouldReInit = true; + } } - // Debounce to batch rapid resize events. - clearTimeout( resizeTimer ); - resizeTimer = setTimeout( () => { - emblaRef.current?.reInit(); - }, RESIZE_DEBOUNCE_MS ); + if ( shouldReInit ) { + // Debounce to batch rapid resize events. + clearTimeout( resizeTimer ); + resizeTimer = setTimeout( () => { + emblaRef.current?.reInit(); + }, RESIZE_DEBOUNCE_MS ); + } } ); + // Observe viewport for alignment changes (wide/full/none) resizeObserver.observe( viewportEl ); + // Track which slide we're currently observing to avoid duplicate observations + let observedSlide: Element | null = null; + + // Observe first slide for column style changes (2/3/4 columns) + const observeFirstSlide = () => { + const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); + const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ); + + // Only re-observe if the first slide changed + if ( firstSlide && firstSlide !== observedSlide ) { + observedSlide = firstSlide; + resizeObserver.observe( firstSlide ); + } + }; + + // Initial observation + observeFirstSlide(); + + // Re-observe when DOM changes. Use subtree:true to catch Query Loop + // rendering posts asynchronously (the .wp-block-post-template may not + // exist at initial setup). Debounced to avoid excessive calls from + // Gutenberg's frequent DOM mutations (typing, block UI updates, etc.). + const mutationObserver = new MutationObserver( () => { + clearTimeout( mutationTimer ); + mutationTimer = setTimeout( observeFirstSlide, MUTATION_DEBOUNCE_MS ); + } ); + + mutationObserver.observe( viewportEl, { childList: true, subtree: true } ); + return () => { clearTimeout( resizeTimer ); + clearTimeout( mutationTimer ); resizeObserver.disconnect(); + mutationObserver.disconnect(); }; }, [ viewportEl, emblaRef ] ); } From 976e923d662f7073645993a113fdd86f5935cf9f Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Wed, 11 Mar 2026 14:28:56 +0600 Subject: [PATCH 14/17] fix: improve first slide observation logic to prevent unnecessary re-observations --- .../carousel/hooks/useEmblaResizeObserver.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts index e47f995..9bfcd8c 100644 --- a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts +++ b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts @@ -82,12 +82,24 @@ export function useEmblaResizeObserver( // Observe first slide for column style changes (2/3/4 columns) const observeFirstSlide = () => { const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); - const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ); + const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null; - // Only re-observe if the first slide changed - if ( firstSlide && firstSlide !== observedSlide ) { + // If the first slide hasn't changed, there's nothing to do. + if ( firstSlide === observedSlide ) { + return; + } + + // If we were observing a previous slide, stop observing it. + if ( observedSlide ) { + resizeObserver.unobserve( observedSlide ); + } + + // If a new first slide exists, start observing it; otherwise clear the reference. + if ( firstSlide ) { observedSlide = firstSlide; resizeObserver.observe( firstSlide ); + } else { + observedSlide = null; } }; From 6e726b2ec6dbbf917d126de55e1848b81ea32c7b Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Thu, 12 Mar 2026 10:32:25 +0600 Subject: [PATCH 15/17] fix: consolidate resize and mutation observers into a unified hook for improved carousel init --- .../carousel/hooks/useCarouselObservers.ts | 150 ++++++++++++++++++ .../hooks/useEmblaQueryLoopObserver.ts | 77 --------- .../carousel/hooks/useEmblaResizeObserver.ts | 127 --------------- src/blocks/carousel/viewport/edit.tsx | 6 +- 4 files changed, 152 insertions(+), 208 deletions(-) create mode 100644 src/blocks/carousel/hooks/useCarouselObservers.ts delete mode 100644 src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts delete mode 100644 src/blocks/carousel/hooks/useEmblaResizeObserver.ts diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts new file mode 100644 index 0000000..1588818 --- /dev/null +++ b/src/blocks/carousel/hooks/useCarouselObservers.ts @@ -0,0 +1,150 @@ +import { useEffect } from '@wordpress/element'; +import type { EmblaCarouselType } from 'embla-carousel'; + +const RESIZE_DEBOUNCE_MS = 200; +const MUTATION_DEBOUNCE_MS = 150; + +/** + * Unified observer hook that handles both resize detection and Query Loop + * DOM mutations through a single coordinated MutationObserver. + * + * **Resize detection** (viewport + first slide width changes): + * Uses `reInit()` because resize only affects measurements — the DOM structure + * (container + slides) remains unchanged, so Embla's cached references stay valid. + * + * **Query Loop detection** (slide count changes): + * Uses full destroy/recreate via `initEmblaRef` because Query Loop changes can + * replace the `.wp-block-post-template` element or swap out its children entirely. + * Embla caches references to container and slide elements, so when those DOM + * nodes are replaced, a fresh instance is required. + * + * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe + * @param {React.MutableRefObject} emblaRef - Ref to the Embla instance for calling reInit() + * @param {React.RefObject<(() => void) | undefined>} initEmblaRef - Ref to the init function for full Embla recreate + */ +export function useCarouselObservers( + viewportEl: HTMLDivElement | null, + emblaRef: React.MutableRefObject, + initEmblaRef: React.RefObject<( () => void ) | undefined>, +) { + useEffect( () => { + if ( ! viewportEl ) { + return; + } + + let resizeTimer: ReturnType | undefined; + let mutationTimer: ReturnType | undefined; + + // When a full init is in progress, suppress any resize-triggered reInits + // that fire due to DOM churn during the init itself. + let fullInitPending = false; + + const lastWidths = new WeakMap(); + let lastSlideCount = 0; + let observedSlide: Element | null = null; + + const resizeObserver = new ResizeObserver( ( entries ) => { + if ( fullInitPending ) { + return; + } + + let shouldReInit = false; + + for ( const entry of entries ) { + const el = entry.target; + const newWidth = entry.contentRect.width; + const previousWidth = lastWidths.get( el ); + + lastWidths.set( el, newWidth ); + + // Skip first observation — establish baseline width. + if ( previousWidth === undefined ) { + continue; + } + + if ( Math.abs( newWidth - previousWidth ) > 1 ) { + shouldReInit = true; + } + } + + if ( shouldReInit ) { + clearTimeout( resizeTimer ); + resizeTimer = setTimeout( () => { + emblaRef.current?.reInit(); + }, RESIZE_DEBOUNCE_MS ); + } + } ); + + resizeObserver.observe( viewportEl ); + + const updateSlideObservation = () => { + const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); + const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null; + + if ( firstSlide === observedSlide ) { + return; + } + + if ( observedSlide ) { + resizeObserver.unobserve( observedSlide ); + } + + if ( firstSlide ) { + observedSlide = firstSlide; + resizeObserver.observe( firstSlide ); + } else { + observedSlide = null; + } + }; + + const checkQueryLoopChanges = (): boolean => { + const postTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + const currentCount = postTemplate ? postTemplate.children.length : 0; + + const changed = currentCount !== lastSlideCount; + lastSlideCount = currentCount; + + return changed && currentCount > 0; + }; + + const processMutations = () => { + const needsFullInit = checkQueryLoopChanges(); + + if ( needsFullInit ) { + clearTimeout( resizeTimer ); + fullInitPending = true; + + initEmblaRef.current?.(); + + // Keep the flag set for the full resize debounce window so any + // ResizeObserver callbacks from the init DOM churn are suppressed. + // Reuses resizeTimer so the cleanup in the return handles it automatically. + resizeTimer = setTimeout( () => { + fullInitPending = false; + }, RESIZE_DEBOUNCE_MS ); + } + + updateSlideObservation(); + }; + + const mutationObserver = new MutationObserver( () => { + clearTimeout( mutationTimer ); + mutationTimer = setTimeout( processMutations, MUTATION_DEBOUNCE_MS ); + } ); + + mutationObserver.observe( viewportEl, { childList: true, subtree: true } ); + + // Seed the initial slide count so the first mutation doesn't trigger a spurious init. + const initialTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + lastSlideCount = initialTemplate ? initialTemplate.children.length : 0; + + updateSlideObservation(); + + return () => { + clearTimeout( resizeTimer ); + clearTimeout( mutationTimer ); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [ viewportEl, emblaRef, initEmblaRef ] ); +} diff --git a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts b/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts deleted file mode 100644 index b951d65..0000000 --- a/src/blocks/carousel/hooks/useEmblaQueryLoopObserver.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useEffect } from '@wordpress/element'; - -/** - * How long to wait after a DOM mutation before re-initialising Embla. - * Gutenberg often fires multiple mutations in quick succession as posts render, - * so we debounce to avoid re-initialising on an incomplete state. - */ -const QUERY_LOOP_DEBOUNCE_MS = 150; - -/** - * Observes DOM mutations inside the carousel viewport and re-initialises Embla - * whenever the number of slides changes. - * - * Uses a full destroy/recreate via `initEmblaRef` (not just `reInit()`) because - * Query Loop changes can alter the container DOM structure. Embla caches - * references to the container and slide elements, so when the `.wp-block-post-template` - * is replaced or its children change, a fresh Embla instance is required. - * - * Uses a ref for `initEmbla` so the observer always calls the latest version - * without needing to re-subscribe when carousel options change. - * - * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe - * @param {React.RefObject<(() => void) | undefined>} initEmblaRef - Ref to the init function for full Embla recreate - */ -export function useEmblaQueryLoopObserver( - viewportEl: HTMLDivElement | null, - initEmblaRef: React.RefObject< ( () => void ) | undefined >, -) { - useEffect( () => { - if ( ! viewportEl ) { - return; - } - - let lastSlideCount = 0; - let debounceTimer: ReturnType< typeof setTimeout > | undefined; - - const syncIfChanged = () => { - const postTemplate = viewportEl.querySelector( - '.wp-block-post-template', - ); - const currentCount = postTemplate - ? postTemplate.children.length - : 0; - - const changed = currentCount !== lastSlideCount; - lastSlideCount = currentCount; - - if ( changed && currentCount > 0 ) { - initEmblaRef.current?.(); - } - }; - - const mutationObserver = new MutationObserver( () => { - // Debounce to handle Gutenberg's rapid successive mutations. - clearTimeout( debounceTimer ); - debounceTimer = setTimeout( syncIfChanged, QUERY_LOOP_DEBOUNCE_MS ); - } ); - - mutationObserver.observe( viewportEl, { - childList: true, - subtree: true, - } ); - - // Seed the initial count so the first mutation doesn't trigger a spurious reInit. - const initialTemplate = viewportEl.querySelector( - '.wp-block-post-template', - ); - lastSlideCount = initialTemplate - ? initialTemplate.children.length - : 0; - - return () => { - clearTimeout( debounceTimer ); - mutationObserver.disconnect(); - }; - }, [ viewportEl, initEmblaRef ] ); -} diff --git a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts b/src/blocks/carousel/hooks/useEmblaResizeObserver.ts deleted file mode 100644 index 9bfcd8c..0000000 --- a/src/blocks/carousel/hooks/useEmblaResizeObserver.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect } from '@wordpress/element'; -import type { EmblaCarouselType } from 'embla-carousel'; - -/** - * How long to wait after a resize event before re-initialising Embla. - * Debouncing avoids unnecessary reInits during continuous resize gestures. - */ -const RESIZE_DEBOUNCE_MS = 200; - -/** - * How long to wait after a DOM mutation before checking for new slides. - * Shorter than resize debounce since we're just checking element existence. - */ -const MUTATION_DEBOUNCE_MS = 50; - -/** - * Observes width changes on both the viewport and the first slide, and - * re-initialises Embla when a meaningful resize is detected (>1px change). - * - * - Viewport width changes occur on alignment changes (wide/full/none) - * - Slide width changes occur on column style changes (2/3/4 columns) - * - * Uses Embla's non-destructive `reInit()` because resize only affects - * measurements and scroll positions — the DOM structure remains unchanged. - * - * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe - * @param {React.MutableRefObject} emblaRef - Ref to the Embla instance for calling reInit() - */ -export function useEmblaResizeObserver( - viewportEl: HTMLDivElement | null, - emblaRef: React.MutableRefObject, -) { - useEffect( () => { - if ( ! viewportEl ) { - return; - } - - let resizeTimer: ReturnType | undefined; - let mutationTimer: ReturnType | undefined; - // Track widths per element to detect meaningful changes - const lastWidths = new WeakMap(); - - const resizeObserver = new ResizeObserver( ( entries ) => { - let shouldReInit = false; - - // Process ALL entries to keep width tracking accurate. - // We observe at most 2 elements (viewport + first slide), so this is trivially cheap. - for ( const entry of entries ) { - const el = entry.target; - const newWidth = entry.contentRect.width; - const previousWidth = lastWidths.get( el ); - - // Always track the latest width. - lastWidths.set( el, newWidth ); - - // Skip first observation for this element — establish baseline. - if ( previousWidth === undefined ) { - continue; - } - - // Trigger reInit if any observed element changed significantly. - if ( Math.abs( newWidth - previousWidth ) > 1 ) { - shouldReInit = true; - } - } - - if ( shouldReInit ) { - // Debounce to batch rapid resize events. - clearTimeout( resizeTimer ); - resizeTimer = setTimeout( () => { - emblaRef.current?.reInit(); - }, RESIZE_DEBOUNCE_MS ); - } - } ); - - // Observe viewport for alignment changes (wide/full/none) - resizeObserver.observe( viewportEl ); - - // Track which slide we're currently observing to avoid duplicate observations - let observedSlide: Element | null = null; - - // Observe first slide for column style changes (2/3/4 columns) - const observeFirstSlide = () => { - const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); - const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null; - - // If the first slide hasn't changed, there's nothing to do. - if ( firstSlide === observedSlide ) { - return; - } - - // If we were observing a previous slide, stop observing it. - if ( observedSlide ) { - resizeObserver.unobserve( observedSlide ); - } - - // If a new first slide exists, start observing it; otherwise clear the reference. - if ( firstSlide ) { - observedSlide = firstSlide; - resizeObserver.observe( firstSlide ); - } else { - observedSlide = null; - } - }; - - // Initial observation - observeFirstSlide(); - - // Re-observe when DOM changes. Use subtree:true to catch Query Loop - // rendering posts asynchronously (the .wp-block-post-template may not - // exist at initial setup). Debounced to avoid excessive calls from - // Gutenberg's frequent DOM mutations (typing, block UI updates, etc.). - const mutationObserver = new MutationObserver( () => { - clearTimeout( mutationTimer ); - mutationTimer = setTimeout( observeFirstSlide, MUTATION_DEBOUNCE_MS ); - } ); - - mutationObserver.observe( viewportEl, { childList: true, subtree: true } ); - - return () => { - clearTimeout( resizeTimer ); - clearTimeout( mutationTimer ); - resizeObserver.disconnect(); - mutationObserver.disconnect(); - }; - }, [ viewportEl, emblaRef ] ); -} diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 326c0f6..1eee81f 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -14,8 +14,7 @@ import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; -import { useEmblaResizeObserver } from '../hooks/useEmblaResizeObserver'; -import { useEmblaQueryLoopObserver } from '../hooks/useEmblaQueryLoopObserver'; +import { useCarouselObservers } from '../hooks/useCarouselObservers'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -87,8 +86,7 @@ export default function Edit( { const { insertBlock } = useDispatch( 'core/block-editor' ); - useEmblaResizeObserver( viewportEl, emblaApiRef ); - useEmblaQueryLoopObserver( viewportEl, initEmblaRef ); + useCarouselObservers( viewportEl, emblaApiRef, initEmblaRef ); const addSlide = useCallback( () => { const block = createBlock( 'carousel-kit/carousel-slide' ); From d4af80c074df8f90353389c2a6e634f70396ff01 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Thu, 12 Mar 2026 10:43:42 +0600 Subject: [PATCH 16/17] fix: update comment to reflect change from manual debounced ResizeObserver in useCarouselObservers --- src/blocks/carousel/viewport/edit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 1eee81f..f584c06 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -187,7 +187,7 @@ export default function Edit( { container: queryLoopContainer || undefined, watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. - watchResize: false, // Replaced by a manual debounced ResizeObserver in useEmblaResizeObserver. + watchResize: false, // Replaced by a manual debounced ResizeObserver in useCarouselObservers. } ); ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; From f6bc32d55b00f5cfa7496f804d7e99eb36a54fb1 Mon Sep 17 00:00:00 2001 From: Masud Rana Date: Thu, 12 Mar 2026 13:00:50 +0600 Subject: [PATCH 17/17] fix: handle empty template case by destroying Embla instance to prevent stale references --- src/blocks/carousel/hooks/useCarouselObservers.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts index 1588818..7cfef8d 100644 --- a/src/blocks/carousel/hooks/useCarouselObservers.ts +++ b/src/blocks/carousel/hooks/useCarouselObservers.ts @@ -104,6 +104,13 @@ export function useCarouselObservers( const changed = currentCount !== lastSlideCount; lastSlideCount = currentCount; + if ( changed && currentCount === 0 ) { + // Template removed or emptied — destroy to avoid stale references. + emblaRef.current?.destroy(); + emblaRef.current = undefined; + return false; + } + return changed && currentCount > 0; };