From 99a7bc00120a62f67031ecc52860426974572b21 Mon Sep 17 00:00:00 2001 From: Anthony Hernandez Date: Thu, 5 Feb 2026 20:35:03 -0800 Subject: [PATCH] fix out-of-bounds active_option crash fixes bug where `active_option` index becomes out of bounds when the options list is dynamically updated via `send_update`, causing the component to crash with `KeyError` or `BadMapError` when trying to access `.label` on `nil`. Variant 1: Crash in `already_selected?` Steps to reproduce: 1. Open a LiveSelect component 2. Type a search query that returns multiple options 3. Use arrow keys to navigate to an option towards the end of the list 4. Select that option 5. Re-open or re-focus the same LiveSelect 6. Type a new search query that returns FEWER options than before 7. Use arrow keys to navigate down in this shorter list 8. Type additional characters to narrow the search even MORE (making the list even shorter) 9. Press Enter to select Variant 2: Crash in `new_current_text_after_selection` Steps to reproduce: 1. Open a LiveSelect component 2. Type a search query that returns multiple options 3. Use arrow keys to navigate to an option towards the end of the list 4. Do NOT press Enter yet - keep the option highlighted 5. Type additional characters to filter results 6. Press Enter to select --- lib/live_select/component.ex | 21 +++++++++++-- test/live_select_test.exs | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index 7eccb3b..c08e452 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -149,6 +149,14 @@ defmodule LiveSelect.Component do |> assign_new(opt, fn -> default end) end) |> update(:options, &normalize_options/1) + |> then(fn socket -> + if Map.has_key?(assigns, :options) && + socket.assigns.active_option >= length(socket.assigns.options) do + assign(socket, active_option: -1) + else + socket + end + end) |> assign(:text_input_field, String.to_atom("#{socket.assigns.field.field}_text_input")) socket = @@ -312,9 +320,18 @@ defmodule LiveSelect.Component do @impl true def handle_event("option_click", %{"idx" => idx}, socket) do - socket = assign(socket, :active_option, String.to_integer(idx)) + idx = String.to_integer(idx) - {:noreply, maybe_select(socket)} + socket = + if idx >= 0 && idx < length(socket.assigns.options) do + socket + |> assign(:active_option, idx) + |> maybe_select() + else + socket + end + + {:noreply, socket} end @impl true diff --git a/test/live_select_test.exs b/test/live_select_test.exs index e8fee8d..ab55024 100644 --- a/test/live_select_test.exs +++ b/test/live_select_test.exs @@ -1155,4 +1155,62 @@ defmodule LiveSelectTest do assert_selected_static(live, "B") end + + test "resets active_option when options shrink via send_update", %{conn: conn} do + stub_options([%{label: "A", value: 1}, %{label: "B", value: 2}, %{label: "C", value: 3}]) + + {:ok, live, _html} = live(conn, "/") + + type(live, "ABC") + + navigate(live, 3, :down) + + send_update(live, options: [%{label: "X", value: 10}, %{label: "Y", value: 20}]) + + keydown(live, "Enter") + + refute_selected(live) + end + + test "does not crash when pressing Enter with out-of-bounds active_option", %{conn: conn} do + stub_options([%{label: "A", value: 1}, %{label: "B", value: 2}, %{label: "C", value: 3}]) + + {:ok, live, _html} = live(conn, "/") + + type(live, "ABC") + + navigate(live, 5, :down) + + send_update(live, options: [%{label: "X", value: 10}, %{label: "Y", value: 20}]) + + keydown(live, "Enter") + + refute_selected(live) + end + + test "ignores out-of-bounds idx in option_click event", %{conn: conn} do + stub_options([%{label: "A", value: 1}, %{label: "B", value: 2}, %{label: "C", value: 3}]) + + {:ok, live, _html} = live(conn, "/") + + type(live, "ABC") + + element(live, selectors()[:container]) + |> render_hook("option_click", %{idx: "999"}) + + refute_selected(live) + end + + test "ignores negative idx in option_click event", %{conn: conn} do + stub_options([%{label: "A", value: 1}, %{label: "B", value: 2}, %{label: "C", value: 3}]) + + {:ok, live, _html} = live(conn, "/") + + type(live, "ABC") + + element(live, selectors()[:container]) + |> render_hook("option_click", %{idx: "-1"}) + + refute_selected(live) + end end