From 66388b0609619d15117bb4167170a96c813b0427 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Tue, 26 Sep 2023 10:30:51 +0200 Subject: [PATCH] [RNMobile] Limit inner blocks nesting depth to avoid call stack size exceeded crash (#54382) * Prevent rendering deeply nested block list * Add `BlockFallbackWebVersion` component * Add `WarningMaxDepthExceeded` component * Add max depth warning in inner blocks component * Update title of max depth exceeded warning Co-authored-by: David Calhoun * Update description of max depth exceeded warning * Add constants file to `InnerBlocks` component `MAX_NESTING_DEPTH` has been moved to the constants file. * Revert `BlockFallbackWebVersion` export * Rename `BlockFallbackWebVersion` component to `UnsupportedBlockDetails` * Rename `UnsupportedBlockDetails` styles * Update default web editor action label via WP hooks * Allow inserting extra notes to description via WP hooks * Extract UBE settings fetch to a hook * Update description of max depth exceeded warning based on UBE support * Update `react-native-editor` changelog --------- Co-authored-by: David Calhoun --- .../inner-blocks/constants.native.js | 5 + .../components/inner-blocks/index.native.js | 15 ++ .../warning-max-depth-exceeded.native.js | 100 ++++++++++ .../unsupported-block-details/index.native.js | 183 ++++++++++++++++++ .../style.native.scss | 56 ++++++ .../index.native.js | 59 ++++++ packages/react-native-editor/CHANGELOG.md | 1 + 7 files changed, 419 insertions(+) create mode 100644 packages/block-editor/src/components/inner-blocks/constants.native.js create mode 100644 packages/block-editor/src/components/inner-blocks/warning-max-depth-exceeded.native.js create mode 100644 packages/block-editor/src/components/unsupported-block-details/index.native.js create mode 100644 packages/block-editor/src/components/unsupported-block-details/style.native.scss create mode 100644 packages/block-editor/src/components/use-unsupported-block-editor/index.native.js diff --git a/packages/block-editor/src/components/inner-blocks/constants.native.js b/packages/block-editor/src/components/inner-blocks/constants.native.js new file mode 100644 index 0000000000000..79962a2373b88 --- /dev/null +++ b/packages/block-editor/src/components/inner-blocks/constants.native.js @@ -0,0 +1,5 @@ +// Hermes has a limit for the call stack depth to avoid infinite recursion. +// When creating a deep nested structure of inner blocks, the editor might exceed +// this limit and crash. In order to avoid this, we set a maximum depth level where +// we stop rendering blocks. +export const MAX_NESTING_DEPTH = 10; diff --git a/packages/block-editor/src/components/inner-blocks/index.native.js b/packages/block-editor/src/components/inner-blocks/index.native.js index f07dcf4fc5305..e254eff6c9ef1 100644 --- a/packages/block-editor/src/components/inner-blocks/index.native.js +++ b/packages/block-editor/src/components/inner-blocks/index.native.js @@ -3,6 +3,7 @@ */ import { __unstableGetInnerBlocksProps as getInnerBlocksProps } from '@wordpress/blocks'; import { useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -21,6 +22,9 @@ import { useBlockEditContext } from '../block-edit/context'; import useBlockSync from '../provider/use-block-sync'; import { BlockContextProvider } from '../block-context'; import { defaultLayout, LayoutProvider } from '../block-list/layout'; +import { store as blockEditorStore } from '../../store'; +import WarningMaxDepthExceeded from './warning-max-depth-exceeded'; +import { MAX_NESTING_DEPTH } from './constants'; /** * This hook is used to lightly mark an element as an inner blocks wrapper @@ -122,6 +126,17 @@ function UncontrolledInnerBlocks( props ) { templateInsertUpdatesSelection ); + const nestingLevel = useSelect( + ( select ) => { + return select( blockEditorStore ).getBlockParents( clientId ) + ?.length; + }, + [ clientId ] + ); + if ( nestingLevel >= MAX_NESTING_DEPTH ) { + return ; + } + return ( diff --git a/packages/block-editor/src/components/inner-blocks/warning-max-depth-exceeded.native.js b/packages/block-editor/src/components/inner-blocks/warning-max-depth-exceeded.native.js new file mode 100644 index 0000000000000..57a7b7a60483c --- /dev/null +++ b/packages/block-editor/src/components/inner-blocks/warning-max-depth-exceeded.native.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { TouchableWithoutFeedback, View } from 'react-native'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import Warning from '../warning'; +import UnsupportedBlockDetails from '../unsupported-block-details'; +import { store as blockEditorStore } from '../../store'; +import { MAX_NESTING_DEPTH } from './constants'; +import useUnsupportedBlockEditor from '../use-unsupported-block-editor'; + +const WarningMaxDepthExceeded = ( { clientId } ) => { + const [ showDetails, setShowDetails ] = useState( false ); + + const { isSelected, innerBlocks } = useSelect( + ( select ) => { + const { getBlock, isBlockSelected } = select( blockEditorStore ); + return { + innerBlocks: getBlock( clientId )?.innerBlocks || [], + isSelected: isBlockSelected( clientId ), + }; + }, + [ clientId ] + ); + const { replaceBlocks } = useDispatch( blockEditorStore ); + + const { + isUnsupportedBlockEditorSupported, + canEnableUnsupportedBlockEditor, + } = useUnsupportedBlockEditor( clientId ); + + const onUngroup = () => { + if ( ! innerBlocks.length ) { + return; + } + + replaceBlocks( clientId, innerBlocks ); + }; + + let description; + // When UBE can't be used, the description mentions using the web browser to edit the block. + if ( + ! isUnsupportedBlockEditorSupported && + ! canEnableUnsupportedBlockEditor + ) { + /* translators: Warning related to having blocks deeply nested. %d: The deepest nesting level. */ + const descriptionFormat = __( + 'Blocks nested deeper than %d levels may not render properly in the mobile editor. For this reason, we recommend flattening the content by ungrouping the block or editing the block using your web browser.' + ); + description = sprintf( descriptionFormat, MAX_NESTING_DEPTH ); + } + // Otherwise, the description mentions using the web editor (i.e. UBE). + else { + /* translators: Warning related to having blocks deeply nested. %d: The deepest nesting level. */ + const descriptionFormat = __( + 'Blocks nested deeper than %d levels may not render properly in the mobile editor. For this reason, we recommend flattening the content by ungrouping the block or editing the block using the web editor.' + ); + description = sprintf( descriptionFormat, MAX_NESTING_DEPTH ); + } + + return ( + setShowDetails( true ) } + > + + + setShowDetails( false ) } + title={ __( 'Deeply nested block' ) } + description={ description } + customActions={ [ + { label: __( 'Ungroup block' ), onPress: onUngroup }, + ] } + /> + + + ); +}; + +export default WarningMaxDepthExceeded; diff --git a/packages/block-editor/src/components/unsupported-block-details/index.native.js b/packages/block-editor/src/components/unsupported-block-details/index.native.js new file mode 100644 index 0000000000000..a35d96a925676 --- /dev/null +++ b/packages/block-editor/src/components/unsupported-block-details/index.native.js @@ -0,0 +1,183 @@ +/** + * External dependencies + */ +import { View, Text } from 'react-native'; + +/** + * WordPress dependencies + */ +import { BottomSheet, Icon, TextControl } from '@wordpress/components'; +import { + requestUnsupportedBlockFallback, + sendActionButtonPressedAction, + actionButtons, +} from '@wordpress/react-native-bridge'; +import { help } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; +import { usePreferredColorSchemeStyle } from '@wordpress/compose'; +import { getBlockType } from '@wordpress/blocks'; +import { useCallback, useState } from '@wordpress/element'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import styles from './style.scss'; +import useUnsupportedBlockEditor from '../use-unsupported-block-editor'; + +const EMPTY_ARRAY = []; + +const UnsupportedBlockDetails = ( { + clientId, + showSheet, + onCloseSheet, + customBlockTitle = '', + icon, + title, + description, + actionButtonLabel, + customActions = EMPTY_ARRAY, +} ) => { + const [ sendFallbackMessage, setSendFallbackMessage ] = useState( false ); + const [ sendButtonPressMessage, setSendButtonPressMessage ] = + useState( false ); + + const { + blockName, + blockContent, + isUnsupportedBlockEditorSupported, + canEnableUnsupportedBlockEditor, + isEditableInUnsupportedBlockEditor, + } = useUnsupportedBlockEditor( clientId ); + + // Styles + const textStyle = usePreferredColorSchemeStyle( + styles[ 'unsupported-block-details__text' ], + styles[ 'unsupported-block-details__text--dark' ] + ); + const titleStyle = usePreferredColorSchemeStyle( + styles[ 'unsupported-block-details__title' ], + styles[ 'unsupported-block-details__title--dark' ] + ); + const descriptionStyle = usePreferredColorSchemeStyle( + styles[ 'unsupported-block-details__description' ], + styles[ 'unsupported-block-details__description--dark' ] + ); + const iconStyle = usePreferredColorSchemeStyle( + styles[ 'unsupported-block-details__icon' ], + styles[ 'unsupported-block-details__icon--dark' ] + ); + const actionButtonStyle = usePreferredColorSchemeStyle( + styles[ 'unsupported-block-details__action-button' ], + styles[ 'unsupported-block-details__action-button--dark' ] + ); + + const blockTitle = + customBlockTitle || getBlockType( blockName )?.title || blockName; + + const requestFallback = useCallback( () => { + if ( + canEnableUnsupportedBlockEditor && + isUnsupportedBlockEditorSupported === false + ) { + onCloseSheet(); + setSendButtonPressMessage( true ); + } else { + onCloseSheet(); + setSendFallbackMessage( true ); + } + }, [ + canEnableUnsupportedBlockEditor, + isUnsupportedBlockEditorSupported, + onCloseSheet, + ] ); + + // The description can include extra notes via WP hooks. + const descriptionWithNotes = applyFilters( + 'native.unsupported_block_details_extra_note', + description, + blockName + ); + + const webEditorDefaultLabel = applyFilters( + 'native.unsupported_block_details_web_editor_action', + __( 'Edit using web editor' ) + ); + + const canUseWebEditor = + ( isUnsupportedBlockEditorSupported || + canEnableUnsupportedBlockEditor ) && + isEditableInUnsupportedBlockEditor; + const actions = [ + ...[ + canUseWebEditor && { + label: actionButtonLabel || webEditorDefaultLabel, + onPress: requestFallback, + }, + ], + ...customActions, + ].filter( Boolean ); + + return ( + { + if ( sendFallbackMessage ) { + // On iOS, onModalHide is called when the controller is still part of the hierarchy. + // A small delay will ensure that the controller has already been removed. + this.timeout = setTimeout( () => { + // For the Classic block, the content is kept in the `content` attribute. + requestUnsupportedBlockFallback( + blockContent, + clientId, + blockName, + blockTitle + ); + }, 100 ); + setSendFallbackMessage( false ); + } else if ( sendButtonPressMessage ) { + this.timeout = setTimeout( () => { + sendActionButtonPressedAction( + actionButtons.missingBlockAlertActionButton + ); + }, 100 ); + setSendButtonPressMessage( false ); + } + } } + > + + + { title } + { isEditableInUnsupportedBlockEditor && + descriptionWithNotes && ( + + { descriptionWithNotes } + + ) } + + { actions.map( ( { label, onPress }, index ) => ( + + ) ) } + + + ); +}; + +export default UnsupportedBlockDetails; diff --git a/packages/block-editor/src/components/unsupported-block-details/style.native.scss b/packages/block-editor/src/components/unsupported-block-details/style.native.scss new file mode 100644 index 0000000000000..a12272e6c8593 --- /dev/null +++ b/packages/block-editor/src/components/unsupported-block-details/style.native.scss @@ -0,0 +1,56 @@ +.unsupported-block-details__container { + flex-direction: column; + align-items: center; + justify-content: flex-end; +} + +.unsupported-block-details__icon { + size: 36; + height: 36; + padding-top: 8; + padding-bottom: 8; + color: $gray; +} + +.unsupported-block-details__icon--dark { + color: $gray-20; +} + +.unsupported-block-details__text { + text-align: center; + color: $gray-dark; +} + +.unsupported-block-details__text--dark { + color: $white; +} + +.unsupported-block-details__title { + padding-top: 8; + padding-bottom: 12; + font-size: 20; + font-weight: bold; + color: $gray-dark; +} + +.unsupported-block-details__title--dark { + color: $white; +} + +.unsupported-block-details__description { + padding-bottom: 24; + font-size: 16; + color: $gray-darken-20; +} + +.unsupported-block-details__description--dark { + color: $gray-20; +} + +.unsupported-block-details__action-button { + color: $blue-50; +} + +.unsupported-block-details__action-button--dark { + color: $blue-30; +} diff --git a/packages/block-editor/src/components/use-unsupported-block-editor/index.native.js b/packages/block-editor/src/components/use-unsupported-block-editor/index.native.js new file mode 100644 index 0000000000000..4b5bbe65857b2 --- /dev/null +++ b/packages/block-editor/src/components/use-unsupported-block-editor/index.native.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { serialize } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { store as blockEditorStore } from '../../store'; + +// Blocks that can't be edited through the Unsupported block editor identified by their name. +const UBE_INCOMPATIBLE_BLOCKS = [ 'core/block' ]; + +/** + * Hook that retrieves the settings to determine if the + * Unsupported Block Editor can be used in a specific block. + * + * @param {string} clientId Client ID of block. + * @return {Object} Unsupported block editor settings. + */ +export default function useUnsupportedBlockEditor( clientId ) { + return useSelect( + ( select ) => { + const { getBlock, getSettings } = select( blockEditorStore ); + const { capabilities } = getSettings(); + + const block = getBlock( clientId ); + const blockAttributes = block?.attributes || {}; + + const blockDetails = { + blockName: block?.name, + blockContent: serialize( block ? [ block ] : [] ), + }; + + // If the block is unsupported, use the `original` attributes to identify the block's name. + if ( blockDetails.blockName === 'core/missing' ) { + blockDetails.blockName = blockAttributes.originalName; + blockDetails.blockContent = + blockDetails.blockName === 'core/freeform' + ? blockAttributes.content + : block?.originalContent; + } + + return { + isUnsupportedBlockEditorSupported: + capabilities?.unsupportedBlockEditor === true, + canEnableUnsupportedBlockEditor: + capabilities?.canEnableUnsupportedBlockEditor === true, + isEditableInUnsupportedBlockEditor: + ! UBE_INCOMPATIBLE_BLOCKS.includes( + blockDetails.blockName + ), + ...blockDetails, + }; + }, + [ clientId ] + ); +} diff --git a/packages/react-native-editor/CHANGELOG.md b/packages/react-native-editor/CHANGELOG.md index 1debe8fbaad8e..70f0605599add 100644 --- a/packages/react-native-editor/CHANGELOG.md +++ b/packages/react-native-editor/CHANGELOG.md @@ -10,6 +10,7 @@ For each user feature we should also add a importance categorization label to i --> ## Unreleased +- [*] Limit inner blocks nesting depth to avoid call stack size exceeded crash [#54382] ## 1.104.0 - [*] Fix the obscurred "Insert from URL" input for media blocks when using a device in landscape orientation. [#54096]