From 3b8cc3e03f967257527018cdf2138054dcf116f5 Mon Sep 17 00:00:00 2001 From: Tung Lam Date: Tue, 15 Sep 2026 06:35:16 +0000 Subject: [PATCH] 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.js | 108 ++++++++++++++++++ .../src/components/Editor/index.tsx | 14 ++- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-donkeys-refocus.md create mode 100644 packages/react-live/src/components/Editor/index.test.js diff --git a/.changeset/tidy-donkeys-refocus.md b/.changeset/tidy-donkeys-refocus.md new file mode 100644 index 0000000..3f93aa8 --- /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.js b/packages/react-live/src/components/Editor/index.test.js new file mode 100644 index 0000000..0d15c63 --- /dev/null +++ b/packages/react-live/src/components/Editor/index.test.js @@ -0,0 +1,108 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import Editor from "./index"; + +// React 18 only treats `act` as supported when this flag is set. +global.IS_REACT_ACT_ENVIRONMENT = true; + +const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 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(async () => { + 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(async () => { + 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(async () => { + 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(async () => root.unmount()); + document.body.removeChild(container); +}; + +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. See FormidableLabs/react-live#415. + 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 6553d54..b312416 100644 --- a/packages/react-live/src/components/Editor/index.tsx +++ b/packages/react-live/src/components/Editor/index.tsx @@ -1,5 +1,5 @@ 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 = { @@ -24,6 +24,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) => {