From 089a88fa98dd835f349b169d8d09b90b605226f2 Mon Sep 17 00:00:00 2001 From: bfintal Date: Sat, 11 Jul 2026 13:17:52 +0800 Subject: [PATCH 1/6] fix(editor): improve Safari responsive preview performance Pool editor DOM class observers into a shared onClassChange utility with rAF batching to avoid MutationObserver feedback loops when switching device preview. Replace per-image ResizeObservers with CSS container queries for narrow resizer tooltip hiding. Fixes #2269 Co-authored-by: Cursor --- .stylelintrc.js | 19 +++- src/block-components/image/editor.scss | 16 ++- src/block-components/image/image.js | 17 --- .../editor-device-preview-class/index.js | 31 +----- .../color-schemes/editor-loader.js | 6 +- .../utils/block-layout-utils.js | 22 ---- .../utils/use-block-layout-editor-loader.js | 7 +- .../theme-block-style-inheritance/index.js | 28 +---- src/util/index.js | 1 + src/util/on-class-change.js | 101 ++++++++++++++++++ 10 files changed, 146 insertions(+), 102 deletions(-) create mode 100644 src/util/on-class-change.js diff --git a/.stylelintrc.js b/.stylelintrc.js index 4f84ca8829..9d88b8ae47 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -36,6 +36,23 @@ module.exports = { "selector-class-pattern": null, "value-keyword-case": null, "length-zero-no-unit": null, - "font-family-no-missing-generic-family-keyword": null + "font-family-no-missing-generic-family-keyword": null, + "property-no-unknown": [ + true, + { + ignoreProperties: [ + "container-type", + "container-name", + ], + }, + ], + "scss/at-rule-no-unknown": [ + true, + { + ignoreAtRules: [ + "container", + ], + }, + ], } } diff --git a/src/block-components/image/editor.scss b/src/block-components/image/editor.scss index 2feb34262c..e0992197c6 100644 --- a/src/block-components/image/editor.scss +++ b/src/block-components/image/editor.scss @@ -84,6 +84,17 @@ } } +// Hide the size tooltip when the image resizer is too narrow. +.stk-img-resizer { + container-type: inline-size; +} + +@container (max-width: 139px) { + .stk-resizer-tooltip { + display: none !important; + } +} + .stk-img-resizer-tooltip { background: rgba(0, 0, 0, 0.75); border-radius: 2px; @@ -136,11 +147,6 @@ } } -// If the image its too small, don't show the tooltip. -.stk--too-small .stk-img-resizer-tooltip { - display: none !important; -} - .stk-image-size-popup { .components-popover__content { padding: 16px; diff --git a/src/block-components/image/image.js b/src/block-components/image/image.js index 7f7d98036b..6d506f142a 100644 --- a/src/block-components/image/image.js +++ b/src/block-components/image/image.js @@ -80,7 +80,6 @@ const Image = memo( props => { const [ currentHeight, setCurrentHeight ] = useState() const [ currentWidth, setCurrentWidth ] = useState() - const [ imageWidthIsTooSmall, setImageWidthIsTooSmall ] = useState( false ) const imageRef = useRef() const wrapperRef = useRef() @@ -109,24 +108,8 @@ const Image = memo( props => { 'stk--never-resized': ( ! src || hasImageError ) && neverResized, 'stk--is-resizing': isResizing, 'stk--no-click-to-edit': ! props.enableClickToEdit, - // If the image is too small, hide the size tooltip. - 'stk--too-small': imageWidthIsTooSmall, } ) - // Observe the size of the image, if it's too small, we shouldn't show the - // size tooltip. - useEffect( () => { - if ( imageRef.current ) { - const resizeObserver = new ResizeObserver( entries => { // eslint-disable-line compat/compat - for ( const entry of entries ) { - setImageWidthIsTooSmall( entry.contentRect.width < 140 ) - } - } ) - resizeObserver.observe( imageRef.current ) - return () => resizeObserver.disconnect() - } - }, [ imageRef.current ] ) - const imageClasses = getImageClasses( props ) return ( diff --git a/src/plugins/editor-device-preview-class/index.js b/src/plugins/editor-device-preview-class/index.js index 06cf13c5d6..9be71903d1 100644 --- a/src/plugins/editor-device-preview-class/index.js +++ b/src/plugins/editor-device-preview-class/index.js @@ -8,6 +8,7 @@ * External dependencies */ import { useDeviceType, useBlockHoverState } from '~stackable/hooks' +import { onClassChange } from '~stackable/util' /** * WordPress dependencies @@ -53,12 +54,12 @@ const EditorPreviewClass = () => { } // At first load of the editor, the `stk-preview-device-*` and `stk--is-*-theme` are removed, so we have to re-add it. - const mo = onClassChange( editorEl, () => { - if ( editorEl?.classList.contains( `stk-preview-device-${ deviceType.toLowerCase() }` ) === false ) { + const unsubscribe = onClassChange( editorEl, () => { + if ( editorEl.classList.contains( `stk-preview-device-${ deviceType.toLowerCase() }` ) === false ) { editorEl.classList.remove( 'stk-preview-device-desktop', 'stk-preview-device-tablet', 'stk-preview-device-mobile' ) editorEl.classList.add( `stk-preview-device-${ deviceType.toLowerCase() }` ) } - if ( editorEl?.classList.contains( `stk-preview-state--${ currentHoverState }` ) === false ) { + if ( editorEl.classList.contains( `stk-preview-state--${ currentHoverState }` ) === false ) { editorEl.classList.remove( 'stk-preview-state--normal', 'stk-preview-state--hover', 'stk-preview-state--parent-hover', 'stk-preview-state--collapsed' ) editorEl.classList.add( `stk-preview-state--${ currentHoverState }` ) } @@ -72,7 +73,7 @@ const EditorPreviewClass = () => { } } ) - return () => mo.disconnect() + return unsubscribe } }, [ editorEl, deviceType, currentHoverState ] ) @@ -82,25 +83,3 @@ const EditorPreviewClass = () => { registerPlugin( 'stackable-editor-device-preview-class', { render: EditorPreviewClass, } ) - -// Listener when a class is changed on an element. -const onClassChange = ( node, callback ) => { - let lastClassString = node.classList.toString() - - const mutationObserver = new MutationObserver( mutationList => { - for ( const item of mutationList ) { - if ( item.attributeName === 'class' ) { - const classString = node.classList.toString() - if ( classString !== lastClassString ) { - callback( mutationObserver ) - lastClassString = classString - break - } - } - } - } ) - - mutationObserver.observe( node, { attributes: true } ) - - return mutationObserver -} diff --git a/src/plugins/global-settings/color-schemes/editor-loader.js b/src/plugins/global-settings/color-schemes/editor-loader.js index 279eb374b6..471ce4477a 100644 --- a/src/plugins/global-settings/color-schemes/editor-loader.js +++ b/src/plugins/global-settings/color-schemes/editor-loader.js @@ -7,7 +7,7 @@ import { unsetDefaultColors, } from './utils' -import { onClassChange } from '../utils' +import { onClassChange } from '~stackable/util' /** * External dependencies @@ -227,11 +227,11 @@ export const GlobalColorSchemeStyles = () => { addClassNames( editorEl ) // At first load of the editor, the color scheme classnames removed, so we have to re-add it. - const mo = onClassChange( editorEl, () => { + const unsubscribe = onClassChange( editorEl, () => { addClassNames( editorEl ) } ) - return () => mo.disconnect() + return unsubscribe } }, [ editorEl, styles ] ) diff --git a/src/plugins/global-settings/utils/block-layout-utils.js b/src/plugins/global-settings/utils/block-layout-utils.js index 9fd1bd0b25..bf5c9ce273 100644 --- a/src/plugins/global-settings/utils/block-layout-utils.js +++ b/src/plugins/global-settings/utils/block-layout-utils.js @@ -39,25 +39,3 @@ export const getDefault = ( defaults, property, device ) => { return defaultValue ?? '' } - -// Listener when a class is changed on an element. -export const onClassChange = ( node, callback ) => { - let lastClassString = node.classList.toString() - - const mutationObserver = new MutationObserver( mutationList => { - for ( const item of mutationList ) { - if ( item.attributeName === 'class' ) { - const classString = node.classList.toString() - if ( classString !== lastClassString ) { - callback( mutationObserver ) - lastClassString = classString - break - } - } - } - } ) - - mutationObserver.observe( node, { attributes: true } ) - - return mutationObserver -} diff --git a/src/plugins/global-settings/utils/use-block-layout-editor-loader.js b/src/plugins/global-settings/utils/use-block-layout-editor-loader.js index 013d630219..d16844041a 100644 --- a/src/plugins/global-settings/utils/use-block-layout-editor-loader.js +++ b/src/plugins/global-settings/utils/use-block-layout-editor-loader.js @@ -1,7 +1,8 @@ /** * Internal dependencies */ -import { getDefault, onClassChange } from './block-layout-utils' +import { getDefault } from './block-layout-utils' +import { onClassChange } from '~stackable/util' /** * WordPress dependencies @@ -182,7 +183,7 @@ export const useBlockLayoutEditorLoader = ( storeName, classSuffix ) => { editorEl.classList.remove( className ) } - const mo = onClassChange( editorEl, () => { + const unsubscribe = onClassChange( editorEl, () => { if ( styles !== '' && editorEl?.classList.contains( className ) === false ) { editorEl?.classList.add( className ) addFilter( 'stackable.global-styles.classnames', `stackable/global-settings.${ classSuffix }`, classnames => { @@ -195,7 +196,7 @@ export const useBlockLayoutEditorLoader = ( storeName, classSuffix ) => { } } ) - return () => mo.disconnect() + return unsubscribe } }, [ editorEl, styles ] ) diff --git a/src/plugins/theme-block-style-inheritance/index.js b/src/plugins/theme-block-style-inheritance/index.js index 53790a4e67..73b43f2767 100644 --- a/src/plugins/theme-block-style-inheritance/index.js +++ b/src/plugins/theme-block-style-inheritance/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { fetchSettings } from '~stackable/util' +import { fetchSettings, onClassChange } from '~stackable/util' /** * WordPress dependencies @@ -45,7 +45,7 @@ const ThemeBlockStyleInheritanceClass = () => { } // At first load of the editor, the block style inheritance class is removed, so we have to re-add it. - const mo = onClassChange( editorEl, () => { + const unsubscribe = onClassChange( editorEl, () => { if ( ! disableBlockStyleInheritance && editorEl.classList.contains( `stk-has-block-style-inheritance` ) === false ) { editorEl.classList.add( `stk-has-block-style-inheritance` ) } @@ -55,7 +55,7 @@ const ThemeBlockStyleInheritanceClass = () => { } } ) - return () => mo.disconnect() + return unsubscribe } }, [ editorEl, disableBlockStyleInheritance ] ) @@ -65,25 +65,3 @@ const ThemeBlockStyleInheritanceClass = () => { registerPlugin( 'stackable-theme-block-style-inheritance-class', { render: ThemeBlockStyleInheritanceClass, } ) - -// Listener when a class is changed on an element. -const onClassChange = ( node, callback ) => { - let lastClassString = node.classList.toString() - - const mutationObserver = new MutationObserver( mutationList => { - for ( const item of mutationList ) { - if ( item.attributeName === 'class' ) { - const classString = node.classList.toString() - if ( classString !== lastClassString ) { - callback( mutationObserver ) - lastClassString = classString - break - } - } - } - } ) - - mutationObserver.observe( node, { attributes: true } ) - - return mutationObserver -} diff --git a/src/util/index.js b/src/util/index.js index c3928cca33..996accdd4c 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -16,6 +16,7 @@ export * from './fontawesome' export * from './user' export * from './colors' export * from './element' +export * from './on-class-change' export * from './block-styles' /** diff --git a/src/util/on-class-change.js b/src/util/on-class-change.js new file mode 100644 index 0000000000..ff1177164f --- /dev/null +++ b/src/util/on-class-change.js @@ -0,0 +1,101 @@ +/** + * Shared MutationObserver for class attribute changes on a DOM element. + * + * Multiple callers can subscribe via onClassChange( node, callback ) without + * each creating their own observer on the same element. + */ + +const observersByNode = new WeakMap() + +const scheduleCallbacks = ( node, state ) => { + if ( state.rafId || state.callbacks.size === 0 ) { + return + } + + state.rafId = requestAnimationFrame( () => { + state.rafId = null + + if ( state.callbacks.size === 0 ) { + return + } + + const classString = node.classList.toString() + if ( classString === state.lastClassString ) { + return + } + + state.lastClassString = classString + + // Prevent our own classList writes from re-triggering the observer synchronously. + state.mutationObserver.disconnect() + + for ( const callback of state.callbacks ) { + callback() + } + + state.lastClassString = node.classList.toString() + state.mutationObserver.observe( node, { attributes: true } ) + } ) +} + +const getOrCreateState = node => { + let state = observersByNode.get( node ) + + if ( ! state ) { + state = { + callbacks: new Set(), + lastClassString: node.classList.toString(), + mutationObserver: null, + rafId: null, + } + + state.mutationObserver = new MutationObserver( mutationList => { + for ( const item of mutationList ) { + if ( item.attributeName === 'class' ) { + const classString = node.classList.toString() + if ( classString !== state.lastClassString ) { + scheduleCallbacks( node, state ) + } + break + } + } + } ) + + state.mutationObserver.observe( node, { attributes: true } ) + observersByNode.set( node, state ) + } + + return state +} + +const destroyState = ( node, state ) => { + if ( state.rafId ) { + cancelAnimationFrame( state.rafId ) + } + state.mutationObserver.disconnect() + observersByNode.delete( node ) +} + +/** + * Subscribe to class changes on a DOM element. Uses one shared MutationObserver + * per element, even when called multiple times. + * + * @param {HTMLElement} node The DOM element to observe. + * @param {Function} callback Called when the element's class attribute changes. + * @return {Function} Unsubscribe function. + */ +export const onClassChange = ( node, callback ) => { + if ( ! node || typeof callback !== 'function' ) { + return () => {} + } + + const state = getOrCreateState( node ) + state.callbacks.add( callback ) + + return () => { + state.callbacks.delete( callback ) + if ( state.callbacks.size === 0 ) { + destroyState( node, state ) + } + } +} From 78d84ea408c51f9fcf01121393eae0d0de69fdab Mon Sep 17 00:00:00 2001 From: bfintal Date: Sat, 11 Jul 2026 14:16:27 +0800 Subject: [PATCH 2/6] improved unified block styles --- .../block-css/use-block-style-generator.js | 18 +- .../editor-block-css/block-style-sheets.js | 254 ++++++++++++++++++ src/plugins/editor-block-css/index.js | 154 +++++++++++ src/plugins/editor-block-css/store.js | 71 +++++ src/plugins/index.js | 1 + 5 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 src/plugins/editor-block-css/block-style-sheets.js create mode 100644 src/plugins/editor-block-css/index.js create mode 100644 src/plugins/editor-block-css/store.js diff --git a/src/components/block-css/use-block-style-generator.js b/src/components/block-css/use-block-style-generator.js index 991f73b815..2ec81687d0 100644 --- a/src/components/block-css/use-block-style-generator.js +++ b/src/components/block-css/use-block-style-generator.js @@ -1,5 +1,8 @@ import { useQueryLoopInstanceId } from '~stackable/util' -import { useMemo, useRef } from '@wordpress/element' +import { + useLayoutEffect, useMemo, useRef, +} from '@wordpress/element' +import { dispatch } from '@wordpress/data' import { useRafEffect } from '~stackable/hooks' import CssSaveCompiler from './css-save-compiler' @@ -73,5 +76,16 @@ export const useBlockCssGenerator = props => { attributes.generatedCss = saveCss }, [ attributes, version ] ) - return editCss + const styleKey = `${ clientId }-${ instanceId }` + + useLayoutEffect( () => { + dispatch( 'stackable/editor-block-css' ).setBlockCss( styleKey, editCss || '' ) + return () => { + dispatch( 'stackable/editor-block-css' ).removeBlockCss( styleKey ) + } + }, [ styleKey, editCss ] ) + + // We used to return the CSS here, but for optimization, now + // CSS is injected via the unified editor stylesheet plugin. + return null } diff --git a/src/plugins/editor-block-css/block-style-sheets.js b/src/plugins/editor-block-css/block-style-sheets.js new file mode 100644 index 0000000000..5aa3dc7d83 --- /dev/null +++ b/src/plugins/editor-block-css/block-style-sheets.js @@ -0,0 +1,254 @@ +/** + * Per-block editor styles via Constructable Stylesheets, with a per-block + *