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]