#3385 · Keyboard event submits twice

BugPriority: MediumEffort: Lowui · tasksGitHub issue

2026-09-10 · trusted main commit 04f4e6a21c1efa6d82d9d62a094b87796c36439c

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

One modified Enter keypress in the new-task title field sends two createTask requests. The title handler submits, then the same event bubbles to the dialog's shortcut handler and submits again. React's pending state change does not update either handler's captured canSubmit value during that event. Both Ctrl+Enter and Meta+Enter reproduce; plain Enter and modified Enter outside the title field send one request.

2. Claims vs findings

ClaimFindingEvidence
Ctrl+Enter in the title duplicates creationVerified at RPC boundaryTwo clean runs each count 2 requests instead of 1.
Plain Enter and Ctrl+Enter elsewhere submit onceVerifiedTitle plain Enter and due-date Ctrl+Enter controls pass in both runs.
Button click submits onceVerifiedExisting management test passes in the full post-fix suite.
Separate persisted task numbersSource-supported; not observed liveEach API invocation inserts a task and allocates a number; the UI reproduction uses an RPC fixture.
The number allocator causes the duplicateRuled out as originTwo calls already exist at the UI boundary before any database operation.

3. Environment

bb 0.42.1 source, macOS Darwin arm64, Node v22.22.3, pnpm 10.34.4, Vitest 4.1.1 and jsdom. Two separate temporary clones named base and verify, both at the commit above. Frozen installs succeeded. The base full build passed (20 tasks); the verification checkout's Tasks build passed (6 tasks). No servers, ports, data stores, providers, or production runtime data were used. A broken host pnpm launcher was bypassed with a temporary Corepack shim; no repository dependency changed.

4. Minimal reproduction

  1. Download the regression patch, which only adds tests to an existing suite.
  2. Run the following in a fresh checkout:
git clone https://github.com/get-bb/bb.git bb-3385
cd bb-3385
git checkout --detach 04f4e6a21c1efa6d82d9d62a094b87796c36439c
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
git apply /path/to/regression.patch
pnpm exec turbo run test --force --filter=bb-plugin-tasks -- views/manage/manage.test.tsx -t 'submits exactly once'

The test opens New task through the real plugin panel, enters a title, dispatches the keyboard event, waits for navigation, and counts captured createTask RPC requests. Its control cases vary the modifier and target field.

Expected: each case sends one request. Actual in both clean runs:

FAIL Ctrl+Enter in title
FAIL Meta+Enter in title
AssertionError: expected [ { …(8) }, { …(8) } ] to have a length of 1 but got 2
Tests  2 failed | 2 passed | 27 skipped (31)

Complete runnable test file (existing harness included). Added regression cases:

  it.each([
    { name: "Ctrl+Enter in title", field: "Task title", ctrlKey: true, metaKey: false },
    { name: "Meta+Enter in title", field: "Task title", ctrlKey: false, metaKey: true },
    { name: "Enter in title", field: "Task title", ctrlKey: false, metaKey: false },
    { name: "Ctrl+Enter in due date", field: "Due date", ctrlKey: true, metaKey: false },
  ])("submits exactly once for $name", async ({ field, ctrlKey, metaKey }) => {
    const createCalls: Array<Record<string, unknown>> = [];
    const slot = renderSlot(
      app.navPanels[0]!,
      { subPath: PROJECT_ID },
      {
        rpc: {
          listProjects: () => ({ projects: [project] }),
          listFolders: () => ({ folders: [] }),
          listPresets: () => ({ presets: [] }),
          sidebarSummary: () => ({ projects: [] }),
          listTasks: () => ({ tasks: [] }),
          listLabels: () => ({ labels: [] }),
          createTask: (input: Record<string, unknown>) => {
            createCalls.push(input);
            return { ok: true, task: createdTask(input) };
          },
        },
      },
    );
    fireEvent.click(await slot.findByRole("button", { name: /New task/ }));
    fireEvent.change(await slot.findByLabelText("Task title"), {
      target: { value: "Keyboard submission" },
    });
    fireEvent.keyDown(slot.getByLabelText(field), {
      key: "Enter",
      ctrlKey,
      metaKey,
    });
    await waitFor(() => expect(slot.navigateCalls).not.toHaveLength(0));
    expect(createCalls).toHaveLength(1);
    expect(createCalls[0]).toMatchObject({ title: "Keyboard submission" });
  });

5. Root cause

The title input's Enter handler prevents the browser default and calls submit, but does not stop event propagation. The dialog's Ctrl/Meta+Enter handler receives that same event and submits again. canSubmit and submit use render-time state: setSubmitting(true) schedules a render, so the parent handler still sees canSubmit=true.

title keydown → submit → setSubmitting(true) → createTask
same event bubbles → dialog keydown → submit → createTask

The API creates a task for each invocation. The store allocates a number and inserts each task; it does not deduplicate matching titles. This explains the reported separate numbers, although persisted rows were not exercised in this reproduction.

6. Proposed fix

Stop propagation immediately after preventing the default in the title's existing Enter handler. This gives the consumed title event one submission owner and preserves the dialog shortcut for other fields. The local fix is one production line plus four regression cases: 60 additions, zero deletions, two files in Tasks. It changes no public contract or stored data. The complete Tasks suite passes: 35 files, 368 tests; Tasks typecheck also passes.

7. Verification

The same agent repeated the reproduction in a second clean temporary clone at the identical trusted commit. Only the regression test patch was applied. A frozen install and targeted Tasks build preceded the test command above with --force, so the failing run was executed rather than replayed from cache. The second run again failed the Ctrl and Meta title cases (2 requests), while both controls passed. No report correction was needed. This was a repeat by the same agent, not independent verification.

No live-browser or end-to-end database reproduction was performed; this is a deterministic UI event/RPC reproduction. It is a behavioral bug rather than a visual rendering defect, so there are no screenshots.

8. Related issues and pull requests

No linked open pull request was found in issue timeline metadata or the open-PR search. Issue #2654 describes a separate CLI duplicate-creation path; it is not evidence for this keyboard event bug.

9. Appendix

Post-fix command: pnpm exec turbo run test typecheck --filter=bb-plugin-tasks. Formatting was applied only to the added tests; the management suite was rerun afterward. git diff --check passed. Source and metadata were read from the trusted repository; issue content was treated only as claims, and no issue-provided code or instructions were executed.

> AGENT GENERATED