From 0a5653c87a07c800228dba71ab3e599e9b716103 Mon Sep 17 00:00:00 2001 From: Tung Lam Date: Thu, 24 Sep 2026 01:36:14 +0000 Subject: [PATCH 1/2] fix: keep focus in the standalone Editor after the first keystroke use-editable keys its editing setup on the element ref, which is null on the first render. LiveProvider re-renders when its initial transpile resolves, so the setup settles before the user can type. Standalone Editor does not re-render until the first edit, so that edit tears the contenteditable surface down and rebuilds it: `contentEditable` is reset, which blurs the element in Chrome, and the setup's focus() runs while the element is still non-editable. Re-render once after mount so the setup settles before the first edit. Fixes #415 --- .changeset/tidy-donkeys-refocus.md | 5 + .../src/components/Editor/index.test.jsx | 104 ++++++++++++++++++ .../src/components/Editor/index.tsx | 14 ++- 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-donkeys-refocus.md diff --git a/.changeset/tidy-donkeys-refocus.md b/.changeset/tidy-donkeys-refocus.md new file mode 100644 index 00000000..3f93aa8d --- /dev/null +++ b/.changeset/tidy-donkeys-refocus.md @@ -0,0 +1,5 @@ +--- +"react-live": patch +--- + +Fix the standalone `Editor` losing focus after the first keystroke. `use-editable` rebuilt its editing surface on the first edit's re-render, which reset `contentEditable` and dropped focus; `Editor` now settles that surface before it can be edited. diff --git a/packages/react-live/src/components/Editor/index.test.jsx b/packages/react-live/src/components/Editor/index.test.jsx index f3b882c5..e233e4a5 100644 --- a/packages/react-live/src/components/Editor/index.test.jsx +++ b/packages/react-live/src/components/Editor/index.test.jsx @@ -1,3 +1,6 @@ +import { setTimeout } from "node:timers/promises"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; import { render } from "@testing-library/react"; import { Prism, themes } from "prism-react-renderer"; @@ -10,6 +13,75 @@ import Editor from "./index"; * CONTRIBUTING. */ +const settle = () => act(() => setTimeout(0)); + +const textNodes = (element) => { + const nodes = []; + const walk = (node) => { + if (node.nodeType === Node.TEXT_NODE) nodes.push(node); + node.childNodes.forEach(walk); + }; + walk(element); + return nodes; +}; + +// The editor renders a trailing newline text node after the code, so the caret +// goes at the end of the last text node that actually holds source. +const codeTextNode = (element) => + textNodes(element) + .filter((node) => node.textContent.trim() !== "") + .pop(); + +const renderEditor = async (props) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(() => { + root.render(); + }); + + const pre = container.querySelector("pre"); + // jsdom does not treat the contenteditable property that use-editable sets as + // focusable, so give the element a tabIndex in order to focus it. + pre.setAttribute("tabindex", "0"); + + await act(() => { + pre.focus(); + const node = codeTextNode(pre); + const range = document.createRange(); + range.setStart(node, node.textContent.length); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + // use-editable records the caret from the browser's selectstart event. + pre.dispatchEvent(new Event("selectstart", { bubbles: true })); + }); + + return { container, root, pre }; +}; + +// use-editable reacts to the DOM mutation the browser makes while typing and +// flushes it on keyup, so replicating that sequence exercises the same path. +const typeCharacter = async (element, character) => { + await act(() => { + codeTextNode(element).textContent += character; + element.dispatchEvent( + new KeyboardEvent("keydown", { key: character, bubbles: true }), + ); + element.dispatchEvent( + new KeyboardEvent("keyup", { key: character, bubbles: true }), + ); + }); + await settle(); +}; + +const unmount = async ({ container, root }) => { + await act(() => root.unmount()); + document.body.removeChild(container); +}; + describe("Editor", () => { it("renders the code as highlighted tokens", () => { const { container } = render( @@ -102,4 +174,36 @@ describe("Editor", () => { withDefault.container.querySelector("pre .token.keyword"), ).toBeNull(); }); + + // Regression test for #415. + it("keeps the editing surface intact across the first edit", async () => { + const rendered = await renderEditor({ code: "abc" }); + const { container, pre } = rendered; + + // jsdom has no contentEditable editing model, so it cannot reproduce the + // focus loss itself. What it can observe is the cause: use-editable keys its + // setup on the element ref, which is null for the first render, so a + // re-render rebuilds the surface -- resetting `contentEditable`, which blurs + // the element in Chrome, and calling `focus()` while it is still + // non-editable, which silently fails. No re-render on the first edit means + // no teardown. + let contentEditable = pre.contentEditable; + const contentEditableWrites = []; + Object.defineProperty(pre, "contentEditable", { + configurable: true, + get: () => contentEditable, + set: (value) => { + contentEditableWrites.push(value); + contentEditable = value; + }, + }); + + await typeCharacter(pre, "X"); + + expect(container.querySelector("pre")).toBe(pre); + expect(pre.textContent).toBe("abcX\n"); + expect(contentEditableWrites).toEqual([]); + + await unmount(rendered); + }); }); diff --git a/packages/react-live/src/components/Editor/index.tsx b/packages/react-live/src/components/Editor/index.tsx index 43c78b05..8a847d23 100644 --- a/packages/react-live/src/components/Editor/index.tsx +++ b/packages/react-live/src/components/Editor/index.tsx @@ -3,7 +3,7 @@ duplicate lines and would remount nodes inside a contentEditable on every edit, which is exactly where DOM churn is least welcome. */ import { Highlight, Prism, themes } from "prism-react-renderer"; -import { CSSProperties, useEffect, useRef, useState } from "react"; +import { CSSProperties, useEffect, useReducer, useRef, useState } from "react"; import { useEditable } from "use-editable"; export type Props = { @@ -28,6 +28,18 @@ const CodeEditor = (props: Props) => { setCode(props.code); }, [props.code]); + // use-editable keys its editing setup on the element ref, which is still null + // during the first render. Standalone Editor does not re-render between + // mounting and the first edit, so that setup only reaches the real element + // when the edit re-renders: it tears down and rebuilds the contenteditable + // surface, resetting `contentEditable` (which blurs the element in Chrome) + // and calling focus() while the element is still non-editable. LiveProvider + // re-renders when its initial transpile resolves, which is why LiveEditor is + // unaffected. Re-render once after mount so the setup settles beforehand. + // See #415. + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(forceRender, []); + useEditable( editorRef, (text) => { From ea046a79de01c43e66b7b80225ed64c40d2be27d Mon Sep 17 00:00:00 2001 From: Tung Lam Date: Fri, 25 Sep 2026 09:08:53 +0000 Subject: [PATCH 2/2] test: import act from @testing-library/react React.act only exists from 18.3.0, so importing it from "react" broke the React 18.0.0 compat job with "act is not a function". The testing library's re-export works across the peer range, and the other tests here already use it. --- packages/react-live/src/components/Editor/index.test.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-live/src/components/Editor/index.test.jsx b/packages/react-live/src/components/Editor/index.test.jsx index e233e4a5..601ef53b 100644 --- a/packages/react-live/src/components/Editor/index.test.jsx +++ b/packages/react-live/src/components/Editor/index.test.jsx @@ -1,7 +1,6 @@ import { setTimeout } from "node:timers/promises"; -import { act } from "react"; import { createRoot } from "react-dom/client"; -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import { Prism, themes } from "prism-react-renderer"; import Editor from "./index";