Repository navigation
Description
Summary
Pressing Escape during IME composition in the desktop preview's annotation comment removes the annotation editor and cancels the in-progress pick. The composing key should leave the draft intact so it can dismiss the IME candidate. A focused preload DOM test reproduced cancellation while a plain Escape control passed; Impact is P2? because delivery of that key event by specific OS IMEs was not tested.
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/desktop
Steps to reproduce
- In a checkout of the commit under Version with dependencies installed, save the script below as
repro.shand runsh repro.sh "$PWD"from the repository root. - The script starts an annotation through the preview preload channel, focuses its comment textarea, and sends a plain Escape as a control. It starts a fresh overlay and sends a composing Escape to the same textarea, checking that the editor and draft remain.
The final script reproduced the failure on four runs: the control passed and the composing case failed each time. Two earlier harness attempts were inconclusive because the temporary test ran in the default Node environment (ReferenceError: window is not defined); the script below selects jsdom with a test-file directive. A native OS IME was not run. The script requires the installed Vite+ workspace dependencies, prints the Vite+ and Node versions, creates disposable state, removes its temporary test file, and exits 0 for expected behavior, 1 for the reproduced defect, or 2 for an inconclusive setup or control.
Reproduction script: repro.sh
#!/bin/sh
set -u
inconclusive() {
printf 'RESULT: inconclusive\n'
exit 2
}
checkout=${1-}
test -d "$checkout/apps/desktop/src/preview" || inconclusive
vp_cmd=$(command -v vp) || inconclusive
scratch=$(mktemp -d) || inconclusive
test_file="$checkout/apps/desktop/src/preview/PickPreload.x08-01-$$.test.ts"
test ! -e "$test_file" || inconclusive
cleanup() {
rm -f "$test_file"
rm -rf "$scratch"
}
trap cleanup EXIT HUP INT TERM
mkdir "$scratch/home" "$scratch/tmp" "$scratch/state" || inconclusive
export HOME="$scratch/home" TMPDIR="$scratch/tmp" T3CODE_HOME="$scratch/state" NO_COLOR=1
cd "$checkout" || inconclusive
"$vp_cmd" --version || inconclusive
cat > "$test_file" <<'TEST'
// @vitest-environment jsdom
import { expect, it, vi } from "vite-plus/test";
import { START_PICK_CHANNEL } from "./GuestProtocol.ts";
import "./PickPreload.ts";
const listeners = vi.hoisted(() => new Map<string, Array<(...args: unknown[]) => void>>());
vi.mock("electron", () => ({
ipcRenderer: {
on: (channel: string, listener: (...args: unknown[]) => void) => {
listeners.set(channel, [...(listeners.get(channel) ?? []), listener]);
},
off: () => {},
send: () => {},
invoke: async () => undefined,
},
}));
vi.mock("react-grab/primitives", () => ({ getElementContext: async () => null }));
const start = (): { host: Element; comment: HTMLTextAreaElement } => {
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
using attachShadow = vi.spyOn(Element.prototype, "attachShadow");
for (const listener of listeners.get(START_PICK_CHANNEL) ?? []) listener({}, undefined, false);
const root = attachShadow.mock.results[0]?.value;
if (!(root instanceof ShadowRoot)) throw new Error("overlay did not start");
const comment = root.querySelector("textarea");
if (!comment) throw new Error("comment field missing");
return { host: root.host, comment };
};
it("plain Escape cancels annotation", () => {
const { host, comment } = start();
comment.focus();
comment.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape", bubbles: true, composed: true, cancelable: true,
}));
expect(host.isConnected).toBe(false);
});
it("composing Escape preserves annotation", () => {
const { host, comment } = start();
comment.value = "draft";
comment.focus();
comment.dispatchEvent(new KeyboardEvent("keydown", {
key: "Escape", isComposing: true, bubbles: true, composed: true, cancelable: true,
}));
expect(host.isConnected).toBe(true);
expect(comment.value).toBe("draft");
});
TEST
relative_test=apps/desktop/src/preview/$(basename "$test_file")
"$vp_cmd" test run "$relative_test" -t "plain Escape" > "$scratch/control.log" 2>&1
control_status=$?
"$vp_cmd" test run "$relative_test" -t "composing Escape" > "$scratch/defect.log" 2>&1
defect_status=$?
printf 'CONTROL exit=%s\n' "$control_status"
tail -n 8 "$scratch/control.log"
printf 'COMPOSITION exit=%s\n' "$defect_status"
tail -n 28 "$scratch/defect.log"
if [ "$control_status" -ne 0 ]; then
printf 'RESULT: inconclusive\n'
exit 2
fi
if [ "$defect_status" -ne 0 ] && grep -q 'composing Escape preserves annotation' "$scratch/defect.log" && grep -q 'expected false to be true' "$scratch/defect.log"; then
printf 'RESULT: defect reproduced\n'
exit 1
fi
if [ "$defect_status" -eq 0 ]; then
printf 'RESULT: expected behavior\n'
exit 0
fi
printf 'RESULT: inconclusive\n'
exit 2Expected behavior
A composing Escape in the focused comment should leave the annotation editor, its selections or drawings, and the comment draft intact. Plain Escape should cancel annotation mode, as its tooltip states (apps/web/src/components/preview/PreviewChromeRow.tsx:238-240). The preview preload itself excludes composing keys from dictation shortcuts (apps/desktop/src/preview/PickPreload.ts:1672), its Enter submission helper excludes them (apps/desktop/src/preview/AnnotationKeyboard.ts:15), and the web command palette excludes composing Escape from its back action (apps/web/src/shortcuts/commandPaletteCommands.ts:53, with a browser test at :58-99 in the corresponding test file).
Actual behavior
In a focused DOM run of the production preload, a keydown on the annotation comment textarea with key: "Escape" and isComposing: true detached the overlay host. The same test harness confirmed that ordinary Escape detaches it as the UI promises. In source, the composing event takes the unconditional Escape branch, which calls teardown(true); that removes the overlay and sends a null pick result to the desktop host.
Impact and scope
Impact: P2? - if a user's IME delivers a composing Escape keydown to the page, cancelling a candidate loses the in-progress desktop preview annotation and requires the user to rebuild it. The focused synthetic event establishes the preload response; actual OS IME delivery and the frequency among users remain unverified.
This affects the desktop in-app preview annotation editor when its comment textarea is focused. The loss is confined to the unsent annotation; no persisted data was observed to change. Avoiding Escape while composing may bypass the trigger, but that workaround was not tested with a native IME.
Earliest failure stage
Input parsing or validation
Analysis or suspected cause
At the pinned commit, the supported UI entry point is apps/web/src/components/preview/PreviewChromeRow.tsx:218-240 through PreviewView.tsx:783-839, apps/desktop/src/preview/Manager.ts:2384-2407, and the preload's START_PICK_CHANNEL handler at apps/desktop/src/preview/PickPreload.ts:1828-1842. The early overlay key listener at PickPreload.ts:229-239 forwards a keydown from the overlay to handleOverlayKey at :1778-1790; onKeyDown at :1689-1695 handles every Escape before checking text focus or composition. teardown at :1626-1659 removes the overlay and reports cancellation. The PickPreload.ts blob on upstream main matched this checkout when checked, and the newest upstream commit for that path was 6505756; no later change to that file had fixed the branch.
Additional context
The closed #32970 asks for plain Escape to cancel annotation mode. The closed #20364 describes other comment keystrokes reaching page shortcuts; its fix #35431 was merged and was the latest change to this preload file at verification. Searches of open and closed issues and PRs for desktop preview, annotation, IME, Escape, and composition found no exact open report or open fix.
Evidence and verification
Focused production-preload DOM reproduction on the pinned commit: the plain Escape control passed, and the composing Escape check failed in four final-script runs. The observed event was synthetic; a native OS IME or Electron session was not run. The expected behavior is supported by the preview's plain Escape tooltip and existing composition guards in adjacent keyboard handlers. A restoration check is that the same script prints RESULT: expected behavior with exit 0 while retaining the plain Escape control.
Impact
Minor bug or occasional failure
Version or commit
main @ c00a373
Environment
macOS Darwin arm64; Node.js v26.12.1; Vite+ CLI v1.5.1; workspace Vite+ v1.4.0 and Vitest v5.0.5 with jsdom. Focused preload DOM test only; no native Electron or IME session.
Logs or stack traces
The final run printed the following excerpt. The generated test filename's process suffix was replaced with <pid>; the checkout path in the test runner's RUN line was omitted.
vp v1.5.1
vite-plus v1.4.0
vitest v5.0.5
Node.js v26.12.1 (engines.node)
CONTROL exit=0
Test Files 1 passed (1)
Tests 1 passed | 1 skipped (2)
COMPOSITION exit=1
FAIL |@t3tools/desktop (unit)| src/preview/PickPreload.x08-01-<pid>.test.ts > composing Escape preserves annotation
AssertionError: expected false to be true // Object.is equality
- Expected
+ Received
- true
+ false
47| expect(host.isConnected).toBe(true);
RESULT: defect reproducedScreenshots, recordings, or supporting files
No response
Workaround
No response
References
- #32970 is closed and concerns plain Escape failing to cancel before page focus, whereas this report concerns a composing Escape cancelling while the comment is focused.
- #20364 is closed and concerns comment keystrokes reaching page shortcuts; its merged fix #35431 left the composing Escape path described above.
Activity