Skip to content
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
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Bug Fixes

- `ControlWithError`: Connect validation error messages to controls via `aria-describedby` ([#76742](https://github.com/WordPress/gutenberg/pull/76742)).
- `Autocomplete`: Fix value comparison to avoid resetting block inserter in RTC ([#76980](https://github.com/WordPress/gutenberg/pull/76980)).

## 32.4.0 (2026-03-18)

Expand Down
36 changes: 30 additions & 6 deletions packages/components/src/autocomplete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -393,17 +393,41 @@ export function useAutocomplete( {
};
}

function useLastDifferentValue( value: UseAutocompleteProps[ 'record' ] ) {
const history = useRef< Set< typeof value > >( new Set() );
/**
* Checks whether two records represent the same user-visible state
* (same text content and cursor position).
*/
function recordValuesMatch(
a: UseAutocompleteProps[ 'record' ],
b: UseAutocompleteProps[ 'record' ]
) {
return a.text === b.text && a.start === b.start && a.end === b.end;
}

history.current.add( value );
/**
* Tracks the last record whose value differed from the current one.
* Used to determine whether the user has actually typed something
*/
export function useLastDifferentValue(
value: UseAutocompleteProps[ 'record' ]
) {
const history = useRef< Array< typeof value > >( [] );

const lastEntry = history.current[ history.current.length - 1 ];

// Only add to history if the value is meaningfully different from
// the most recent entry (analogous to Set.add being a no-op for
// duplicate references in the original implementation).
if ( ! lastEntry || ! recordValuesMatch( value, lastEntry ) ) {
history.current.push( value );
}

// Keep the history size to 2.
if ( history.current.size > 2 ) {
history.current.delete( Array.from( history.current )[ 0 ] );
if ( history.current.length > 2 ) {
history.current.shift();
}

return Array.from( history.current )[ 0 ];
return history.current[ 0 ];
}

export function useAutocompleteProps( options: UseAutocompleteProps ) {
Expand Down
111 changes: 110 additions & 1 deletion packages/components/src/autocomplete/test/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { render, screen, renderHook } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

/**
Expand All @@ -13,9 +13,118 @@ import { useRef } from '@wordpress/element';
* Internal dependencies
*/
import { getAutoCompleterUI } from '../autocompleter-ui';
import { useLastDifferentValue } from '..';

type FruitOption = { visual: string; name: string; id: number };

function makeRecord( text: string ) {
return {
text,
formats: [],
replacements: [],
start: text.length,
end: text.length,
};
}

describe( 'useLastDifferentValue', () => {
it( 'should return the current record on first render', () => {
const record = makeRecord( 'Hello' );
const { result } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: record } }
);

expect( result.current.text ).toBe( 'Hello' );
} );

it( 'should return the previous record when text changes', () => {
const record1 = makeRecord( 'Hello' );
const { result, rerender } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: record1 } }
);

const record2 = makeRecord( 'Hello/' );
rerender( { value: record2 } );

expect( result.current.text ).toBe( 'Hello' );
} );

it( 'should not update when re-rendered with a new reference but same text', () => {
const record1 = makeRecord( 'Hello' );
const { result, rerender } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: record1 } }
);

// User types "/"
const record2 = makeRecord( 'Hello/' );
rerender( { value: record2 } );
expect( result.current.text ).toBe( 'Hello' );

// RESET_BLOCKS creates a new record object with the same text.
const record3 = makeRecord( 'Hello/' );
rerender( { value: record3 } );
expect( result.current.text ).toBe( 'Hello' );
} );

it( 'should survive multiple same-text re-renders', () => {
const record1 = makeRecord( 'Hello' );
const { result, rerender } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: record1 } }
);

// User types "/"
const record2 = makeRecord( 'Hello/' );
rerender( { value: record2 } );

// Multiple syncs, each producing new references with the same text.
for ( let i = 0; i < 5; i++ ) {
rerender( { value: makeRecord( 'Hello/' ) } );
}

expect( result.current.text ).toBe( 'Hello' );
} );

it( 'should track consecutive text changes correctly', () => {
const { result, rerender } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: makeRecord( 'A' ) } }
);

rerender( { value: makeRecord( 'AB' ) } );
expect( result.current.text ).toBe( 'A' );

rerender( { value: makeRecord( 'ABC' ) } );
expect( result.current.text ).toBe( 'AB' );

rerender( { value: makeRecord( 'ABCD' ) } );
expect( result.current.text ).toBe( 'ABC' );
} );

it( 'should update when cursor position changes without text change', () => {
const { result, rerender } = renderHook(
( { value } ) => useLastDifferentValue( value ),
{ initialProps: { value: makeRecord( 'Hello' ) } }
);

// User types "/"
rerender( { value: makeRecord( 'Hello/' ) } );
expect( result.current.text ).toBe( 'Hello' );

// User moves cursor left (same text, different position).
rerender( {
value: { ...makeRecord( 'Hello/' ), start: 0, end: 0 },
} );

// The returned record should now match the current text,
// so that didUserInput evaluates to false.
expect( result.current.text ).toBe( 'Hello/' );
} );
} );

describe( 'AutocompleterUI', () => {
describe( 'click outside behavior', () => {
it( 'should call reset function when a click on another element occurs', async () => {
Expand Down
Loading