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) {