Skip to content

Fix(core): Prevent out-of-bounds activeItemId crash on Enter - #1354

Merged
Haroenv merged 5 commits into
algolia:nextfrom
aryan-iconic:fix/1246
Sep 15, 2026
Merged

Haroenv merged 5 commits into
algolia:nextfrom
aryan-iconic:fix/1246

Conversation

@aryan-iconic

Copy link
Copy Markdown
Contributor

Resolves #1246.

As noted in the issue thread, this has been reported ~302 times across 86 users over the last 18 months, making it a persistent production issue worth addressing.

🐛 What: The Root Cause

The intermittent TypeError: Cannot read properties of null (reading 'item') is caused by stale activeItemId state after an asynchronous collection update.

In a previous comment, @Haroenv mentioned:

"I think this error state can only happen if state.collections is empty itself, which isn't something I'm aware is possible."

After tracing the activeItemId and collection state transitions, I found that the same error can occur even when collections is not empty.

The important condition is that the newly resolved collections contain fewer total items than the current activeItemId requires to remain valid.

activeItemId is tracked as a flattened positional index across all collections. When an asynchronous source resolves, setCollections replaces the collections array but currently preserves the existing activeItemId without checking whether that index is still valid.

For example:

User highlights item
activeItemId = 4

        ↓

Async source is still pending

        ↓

New source result resolves
new total item count = 2

        ↓

setCollections replaces collections
activeItemId remains 4

        ↓

activeItemId is now outside the valid range

        ↓

getActiveItem() returns null

        ↓

Enter handler attempts to destructure the result

        ↓

TypeError

So the crash does not require collections itself 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:

  1. Primary fix: maintain the activeItemId state invariant when collections change.
  2. Defensive backstop: safely handle a missing active item at the Enter boundary.

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, setCollections now validates activeItemId against the total number of items in the new collections.

If the active index is out of bounds, it is reset to defaultActiveItemId.

Resetting to defaultActiveItemId follows the existing behavior used by focus, reset, and mouseleave, so this does not introduce a new reset convention.

Valid activeItemId values are preserved.

case 'setCollections': {
  const newItemsCount = getItemsCount({ collections: action.payload });

  return {
    ...state,
    collections: action.payload,
    activeItemId:
      state.activeItemId !== null && state.activeItemId >= newItemsCount
        ? action.props.defaultActiveItemId
        : state.activeItemId,
  };
}

This prevents setCollections from leaving activeItemId pointing outside the bounds of the newly supplied collections.

activeItemId is otherwise maintained as either null or a non-negative positional index by the existing state transitions.

2. Defensive Guard (onKeyDown.ts)

As a second line of defense, the Enter handler now explicitly handles the case where getActiveItem() returns null.

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.

const activeItem =
  store.getState().activeItemId !== null
    ? getActiveItem(store.getState())
    : null;

if (
  !activeItem ||
  store.getState().collections.every(
    (collection) => collection.items.length === 0
  )
) {
  // ... existing pendingRequests.cancelAll() logic
  return;
}

The existing empty-collections check is retained; the change adds the !activeItem condition 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.ts

  • Verifies that an out-of-bounds activeItemId is reset to defaultActiveItemId.
  • Verifies that a valid activeItemId is preserved.
  • Covers the boundary where activeItemId === newItemsCount.
  • Covers the zero-item case.

getInputProps.test.ts

  • Reproduces the stale activeItemId condition and verifies that pressing Enter no longer throws the TypeError.
  • Verifies that the stale-item path does not call preventDefault().
  • Verifies that the existing pending-request cancellation behavior is retained.

I also verified the regression tests against the existing implementation: the reducer regression fails because the stale activeItemId is preserved, while the Enter regression reproduces the original TypeError.

Happy to adjust the defaultActiveItemId semantics if that doesn't match the intended behavior.

…#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().
@aryan-iconic aryan-iconic changed the title # Fix(core): Prevent out-of-bounds activeItemId crash on Enter Fix(core): Prevent out-of-bounds activeItemId crash on Enter Sep 4, 2026
@codacy-production

codacy-production Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 4 complexity

Metric Results
Complexity 4

View in Codacy

TIP This summary will be updated as you push new changes.

@Haroenv Haroenv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fix looks legit, thanks!

Comment thread packages/autocomplete-core/src/__tests__/setCollections.test.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 activeItemId out of bounds when defaultActiveItemId itself is not representable in the new result set (for example, configured as 1 while the new collection has one item). The public option accepts any number, so this assignment can produce activeItemId === newItemsCount and the state remains stale; validate the fallback against the new count and use null (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.

Comment thread packages/autocomplete-core/src/__tests__/setCollections.test.ts
Comment thread packages/autocomplete-core/src/__tests__/setCollections.test.ts
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.
@pkg-pr-new

pkg-pr-new Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
More templates

@algolia/autocomplete-core

npm i https://pkg.pr.new/@algolia/autocomplete-core@1354

@algolia/autocomplete-js

npm i https://pkg.pr.new/@algolia/autocomplete-js@1354

@algolia/autocomplete-plugin-algolia-insights

npm i https://pkg.pr.new/@algolia/autocomplete-plugin-algolia-insights@1354

@algolia/autocomplete-plugin-query-suggestions

npm i https://pkg.pr.new/@algolia/autocomplete-plugin-query-suggestions@1354

@algolia/autocomplete-plugin-recent-searches

npm i https://pkg.pr.new/@algolia/autocomplete-plugin-recent-searches@1354

@algolia/autocomplete-plugin-redirect-url

npm i https://pkg.pr.new/@algolia/autocomplete-plugin-redirect-url@1354

@algolia/autocomplete-plugin-tags

npm i https://pkg.pr.new/@algolia/autocomplete-plugin-tags@1354

@algolia/autocomplete-preset-algolia

npm i https://pkg.pr.new/@algolia/autocomplete-preset-algolia@1354

@algolia/autocomplete-shared

npm i https://pkg.pr.new/@algolia/autocomplete-shared@1354

@algolia/autocomplete-theme-classic

npm i https://pkg.pr.new/@algolia/autocomplete-theme-classic@1354

commit: b69c2a5

@aryan-iconic

Copy link
Copy Markdown
Contributor Author

Thanks for the approval, @Haroenv!

I've just pushed a minor formatting fix (bc682f64) to resolve the 3 prettier violations in the test files that caused the lint CI job to fail on the previous attempt.

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!

@Haroenv
Haroenv merged commit 45b6a08 into algolia:next Sep 15, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeError: Cannot read properties of null (onKeyDown.js)

3 participants