From ec4644352d3e3dd2a7bc8ee985c7f6ef819e22da Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:18:27 +0000 Subject: [PATCH] fix(command-center): guard ActionSelector Enter against empty/stale options Pressing Enter on a permission prompt or mode switch could crash the whole command-center screen. selectCurrent() read allOptions[selectedIndex] and dereferenced selected.id with no bounds check, unlike its siblings selectByIndex and handleClick which already guard. When options change mid-render (a permission resolves/supersedes, a step/mode transition fires, or the clamping effect hasn't run before the keydown) selected is undefined and selected.id throws a TypeError. Because command center mounts one ActionSelector per grid cell with no per-cell error boundary, a single throw unmounts the whole router tree via the app-root boundary and shows "Something went wrong". - Add the missing `if (!selected) return;` guard to selectCurrent. - Wrap each command-center grid cell in its own ErrorBoundary so one cell's failure can no longer take down the entire screen. - Add regression tests for the empty and stale-index cases. Generated-By: PostHog Code Task-Id: 64856792-d5b4-4990-ac32-0c102f9884ec --- .../components/CommandCenterGrid.tsx | 8 ++- .../useActionSelectorState.test.ts | 62 +++++++++++++++++++ .../action-selector/useActionSelectorState.ts | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/primitives/action-selector/useActionSelectorState.test.ts diff --git a/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx b/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx index 32254d828e..22dc13ec35 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx @@ -1,4 +1,5 @@ import { destroyShellTerminal } from "@posthog/ui/features/terminal/destroyShellTerminal"; +import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { useCallback, useEffect, useRef, useState } from "react"; import { FOCUSABLE_SELECTOR } from "../../../utils/overlay"; import { @@ -132,7 +133,12 @@ function GridCell({ zoom: zoom !== 1 ? zoom : undefined, }} > - + + + {isActive && (
diff --git a/packages/ui/src/primitives/action-selector/useActionSelectorState.test.ts b/packages/ui/src/primitives/action-selector/useActionSelectorState.test.ts new file mode 100644 index 0000000000..48f70d9a11 --- /dev/null +++ b/packages/ui/src/primitives/action-selector/useActionSelectorState.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { SelectorOption } from "./types"; +import { useActionSelectorState } from "./useActionSelectorState"; + +function makeProps( + overrides: Partial[0]> = {}, +) { + return { + options: [] as SelectorOption[], + multiSelect: false, + allowCustomInput: false, + hideSubmitButton: false, + currentStep: 0, + steps: undefined, + onSelect: vi.fn(), + onMultiSelect: vi.fn(), + onStepChange: vi.fn(), + onStepAnswer: vi.fn(), + ...overrides, + }; +} + +describe("useActionSelectorState.selectCurrent", () => { + it("does not throw when there are no options (empty allOptions)", () => { + const { result } = renderHook(() => useActionSelectorState(makeProps())); + + // Pressing Enter with an empty option list must be a no-op, not a crash. + expect(() => act(() => result.current.selectCurrent())).not.toThrow(); + }); + + it("does not throw when the highlighted index is stale after options shrink", () => { + const options: SelectorOption[] = [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ]; + const { result, rerender } = renderHook( + (props: Parameters[0]) => + useActionSelectorState(props), + { initialProps: makeProps({ options }) }, + ); + + // Move the highlight to the last option, then remove every option. + act(() => result.current.moveUp()); + rerender(makeProps({ options: [] })); + + expect(() => act(() => result.current.selectCurrent())).not.toThrow(); + }); + + it("still selects the highlighted option when one exists", () => { + const onSelect = vi.fn(); + const options: SelectorOption[] = [{ id: "approve", label: "Approve" }]; + const { result } = renderHook(() => + useActionSelectorState(makeProps({ options, onSelect })), + ); + + act(() => result.current.selectCurrent()); + + expect(onSelect).toHaveBeenCalledWith("approve"); + }); +}); diff --git a/packages/ui/src/primitives/action-selector/useActionSelectorState.ts b/packages/ui/src/primitives/action-selector/useActionSelectorState.ts index 42c7a21239..d36573fd14 100644 --- a/packages/ui/src/primitives/action-selector/useActionSelectorState.ts +++ b/packages/ui/src/primitives/action-selector/useActionSelectorState.ts @@ -259,6 +259,7 @@ export function useActionSelectorState({ const selectCurrent = useCallback(() => { const selected = allOptions[selectedIndex]; + if (!selected) return; if (isSubmitOption(selected.id)) { if (!showSubmitButton) {