Fix(core): Prevent out-of-bounds activeItemId crash on Enter - #1354
Conversation
…#1246) Root Cause: �ctiveItemId is a flattened positional index across all collections. When an asynchronous source resolves and returns fewer items than previously existed, setCollections replaces the collections array but preserves �ctiveItemId blindly. If a user navigates to an item while an async request is pending, �ctiveItemId can point outside the new collection bounds. Pressing Enter then causes getActiveItem() to return ull, leading to a destructuring TypeError. How we fixed it: This commit implements a two-layer fix to protect the invariant without altering intended UX: 1. Revalidate at the State Boundary (stateReducer.ts) When setCollections replaces the collections array, it validates �ctiveItemId against ewItemsCount. If out of bounds, it safely resets to defaultActiveItemId (matching conventions used by ocus, eset, and mouseleave): \\\ s activeItemId: state.activeItemId !== null && state.activeItemId >= newItemsCount ? action.props.defaultActiveItemId : state.activeItemId, \\\ 2. Defensive Guard (onKeyDown.ts) As a fallback, the Enter handler explicitly guards against !activeItem. If a stale state is encountered, it safely cancels pending requests and leaves the event unprevented, allowing the browser's normal Enter-key/form behavior to proceed: \\\ s if (!activeItem || /* empty collections check */) { // cancel requests... return; } \\\ Regression Tests: - setCollections: resets out-of-bounds IDs but preserves valid ones. - onKeyDown: Enter handler does not throw on a stale �ctiveItemId and safely bypasses preventDefault().
activeItemId crash on EnteractiveItemId crash on Enter
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 4 |
TIP This summary will be updated as you push new changes.
Haroenv
left a comment
There was a problem hiding this comment.
fix looks legit, thanks!
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the fallback validation issue, remove the unused import, and add the requested boundary tests.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR prevents Enter-key crashes caused by stale activeItemId values after asynchronous collection updates.
Changes:
- Revalidates active indexes when collections change.
- Guards Enter handling when no active item resolves.
- Adds reducer and keyboard regression tests.
File summaries
| File | Summary |
|---|---|
packages/autocomplete-core/src/stateReducer.ts |
Resets stale active indexes; fallback validation still requires correction. |
packages/autocomplete-core/src/onKeyDown.ts |
Safely handles missing active items. |
packages/autocomplete-core/src/__tests__/setCollections.test.ts |
Tests index updates; requires an unused-import fix and additional boundary cases. |
packages/autocomplete-core/src/__tests__/getInputProps.test.ts |
Tests stale-index Enter handling. |
Review details
Suppressed comments (1)
packages/autocomplete-core/src/stateReducer.ts:33
- This fallback can still leave
activeItemIdout of bounds whendefaultActiveItemIditself is not representable in the new result set (for example, configured as1while the new collection has one item). The public option accepts any number, so this assignment can produceactiveItemId === newItemsCountand the state remains stale; validate the fallback against the new count and usenull(or another valid index) when it is invalid.
activeItemId:
state.activeItemId !== null && state.activeItemId >= newItemsCount
? action.props.defaultActiveItemId
: state.activeItemId,
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Remove dead AutocompleteCollection import after return type change to any. Add regression tests for the exact activeItemId === newItemsCount boundary and the zero-item collections edge case.
More templates
@algolia/autocomplete-core
@algolia/autocomplete-js
@algolia/autocomplete-plugin-algolia-insights
@algolia/autocomplete-plugin-query-suggestions
@algolia/autocomplete-plugin-recent-searches
@algolia/autocomplete-plugin-redirect-url
@algolia/autocomplete-plugin-tags
@algolia/autocomplete-preset-algolia
@algolia/autocomplete-shared
@algolia/autocomplete-theme-classic
commit: |
|
Thanks for the approval, @Haroenv! I've just pushed a minor formatting fix ( The implementation logic remains entirely unchanged, and all tests continue to pass locally without issue. The PR should now be fully green and ready to merge once the CI finishes! |
Resolves #1246.
🐛 What: The Root Cause
The intermittent
TypeError: Cannot read properties of null (reading 'item')is caused by staleactiveItemIdstate after an asynchronous collection update.In a previous comment, @Haroenv mentioned:
After tracing the
activeItemIdand collection state transitions, I found that the same error can occur even whencollectionsis not empty.The important condition is that the newly resolved collections contain fewer total items than the current
activeItemIdrequires to remain valid.activeItemIdis tracked as a flattened positional index across all collections. When an asynchronous source resolves,setCollectionsreplaces thecollectionsarray but currently preserves the existingactiveItemIdwithout checking whether that index is still valid.For example:
So the crash does not require
collectionsitself to be empty; it can occur whenever the active positional index is no longer valid for the newly supplied collections.🛠 How: The Implementation
This PR uses a two-layer approach:
activeItemIdstate invariant when collections change.The reducer-level change addresses the identified stale-state transition rather than simply suppressing the exception at the point where it occurs.
1. Revalidate at the State Boundary (
stateReducer.ts)When replacing the collections array,
setCollectionsnow validatesactiveItemIdagainst the total number of items in the new collections.If the active index is out of bounds, it is reset to
defaultActiveItemId.Resetting to
defaultActiveItemIdfollows the existing behavior used byfocus,reset, andmouseleave, so this does not introduce a new reset convention.Valid
activeItemIdvalues are preserved.This prevents
setCollectionsfrom leavingactiveItemIdpointing outside the bounds of the newly supplied collections.activeItemIdis otherwise maintained as eithernullor a non-negative positional index by the existing state transitions.2. Defensive Guard (
onKeyDown.ts)As a second line of defense, the
Enterhandler now explicitly handles the case wheregetActiveItem()returnsnull.If no active item can be resolved, the existing pending-request cancellation path is retained and the event is left unprevented, allowing the browser's normal Enter-key/form behavior to proceed.
The existing empty-collections check is retained; the change adds the
!activeItemcondition alongside it.This guard is intentionally kept as a backstop rather than the primary fix, so that the Enter handler remains safe if an invalid state is ever reached through another path.
🧪 Testing
I've added deterministic regression tests covering the affected behavior.
setCollections.test.tsactiveItemIdis reset todefaultActiveItemId.activeItemIdis preserved.activeItemId === newItemsCount.getInputProps.test.tsactiveItemIdcondition and verifies that pressingEnterno longer throws theTypeError.preventDefault().I also verified the regression tests against the existing implementation: the reducer regression fails because the stale
activeItemIdis preserved, while the Enter regression reproduces the originalTypeError.Happy to adjust the
defaultActiveItemIdsemantics if that doesn't match the intended behavior.