Skip to content

Commit 6a0e7e1

Browse files
jeryjjeryjgetdaveockham
authored
Fix empty url value from unbinding entity from inspector sidebar (#72447)
When unsyncing a navigation link url, we now set the url as the synced entity url to avoid empty url states which cause bugs in the canvas. We also defer updating the url value in the store until the blur event to avoid the canvas receiving a temporarily empty value (such as when a user clears the URL in order to start typing a new URL). ---- Co-authored-by: jeryj <[email protected]> Co-authored-by: getdave <[email protected]> Co-authored-by: ockham <[email protected]>
1 parent bc703dd commit 6a0e7e1

File tree

3 files changed

+57
-27
lines changed

3 files changed

+57
-27
lines changed

packages/block-library/src/navigation-link/shared/controls.js

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
TextareaControl,
1212
} from '@wordpress/components';
1313
import { __, sprintf } from '@wordpress/i18n';
14-
import { useRef, useEffect } from '@wordpress/element';
14+
import { useRef, useEffect, useState } from '@wordpress/element';
1515
import { useInstanceId } from '@wordpress/compose';
1616
import { safeDecodeURI } from '@wordpress/url';
1717
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
@@ -77,6 +77,15 @@ export function Controls( { attributes, setAttributes, clientId } ) {
7777
const inputId = useInstanceId( Controls, 'link-input' );
7878
const helpTextId = `${ inputId }__help`;
7979

80+
// Local state to control the input value
81+
const [ inputValue, setInputValue ] = useState( url );
82+
83+
// Sync local state when url prop changes (e.g., from undo/redo or external updates)
84+
useEffect( () => {
85+
setInputValue( url );
86+
lastURLRef.current = url;
87+
}, [ url ] );
88+
8089
// Use the entity binding hook internally
8190
const { hasUrlBinding, clearBinding } = useEntityBinding( {
8291
clientId,
@@ -95,12 +104,19 @@ export function Controls( { attributes, setAttributes, clientId } ) {
95104
// setAttributes is actually setBoundAttributes, a wrapper function that
96105
// processes attributes through the binding system.
97106
// See: packages/block-editor/src/components/block-edit/edit.js
98-
updateBlockAttributes( clientId, { url: '', id: undefined } );
107+
updateBlockAttributes( clientId, {
108+
url: lastURLRef.current, // set the lastURLRef as the new editable value so we avoid bugs from empty link states
109+
id: undefined,
110+
} );
99111
};
100112

101113
useEffect( () => {
114+
// Checking for ! hasUrlBinding is a defensive check, as we would
115+
// only want to focus the input if the url is not bound to an entity.
102116
if ( ! hasUrlBinding && shouldFocusURLInputRef.current ) {
103-
urlInputRef.current?.focus();
117+
// focuses and highlights the url input value, giving the user
118+
// the ability to delete the value quickly or edit it.
119+
urlInputRef.current?.select();
104120
}
105121
shouldFocusURLInputRef.current = false;
106122
}, [ hasUrlBinding ] );
@@ -149,18 +165,20 @@ export function Controls( { attributes, setAttributes, clientId } ) {
149165
__next40pxDefaultSize
150166
id={ inputId }
151167
label={ __( 'Link' ) }
152-
value={ url ? safeDecodeURI( url ) : '' }
153-
onChange={ ( urlValue ) => {
154-
if ( hasUrlBinding ) {
155-
return; // Prevent editing when URL is bound
156-
}
157-
setAttributes( {
158-
url: encodeURI( safeDecodeURI( urlValue ) ),
159-
} );
160-
} }
168+
value={ inputValue ? safeDecodeURI( inputValue ) : '' }
161169
autoComplete="off"
162170
type="url"
163171
disabled={ hasUrlBinding }
172+
onChange={ ( newValue ) => {
173+
if ( hasUrlBinding ) {
174+
return;
175+
}
176+
177+
// Defer updating the url attribute until onBlur to prevent the canvas from
178+
// treating a temporary empty value as a committed value, which replaces the
179+
// label with placeholder text.
180+
setInputValue( newValue );
181+
} }
164182
onFocus={ () => {
165183
if ( hasUrlBinding ) {
166184
return;
@@ -171,12 +189,19 @@ export function Controls( { attributes, setAttributes, clientId } ) {
171189
if ( hasUrlBinding ) {
172190
return;
173191
}
192+
193+
const finalValue = ! inputValue
194+
? lastURLRef.current
195+
: inputValue;
196+
197+
// Update local state immediately so input reflects the reverted value if the value was cleared
198+
setInputValue( finalValue );
199+
174200
// Defer the updateAttributes call to ensure entity connection isn't severed by accident.
175-
updateAttributes(
176-
{ url: ! url ? lastURLRef.current : url },
177-
setAttributes,
178-
{ ...attributes, url: lastURLRef.current }
179-
);
201+
updateAttributes( { url: finalValue }, setAttributes, {
202+
...attributes,
203+
url: lastURLRef.current,
204+
} );
180205
} }
181206
help={
182207
hasUrlBinding && (

packages/block-library/src/navigation-link/shared/test/controls.js

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* External dependencies
77
*/
88
import { render, screen, fireEvent } from '@testing-library/react';
9+
import userEvent from '@testing-library/user-event';
910

1011
/**
1112
* Internal dependencies
@@ -95,18 +96,22 @@ describe( 'Controls', () => {
9596
expect( urlInput.value ).toBe( 'https://example.com/test page' );
9697
} );
9798

98-
it( 'encodes URL values when changed', () => {
99+
it( 'calls updateAttributes with new URL on blur', async () => {
100+
const user = userEvent.setup();
99101
render( <Controls { ...defaultProps } /> );
100102

101103
const urlInput = screen.getByLabelText( 'Link' );
102104

103-
fireEvent.change( urlInput, {
104-
target: { value: 'https://example.com/test page' },
105-
} );
105+
await user.click( urlInput );
106+
await user.clear( urlInput );
107+
await user.type( urlInput, 'https://example.com/test page' );
108+
await user.tab();
106109

107-
expect( defaultProps.setAttributes ).toHaveBeenCalledWith( {
108-
url: 'https://example.com/test%20page',
109-
} );
110+
expect( mockUpdateAttributes ).toHaveBeenCalledWith(
111+
{ url: 'https://example.com/test page' },
112+
defaultProps.setAttributes,
113+
{ ...defaultProps.attributes, url: 'https://example.com' }
114+
);
110115
} );
111116

112117
it( 'calls updateAttributes on URL blur', () => {
@@ -143,11 +148,11 @@ describe( 'Controls', () => {
143148
target: { value: 'https://new.com' },
144149
} );
145150

146-
// Blur should call updateAttributes with the current URL (since url exists)
151+
// Blur should call updateAttributes with the new URL value from the input
147152
fireEvent.blur( urlInput );
148153

149154
expect( mockUpdateAttributes ).toHaveBeenCalledWith(
150-
{ url: 'https://different.com' }, // Current URL from attributes (not input value)
155+
{ url: 'https://new.com' }, // New URL from input value
151156
defaultProps.setAttributes,
152157
{
153158
...propsWithDifferentUrl.attributes,

test/e2e/specs/editor/blocks/navigation.spec.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,7 @@ test.describe( 'Navigation block', () => {
10691069
} );
10701070
await unlinkButton.click();
10711071
await expect( linkInput ).toBeEnabled();
1072-
await expect( linkInput ).toHaveValue( '' );
1072+
await expect( linkInput ).toHaveValue( updatedPage.link );
10731073
await expect( linkInput ).toBeFocused();
10741074
} );
10751075
} );

0 commit comments

Comments
 (0)