Skip to content

Commit

Permalink
[RNMobile] Limit inner blocks nesting depth to avoid call stack size …
Browse files Browse the repository at this point in the history
…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 <github@davidcalhoun.me>

* 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 <github@davidcalhoun.me>
  • Loading branch information
fluiddot and dcalhoun committed Sep 26, 2023
1 parent c3cecac commit 66388b0
Show file tree
Hide file tree
Showing 7 changed files with 419 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions packages/block-editor/src/components/inner-blocks/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { __unstableGetInnerBlocksProps as getInnerBlocksProps } from '@wordpress/blocks';
import { useRef } from '@wordpress/element';
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
Expand All @@ -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
Expand Down Expand Up @@ -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 <WarningMaxDepthExceeded clientId={ clientId } />;
}

return (
<LayoutProvider value={ layout }>
<BlockContextProvider value={ context }>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<TouchableWithoutFeedback
disabled={ ! isSelected }
accessibilityLabel={ __( 'Warning message' ) }
accessibilityRole={ 'button' }
accessibilityHint={ __( 'Tap here to show more details.' ) }
onPress={ () => setShowDetails( true ) }
>
<View>
<Warning
message={ __(
'Block cannot be rendered because it is deeply nested. Tap here for more details.'
) }
/>
<UnsupportedBlockDetails
clientId={ clientId }
showSheet={ showDetails }
onCloseSheet={ () => setShowDetails( false ) }
title={ __( 'Deeply nested block' ) }
description={ description }
customActions={ [
{ label: __( 'Ungroup block' ), onPress: onUngroup },
] }
/>
</View>
</TouchableWithoutFeedback>
);
};

export default WarningMaxDepthExceeded;
Original file line number Diff line number Diff line change
@@ -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 (
<BottomSheet
isVisible={ showSheet }
hideHeader
onClose={ onCloseSheet }
onModalHide={ () => {
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 );
}
} }
>
<View style={ styles[ 'unsupported-block-details__container' ] }>
<Icon
icon={ icon || help }
color={ iconStyle.color }
size={ iconStyle.size }
/>
<Text style={ [ textStyle, titleStyle ] }>{ title }</Text>
{ isEditableInUnsupportedBlockEditor &&
descriptionWithNotes && (
<Text style={ [ textStyle, descriptionStyle ] }>
{ descriptionWithNotes }
</Text>
) }
</View>
{ actions.map( ( { label, onPress }, index ) => (
<TextControl
key={ `${ label } - ${ index }` }
label={ label }
separatorType="topFullWidth"
onPress={ onPress }
labelStyle={ actionButtonStyle }
/>
) ) }
<TextControl
label={ __( 'Dismiss' ) }
separatorType="topFullWidth"
onPress={ onCloseSheet }
labelStyle={ actionButtonStyle }
/>
</BottomSheet>
);
};

export default UnsupportedBlockDetails;
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 66388b0

Please sign in to comment.