Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Prevent unnecessary content changes clearing redo actions #57028

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export class RichText extends Component {
onCreateUndoLevel() {
const { __unstableOnCreateUndoLevel: onCreateUndoLevel } = this.props;
// If the content is the same, no level needs to be created.
if ( this.lastHistoryValue === this.value ) {
if ( this.lastHistoryValue.toString() === this.value.toString() ) {
return;
}

Expand Down Expand Up @@ -320,7 +320,7 @@ export class RichText extends Component {
unescapeSpaces( event.nativeEvent.text )
);
// On iOS, onChange can be triggered after selection changes, even though there are no content changes.
if ( contentWithoutRootTag === this.value ) {
if ( contentWithoutRootTag === this.value.toString() ) {
return;
}
this.lastEventCount = event.nativeEvent.eventCount;
Expand All @@ -336,7 +336,7 @@ export class RichText extends Component {
);

this.debounceCreateUndoLevel();
const refresh = this.value !== contentWithoutRootTag;
Copy link
Member Author

@dcalhoun dcalhoun Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the specific line resulting in incorrectly clearing pending redo actions. Comparing a RichTextData instance (this.value) against a string (contentWithoutRootTag) never resulted in equality. Making the situation more complex is the fact that iOS and Android Aztec implementations behave differently when it comes to blur and selection events, which means this nuanced scenario only occurred on iOS.

The rest of the added toString invocations separate from this line in the proposed changes are out of caution, not to address an explicitly observed issue. Transparently, it is unclear to me currently when/where RichTextData should be leverage and where Aztec's string-value-based communication might disrupt that by passing back a string. It could be that a better solution is to always leverage RichTextData values in JavaScript and convert values received from Aztec from string to RichTextData.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, the use of toString is fairly innocuous as both String and RichTextData provide the method. So, regardless of which type is present, the method and comparison should succeed.

If nothing else, we could use the proposed changes as a stopgap solution until we fully integrate the RichTextData type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[...] It could be that a better solution is to always leverage RichTextData values in JavaScript and convert values received from Aztec from string to RichTextData.

I think this would be the best option for consistency. This way we'll ensure that RichTextData is the expected value of the RichText component when used anywhere.

In any case, I think there will still be some cases where we need to convert the value to a String. As an example, I noticed a couple of examples in RichText: pass value to store:

To be clear, the use of toString is fairly innocuous as both String and RichTextData provide the method. So, regardless of which type is present, the method and comparison should succeed.

For reference, this is the implementation of RichTextData.toString:

toHTMLString() {
return (
this.originalHTML ||
toHTMLString( { value: this[ RichTextInternalData ] } )
);
}
valueOf() {
return this.toHTMLString();
}
toString() {
return this.toHTMLString();
}

const refresh = this.value.toString() !== contentWithoutRootTag;
this.value = contentWithoutRootTag;

// We don't want to refresh if our goal is just to create a record.
Expand Down Expand Up @@ -567,7 +567,7 @@ export class RichText extends Component {
// Check if value is up to date with latest state of native AztecView.
if (
event.nativeEvent.text &&
event.nativeEvent.text !== this.props.value
event.nativeEvent.text !== this.props.value.toString()
) {
this.onTextUpdate( event );
}
Expand All @@ -592,7 +592,7 @@ export class RichText extends Component {
// this approach is not perfectly reliable.
const isManual =
this.lastAztecEventType !== 'input' &&
this.props.value === this.value;
this.props.value.toString() === this.value.toString();
if ( hasChanged && isManual ) {
const value = this.createRecord();
const activeFormats = getActiveFormats( value );
Expand Down Expand Up @@ -662,7 +662,7 @@ export class RichText extends Component {
unescapeSpaces( event.nativeEvent.text )
);
if (
contentWithoutRootTag === this.value &&
contentWithoutRootTag === this.value.toString() &&
realStart === this.selectionStart &&
realEnd === this.selectionEnd
) {
Expand Down Expand Up @@ -759,7 +759,7 @@ export class RichText extends Component {
typeof nextProps.value !== 'undefined' &&
typeof this.props.value !== 'undefined' &&
( ! this.comesFromAztec || ! this.firedAfterTextChanged ) &&
nextProps.value !== this.props.value
nextProps.value.toString() !== this.props.value.toString()
) {
// Gutenberg seems to try to mirror the caret state even on events that only change the content so,
// let's force caret update if state has selection set.
Expand Down Expand Up @@ -833,7 +833,7 @@ export class RichText extends Component {
const { style, tagName } = this.props;
const { currentFontSize } = this.state;

if ( this.props.value !== this.value ) {
if ( this.props.value.toString() !== this.value.toString() ) {
this.value = this.props.value;
}
const { __unstableIsSelected: prevIsSelected } = prevProps;
Expand All @@ -851,7 +851,7 @@ export class RichText extends Component {
// Since this is happening when merging blocks, the selection should be at the last character position.
// As a fallback the internal selectionEnd value is used.
const lastCharacterPosition =
this.value?.length ?? this.selectionEnd;
this.value?.toString().length ?? this.selectionEnd;
this._editor.focus();
this.props.onSelectionChange(
lastCharacterPosition,
Expand Down Expand Up @@ -893,7 +893,8 @@ export class RichText extends Component {
// On android if content is empty we need to send no content or else the placeholder will not show.
if (
! this.isIOS &&
( value === '' || value === EMPTY_PARAGRAPH_TAGS )
( value.toString() === '' ||
value.toString() === EMPTY_PARAGRAPH_TAGS )
) {
return '';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
* External dependencies
*/
import { Dimensions } from 'react-native';
import { getEditorHtml, render, initializeEditor } from 'test/helpers';
import {
fireEvent,
getEditorHtml,
render,
initializeEditor,
} from 'test/helpers';

/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
import { store as richTextStore } from '@wordpress/rich-text';
import { store as richTextStore, RichTextData } from '@wordpress/rich-text';
import { coreBlocks } from '@wordpress/block-library';
import {
getBlockTypes,
Expand Down Expand Up @@ -78,6 +83,26 @@ describe( '<RichText/>', () => {
} );
} );

describe( 'when changes arrive from Aztec', () => {
it( 'should avoid updating attributes when values are equal', async () => {
const handleChange = jest.fn();
const defaultEmptyValue = new RichTextData();
const screen = render(
<RichText
onChange={ handleChange }
value={ defaultEmptyValue }
/>
);

// Simulate an empty string from Aztec
fireEvent( screen.getByLabelText( 'Text input. Empty' ), 'change', {
nativeEvent: { text: '' },
} );

expect( handleChange ).not.toHaveBeenCalled();
} );
} );

describe( 'Font Size', () => {
it( 'should display rich text at the DEFAULT font size.', () => {
// Arrange.
Expand Down
Loading