From f27b46d59f8de8af2d517642d3432a6af6153fe3 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 2 Nov 2026 13:48:45 +0100 Subject: [PATCH 1/4] fix(core): ignore presentation attribute mutations on block wrappers --- packages/core/src/pm-nodes/BlockContainer.ts | 10 +- packages/core/src/pm-nodes/BlockGroup.ts | 4 + .../createBlockNodeView.browser.test.ts | 39 +++++ .../core/src/pm-nodes/createBlockNodeView.ts | 42 +++++ .../schema/ReactBlockSpec.browser.test.tsx | 148 ++++++++++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/pm-nodes/createBlockNodeView.browser.test.ts create mode 100644 packages/core/src/pm-nodes/createBlockNodeView.ts create mode 100644 packages/react/src/schema/ReactBlockSpec.browser.test.tsx diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 2b1e6e674a..f764158fc1 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -13,6 +13,7 @@ import { } from "../schema/blocks/frame.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; +import { isBlockWrapperMutation } from "./createBlockNodeView.js"; import { suggestionMarks } from "./suggestionMarks.js"; /** The block props whose colors `Block.css` applies to a block's children. */ @@ -169,6 +170,7 @@ export const BlockContainer = Node.create<{ setBlockColorAttributes(block, attrs); } + const frameHooks = frameNodeViewHooks(frame); const nodeView: NodeView = { dom, contentDOM: frame.slot, @@ -192,7 +194,13 @@ export const BlockContainer = Node.create<{ } return true; }, - ...frameNodeViewHooks(frame), + ...frameHooks, + ignoreMutation(mutation) { + return ( + isBlockWrapperMutation(mutation, dom, block) || + frameHooks.ignoreMutation(mutation) + ); + }, }; return nodeView; }) as unknown as NodeViewRenderer; diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts index bba1d84f4d..0afe459696 100644 --- a/packages/core/src/pm-nodes/BlockGroup.ts +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -2,6 +2,7 @@ import { Node } from "@tiptap/core"; import { CHILD_CONTAINER_GROUP } from "../schema/blocks/children.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; +import { createBlockNodeView } from "./createBlockNodeView.js"; import { suggestionMarks } from "./suggestionMarks.js"; export const BlockGroup = Node.create<{ @@ -13,6 +14,9 @@ export const BlockGroup = Node.create<{ marks() { return suggestionMarks(this.editor); }, + addNodeView() { + return createBlockNodeView; + }, parseHTML() { return [ { diff --git a/packages/core/src/pm-nodes/createBlockNodeView.browser.test.ts b/packages/core/src/pm-nodes/createBlockNodeView.browser.test.ts new file mode 100644 index 0000000000..c9950797b1 --- /dev/null +++ b/packages/core/src/pm-nodes/createBlockNodeView.browser.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "vite-plus/test"; + +import { isBlockWrapperMutation } from "./createBlockNodeView.js"; + +it("ignores only attributes on the structural wrappers", () => { + const dom = document.createElement("div"); + const innerWrapper = dom.appendChild(document.createElement("div")); + const content = innerWrapper.appendChild(document.createElement("a")); + const text = content.appendChild(document.createTextNode("Before")); + const observer = new MutationObserver(() => {}); + observer.observe(dom, { + subtree: true, + attributes: true, + childList: true, + characterData: true, + }); + try { + dom.setAttribute("data-ui-state", "active"); + innerWrapper.setAttribute("class", "ui-active"); + content.setAttribute("href", "https://example.com/after"); + text.data = "After"; + innerWrapper.appendChild(document.createElement("p")); + + expect( + observer + .takeRecords() + .map((mutation) => isBlockWrapperMutation(mutation, dom, innerWrapper)), + ).toEqual([true, true, false, false, false]); + expect( + isBlockWrapperMutation( + { type: "selection", target: dom }, + dom, + innerWrapper, + ), + ).toBe(false); + } finally { + observer.disconnect(); + } +}); diff --git a/packages/core/src/pm-nodes/createBlockNodeView.ts b/packages/core/src/pm-nodes/createBlockNodeView.ts new file mode 100644 index 0000000000..93bc0d17d2 --- /dev/null +++ b/packages/core/src/pm-nodes/createBlockNodeView.ts @@ -0,0 +1,42 @@ +import type { NodeViewRendererProps } from "@tiptap/core"; +import { DOMSerializer } from "@tiptap/pm/model"; +import type { NodeView, ViewMutationRecord } from "@tiptap/pm/view"; + +export function isBlockWrapperMutation( + mutation: ViewMutationRecord, + dom: globalThis.Node, + innerWrapper: globalThis.Node | undefined, +): boolean { + // Structural wrappers belong to the node view. Their attributes are rendering; + // document edits belong in their content or must use editor commands. + // Rereading wrapper attributes dirties and recreates their descendants. + // Observe all content mutations, including attributes on descendant elements + // and native mobile paragraph splits next to the content DOM. + return ( + mutation.type === "attributes" && + (mutation.target === dom || mutation.target === innerWrapper) + ); +} + +/** Keep the normal schema rendering and mutation handling for block wrappers. */ +export function createBlockNodeView({ + node, + view, +}: NodeViewRendererProps): NodeView { + const toDOM = node.type.spec.toDOM; + if (!toDOM) { + throw new Error(`Missing DOM renderer for ${node.type.name}`); + } + const { dom, contentDOM } = DOMSerializer.renderSpec( + view.dom.ownerDocument, + toDOM(node), + ); + + return { + dom, + contentDOM, + ignoreMutation(mutation) { + return isBlockWrapperMutation(mutation, dom, contentDOM); + }, + }; +} diff --git a/packages/react/src/schema/ReactBlockSpec.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.browser.test.tsx new file mode 100644 index 0000000000..4eb1c0c3ef --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.browser.test.tsx @@ -0,0 +1,148 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { useEffect, useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +it.each([false, true])( + "keeps custom block state across wrapper attribute changes, framed=%s", + async (framed) => { + let mounts = 0; + const stateful = createReactBlockSpec( + { type: "stateful", propSchema: {}, content: "none" }, + { + renderFrame: framed + ? function Frame({ slotRef }) { + return ( +
+
+
+ ); + } + : undefined, + render: function StatefulBlock() { + const [count, setCount] = useState(0); + useEffect(() => { + mounts++; + }, []); + return ( + + ); + }, + }, + )(); + const schema = BlockNoteSchema.create().extend({ + blockSpecs: { stateful }, + }); + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { type: "paragraph", content: "Before" }, + { type: "stateful", children: [{ type: "stateful" }] }, + { type: "paragraph", content: "After" }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + try { + flushSync(() => { + root.render( + , + ); + }); + await expect.poll(() => host.querySelectorAll("button").length).toBe(2); + expect(host.querySelectorAll(".stateful-frame").length).toBe( + framed ? 2 : 0, + ); + const button = host.querySelector("button")!; + flushSync(() => button.click()); + expect(button.textContent).toBe("Count: 1"); + const initialMounts = mounts; + const documentBefore = editor.document; + + // UI libraries and application chrome can change any wrapper attribute. + // The boundary is the wrapper element, not a list of known attributes. + for (const [attribute, value] of [ + ["aria-hidden", "true"], + ["aria-label", "Editor block"], + ["inert", ""], + ["data-ui-state", "active"], + ["class", "ui-active"], + ["style", "font-weight: bold"], + ]) { + for (const selector of [ + ".bn-block", + ".bn-block-outer", + ".bn-block-group", + ]) { + const containers = Array.from(host.querySelectorAll(selector)); + const originals = containers.map((element) => + element.getAttribute(attribute), + ); + for (const changed of [true, false]) { + for (const [index, container] of containers.entries()) { + const original = originals[index]; + if (changed) { + container.setAttribute( + attribute, + attribute === "class" ? `${original ?? ""} ${value}` : value, + ); + } else if (original === null) { + container.removeAttribute(attribute); + } else { + container.setAttribute(attribute, original); + } + } + // Allow MutationObserver delivery and ProseMirror's deferred DOM read. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(host.querySelector("button")).toBe(button); + expect(button.textContent).toBe("Count: 1"); + expect(mounts).toBe(initialMounts); + expect(editor.document).toEqual(documentBefore); + } + } + } + + editor.insertBlocks( + [{ type: "paragraph", content: "Inserted" }], + editor.document[0], + "after", + ); + await expect.poll(() => host.textContent).toContain("Inserted"); + expect(host.querySelector("button")).toBe(button); + expect(button.textContent).toBe("Count: 1"); + + // Native content edits must still reach the document through DOM parsing. + host.querySelector(".bn-inline-content")!.textContent = "Native edit"; + await expect + .poll(() => editor.document[0]) + .toMatchObject({ + content: [{ type: "text", text: "Native edit" }], + }); + host.querySelector(".bn-inline-content")!.firstChild!.nodeValue = + "Typed edit"; + await expect + .poll(() => editor.document[0]) + .toMatchObject({ + content: [{ type: "text", text: "Typed edit" }], + }); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); From 382a7f137fc81f275a2b51a10f996e9c0c8bfa08 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 3 Nov 2026 16:30:34 +0100 Subject: [PATCH 2/4] fix: preserve custom content during mobile editing --- .claude/skills/ios-simulator/SKILL.md | 162 ++++++++++++++ .../references/native-debugging.md | 134 +++++++++++ .../skills/ios-simulator/scripts/device.mjs | 208 ++++++++++++++++++ .../splitBlock/splitBlock.inline.node.test.ts | 84 +++++++ .../commands/splitBlock/splitBlock.ts | 11 +- .../InlineContentBoundaryEdit.ts | 5 +- .../KeyboardShortcutsExtension.ts | 3 + .../core/src/schema/inlineContent/internal.ts | 20 +- packages/react/src/schema/ReactBlockSpec.tsx | 33 ++- .../mobile/editableInlineEnter.test.tsx | 107 +++++++++ .../mobile/reactBlockEnter.test.tsx | 76 +++++++ .../end-to-end/mobile/shiftBackspace.test.tsx | 43 ++++ 12 files changed, 873 insertions(+), 13 deletions(-) create mode 100644 .claude/skills/ios-simulator/SKILL.md create mode 100644 .claude/skills/ios-simulator/references/native-debugging.md create mode 100644 .claude/skills/ios-simulator/scripts/device.mjs create mode 100644 packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.inline.node.test.ts create mode 100644 tests/src/end-to-end/mobile/editableInlineEnter.test.tsx create mode 100644 tests/src/end-to-end/mobile/reactBlockEnter.test.tsx create mode 100644 tests/src/end-to-end/mobile/shiftBackspace.test.tsx diff --git a/.claude/skills/ios-simulator/SKILL.md b/.claude/skills/ios-simulator/SKILL.md new file mode 100644 index 0000000000..0fb1178dcb --- /dev/null +++ b/.claude/skills/ios-simulator/SKILL.md @@ -0,0 +1,162 @@ +--- +name: ios-simulator +description: Use when validating BlockNote behavior that Vitest browser emulation cannot reproduce, especially iOS Safari software-keyboard input, native selection, autocapitalization, focus, or viewport behavior. Includes Appium setup, model/DOM verification, and simulator recordings. +--- + +# iOS simulator testing for BlockNote + +## Choose the right layer + +Try a red-first Vitest browser-mode regression in Docker first; follow +`../testing-skill/SKILL.md`. Never run the repository's browser suite natively. +Use the simulator for the OS-owned half that emulation cannot establish, or when +the user explicitly requests native validation. A simulator recording is evidence, +not automated regression coverage or proof of physical-device/iPad/IME behavior. + +## Prerequisites + +- macOS, full Xcode selected, an installed iOS runtime, and Node.js/pnpm. +- Docker for browser tests. Use only `vp` / `pnpm`, not npm/yarn. +- A local Safari-accessible dev server from **this checkout**. Another worktree + may own the usual port; verify the URL rather than assuming port 5173. +- An approved external temporary directory for sessions, logs, captures and driver + installation. Keep generated evidence out of the PR checkout. + +Read `references/native-debugging.md` for live editor inspection, input controls, and +failure diagnosis. Check current Appium/XCUITest documentation when capabilities +or versions differ; the versions below are a verified pairing, not a latest claim. + +## 1. Use the dev server and minimal example + +Reuse a dev server already running for this checkout, or start one in a Paseo +terminal (or a managed background process): + +```sh +pnpm exec vp run --filter @blocknote/example-editor dev --host +``` + +Read the actual URL/port from the server output; let the server choose an available +port. This serves `playground`. Open its `basic/minimal` example, or the existing example +that demonstrates the reported behavior. Preserve its real UI and styling. Do not +create a diagnostic page or modify the example to expose globals. + +Set `PAGE_URL` to that example's actual URL. Verify source imports point to this +worktree. Do not stop another checkout's server. Record owned processes/sessions. + +The running editor is already accessible through TipTap's DOM reference: + +```js +document.querySelector(".ProseMirror").editor.schema.cached.blockNoteEditor; +``` + +The class name is case-sensitive. Use a specific editor selector on multi-editor +pages. Inspect through Appium execution without changing the application source. +This lookup was validated against mounted editors in Docker Chromium, Firefox, +and WebKit (six checks, including framed and unframed custom blocks), returning +the exact BlockNote editor instance. That check was not an iOS simulator run. + +## 2. Boot and start automation + +Set `IOS_ARTIFACTS` to a new directory under the approved temporary location, and +`SKILL_DIR` to the absolute path of this skill. Create the artifacts directory. + +```sh +xcrun simctl list devices available +xcrun simctl boot "$UDID" +xcrun simctl bootstatus "$UDID" -b +open -a Simulator +``` + +Choose `UDID` from the list; skip `boot` if already booted. First boot can take +minutes for migration. Do not start duplicate sessions while it is initializing. + +Appium **3.9.0 + XCUITest 12.22.8** worked with Xcode 26.4.1 / iOS 26.4: + +```sh +export APPIUM_HOME="$IOS_ARTIFACTS/appium" +pnpm dlx appium@3.9.0 driver install xcuitest@12.22.8 +pnpm dlx appium@3.9.0 server -p 4723 --log "$IOS_ARTIFACTS/appium.log" +``` + +Run the server in an owned terminal/background process; wait until listening. +Do not install drivers into the repository. Latest driver and Appium majors may +be incompatible (one latest driver required Appium 4 beta). + +```sh +node "$SKILL_DIR/scripts/device.mjs" create "$UDID" "$PAGE_URL" +``` + +The helper disables hardware-keyboard connection, sets Safari's initial URL, +allows 60 seconds for webviews, and sets script timeout to 30 seconds. Session +IDs are saved outside the checkout and become stale after session deletion. +Set `APPIUM_URL` if not using `http://127.0.0.1:4723`. + +## 3. Drive real input, inspect through WebKit + +**Serialize all commands to a session.** Save the WebView context, switch to +`NATIVE_APP` for gestures, then restore it for JS/model inspection. The helper's +native commands do this with `try/finally`. + +```sh +node "$SKILL_DIR/scripts/device.mjs" exec 'return !!document.querySelector(".ProseMirror")?.editor?.schema?.cached?.blockNoteEditor' +node "$SKILL_DIR/scripts/device.mjs" tap 110 145 +node "$SKILL_DIR/scripts/device.mjs" native-source +node "$SKILL_DIR/scripts/device.mjs" key Return +node "$SKILL_DIR/scripts/device.mjs" type After +node "$SKILL_DIR/scripts/device.mjs" key Delete +node "$SKILL_DIR/scripts/device.mjs" state "$IOS_ARTIFACTS/after.json" +node "$SKILL_DIR/scripts/device.mjs" screenshot "$IOS_ARTIFACTS/after.png" +``` + +Coordinates above are illustrative, **not portable**. Inspect native source or a +screenshot for this device/page. Native coordinates are logical points, not PNG +pixels or CSS coordinates; Safari chrome changes after the keyboard opens. + +A real editor tap must open the software keyboard before precise editor-API +selection setup. DOM `focus()` / synthetic clicks do not reliably open it. +`key` finds the native key's accessibility rectangle and taps its center. Use +actual Return/Delete/Shift taps for keyboard-origin investigations. `type` uses +native `/keys` for convenient text, not for proving modifier origin. +`mobile: keys` is not the iPhone substitute (documented as iPad-only). + +Wait for the editor DOM reference after navigating; navigation completion is not +module readiness. Use bounded polling/observable conditions, not arbitrary sleeps. +Capture immediate and settled states when investigating delayed DOM observation. + +## 4. Prove the outcome + +- Compare fixed and unfixed code with identical fixtures/input. Use isolated + snapshots or read-only Docker mounts; never discard user work. +- Check the **model**, DOM, selection, native events and errors. Visible suffix text + can be stranded outside the document model; a screenshot alone misses data loss. +- Count the intended wrapper boundary, not all descendant node-view elements: + nested editable inline views legitimately introduce additional wrappers. +- Continue typing/deleting after the action, not merely checking that it did not + throw. Verify text, marks, props, nesting and caret destination. +- Include a native paragraph/plain contenteditable control where relevant. Repeat + intermittent cases and report failures as well as passes; do not declare them + fixed after a passing rerun. +- Add the cheapest red-first regression test that can express the identified + contract. Explicit Shift in a test covers the handler, not OS autocapitalization. + +## 5. Record and hand off + +See the reference for recording commands and review checks. Record only the +simulator display, pause so results are readable, and keep paired model traces. +Do not upload videos, create PRs, or publish issues without authorization. + +Report device/iOS, exact native vs API/synthetic actions, before/after outcomes, +test results, limitations, and artifact paths. Keep unrelated failures visible. + +Respect the user's lifecycle preference: retain services for ongoing testing if +requested. Once cleanup is authorized, archive useful scripts and evidence +externally first. Remove only your temporary instrumentation/files; leave +production fixes/tests. Delete your Appium session with `device.mjs close`, stop +only owned servers, and shut down the **specific** device: + +```sh +xcrun simctl shutdown "$UDID" +``` + +Do not use `shutdown all`, kill unrelated processes, or close Simulator while +someone else is using it. diff --git a/.claude/skills/ios-simulator/references/native-debugging.md b/.claude/skills/ios-simulator/references/native-debugging.md new file mode 100644 index 0000000000..2dd0ed3f90 --- /dev/null +++ b/.claude/skills/ios-simulator/references/native-debugging.md @@ -0,0 +1,134 @@ +# Live editor inspection and native-input debugging + +## Inspect the existing example without modifying it + +Use the dev server's minimal example or an existing example with the relevant +schema/spec/skin. Do not create a new page or expose globals for inspection. A +minimal example does not reproduce bugs in UI/extensions it does not load. + +TipTap sets `view.dom.editor` when mounting; BlockNote caches itself on that +editor's schema. Both links were source-verified and the complete lookup returned +the exact mounted BlockNote instance in Docker Chromium, Firefox, and WebKit +(six checks across framed/unframed custom blocks). This validates the DOM lookup, +not the Appium/iOS execution path. Execute this read-only snapshot in Appium: + +```js +const root = document.querySelector(".ProseMirror"); +const tiptap = root.editor; +const editor = tiptap.schema.cached.blockNoteEditor; +const { from, to } = tiptap.state.selection; +return JSON.stringify({ + document: editor.document, + selection: { from, to }, + focused: tiptap.isFocused, + dom: root.innerHTML, +}); +``` + +The helper's `state` command performs this lookup; pass an editor selector as its +second argument on multi-editor pages. No `window.probe` is required. + +When a bug needs event tracing, temporarily instrument the running page through +Appium, not source edits. Record capture/bubble +`keydown`, `keyup`, `beforeinput`, `input`: key, code, shiftKey, CapsLock state +(`getModifierState`), inputType, data, isTrusted, defaultPrevented, and composition +state. Bound traces and remove listeners on teardown. An investigation may need +render/mount counters and DOM Selection anchor/focus offsets as well. + +Return JSON strings from remote execution and parse them +on the host: returning nested objects directly has sometimes produced WebDriver +window-handle serialization rather than the intended data. + +For readiness after navigation, the helper supports raw W3C session requests: + +```sh +node "$SKILL_DIR/scripts/device.mjs" request POST /execute/async \ + '{"script":"const done = arguments[arguments.length - 1]; const end = Date.now() + 10000; function check() { if (document.querySelector(\".ProseMirror\")?.editor?.schema?.cached?.blockNoteEditor) return done(true); if (Date.now() >= end) return done(false); setTimeout(check, 50); } check();","args":[]}' +``` + +Require the result to be true before input. The helper sets the script timeout; +a fresh session once defaulted to zero, breaking otherwise valid async checks. + +## Investigating keyboard modifiers + +On one verified iOS 26.4 simulator, native Return changed the keyboard from `a` +to `A`; Return **keyup** and the next Delete **keydown** had `shiftKey: true` +with CapsLock false. Native default editing controls reproduced it. This was +software-keyboard autocapitalization, not BlockNote synthesizing Shift. + +Do not assume this explains every modifier bug. Use these controls: + +1. Native Return then native Delete in BlockNote; capture keydown **and keyup**. +2. Repeat in a plain contenteditable and textarea, without editor plugins. +3. Set `autocapitalize="none"` **before focus**, then repeat. +4. With default capitalization, tap the actual native Shift key off after Return, + then Delete. Read keyboard accessibility letter labels (`a` vs `A`). +5. Compare editor-command Enter as a separate control, clearly labeled non-native. + +Do not inject Shift or use `/keys` in these origin controls. Explicit Shift is +appropriate later for a deterministic regression test of a missing shortcut alias. +Do not disable capitalization in production merely to hide a handler bug. + +## ProseMirror diagnostics + +If model and DOM selections disagree, record both before attributing the problem +to keyboard shortcuts. For plugin-handler tracing, wrap each handler preserving +`this`, arguments, return values and thrown errors. Record transaction/filter +activity and composition state, bound memory, and restore every original method. +Instrumentation must not change event dispatch behavior. + +Missing `beforeinput` is not enough to identify the culprit: ProseMirror can +prevent default in a boundary-deletion fallback after every plugin declines the +event. Inspect modifier fields and actual handler outcomes first. + +When isolating splitting behavior, compare native paragraph, custom React block, +editable inline node, framed/nested block, and any truly relevant chrome updates. +Check valid split boundaries, attributes/marks, suffix preservation, and subsequent +typing. Nested inline `data-node-view-*` descendants are not automatically clones. + +## Setup failures and remedies + +| Symptom | Check / remedy | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| WDA never answers on 8100 | Inspect Appium/Xcode logs with `showXcodeLog`; wait for boot migration. Do not repeatedly create sessions while an earlier create is still running. | +| No webviews during startup | Supply a real `safariInitialUrl`, allow `webviewConnectTimeout: 60000`, confirm the server is reachable. | +| Safari tooltip/alert blocks automation | Inspect native source/screenshot and dismiss the actual first-run UI. Use `request POST /alert/accept '{}'` only when an alert is present. | +| Keyboard absent after `editor.focus()` | Tap the editor natively; verify keyboard exists, hardware keyboard is disconnected, then set the desired selection. | +| Wrong native key | Inspect source; key labels/localization and Return/Search/Go change with input type. `key` accepts the observed name. | +| Session no longer found | Create a new session. Saved JSON is not a persistent browser connection. | +| Broken content with no obvious exception | Check the actual example's CSS and viewport; don't substitute an unstyled diagnostic page for the application. | +| JS returns an opaque window handle | Serialize state to JSON in the WebView before returning it. | + +## Simulator-only recordings + +Record the selected device, not the desktop. The simulator must remain running: + +```sh +xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$IOS_ARTIFACTS/before.mp4" +``` + +Run recording in its own terminal/owned process, wait for “Recording started”, +perform the real reproduction in another process, leave a readable final pause, +then send **SIGINT** to that recorder and await exit so the MP4 finalizes. Do not +SIGKILL it. Repeat with the fixed variant and identical input. Check actual state +and event traces alongside video; successful-looking pixels can hide model loss. + +```sh +ffprobe -v error -show_entries format=duration -of default=nw=1 "$IOS_ARTIFACTS/before.mp4" +ffmpeg -hide_banner -loglevel error -y -i "$IOS_ARTIFACTS/before.mp4" \ + -vf 'fps=1,scale=280:-1,tile=4x3' -frames:v 1 "$IOS_ARTIFACTS/review.png" +``` + +Inspect the contact sheet and important individual frames. Unused cells on short +clips are black, not necessarily corrupt video. Never claim review from metadata +alone. Use the screen-recording skill for conversion/publication if requested. + +## What a report must not imply + +- A fix for native Enter does not prove an unrelated insertion freeze was fixed. +- A passing fixture does not clear a different extension, skin or node-view layout. +- One simulator/iOS version does not cover physical devices, iPad, keyboards, + dictation, composition/IME, or native selection handles that were not exercised. +- Repeated passes after one failure do not erase the unresolved observation. +- A failing existing test is not “preexisting” just because new tests pass. Compare + the relevant baseline under the same machinery and report its exact outcome. diff --git a/.claude/skills/ios-simulator/scripts/device.mjs b/.claude/skills/ios-simulator/scripts/device.mjs new file mode 100644 index 0000000000..e96ba7d1cc --- /dev/null +++ b/.claude/skills/ios-simulator/scripts/device.mjs @@ -0,0 +1,208 @@ +// Diagnostic CLI: no dependencies and no repository/session-specific defaults. +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, resolve } from "node:path"; + +const artifacts = process.env.IOS_ARTIFACTS; +if (!artifacts) { + throw new Error("Set IOS_ARTIFACTS to an external artifact directory"); +} +mkdirSync(artifacts, { recursive: true }); +const sessionFile = resolve(artifacts, "session.json"); +const server = process.env.APPIUM_URL ?? "http://127.0.0.1:4723"; + +function request(method, path, body) { + const url = new URL(`${server.replace(/\/$/, "")}${path}`); + const data = body === undefined ? undefined : JSON.stringify(body); + return new Promise((resolveRequest, reject) => { + const send = url.protocol === "https:" ? httpsRequest : httpRequest; + const req = send( + url, + { + method, + headers: { "Content-Type": "application/json" }, + }, + (res) => { + let text = ""; + res.setEncoding("utf8"); + res.on("error", reject); + res.on("data", (chunk) => { + text += chunk; + }); + res.on("end", () => { + try { + const result = JSON.parse(text); + if (res.statusCode >= 400 || result.value?.error) { + reject(new Error(`${method} ${path}: ${text}`)); + } else { + resolveRequest(result.value); + } + } catch (error) { + reject(error); + } + }); + }, + ); + // Longer than WDA startup; avoids fetch's separate default headers timeout. + req.setTimeout(900_000, () => + req.destroy(new Error("Appium request timed out")), + ); + req.on("error", reject); + req.end(data); + }); +} + +function write(path, data) { + mkdirSync(dirname(resolve(path)), { recursive: true }); + writeFileSync(path, data); + return resolve(path); +} + +const [action, ...args] = process.argv.slice(2); +let result; +if (action === "create") { + const [udid, url] = args; + if (!udid || !url) { + throw new Error("create requires UDID and initial URL"); + } + // Do not overwrite a potentially live session: close or inspect it first. + if (existsSync(sessionFile)) { + throw new Error( + "session.json already exists; close its session or move aside a confirmed stale file", + ); + } + const session = await request("POST", "/session", { + capabilities: { + alwaysMatch: { + platformName: "iOS", + browserName: "Safari", + "appium:automationName": "XCUITest", + "appium:udid": udid, + "appium:connectHardwareKeyboard": false, + "appium:safariInitialUrl": url, + "appium:webviewConnectTimeout": 60_000, + "appium:wdaLaunchTimeout": 120_000, + "appium:wdaStartupRetries": 1, + "appium:showXcodeLog": true, + "appium:newCommandTimeout": 1200, + }, + }, + }); + write(sessionFile, JSON.stringify(session, null, 2)); + await request("POST", `/session/${session.sessionId}/timeouts`, { + script: 30_000, + }); + result = session; +} else { + const { sessionId } = JSON.parse(readFileSync(sessionFile, "utf8")); + const base = `/session/${sessionId}`; + function call(method, path, body) { + return request(method, `${base}${path}`, body); + } + async function native(run) { + const context = await call("GET", "/context"); + await call("POST", "/context", { name: "NATIVE_APP" }); + try { + return await run(); + } finally { + await call("POST", "/context", { name: context }); + } + } + async function tap(x, y) { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + throw new Error("Invalid tap coordinates"); + } + return call("POST", "/execute/sync", { + script: "mobile: tap", + args: [{ x, y }], + }); + } + switch (action) { + case "exec": + result = await call("POST", "/execute/sync", { + script: args[0], + args: JSON.parse(args[1] ?? "[]"), + }); + break; + case "navigate": + result = await call("POST", "/url", { url: args[0] }); + break; + case "request": + result = await call( + args[0], + args[1], + args[2] ? JSON.parse(args[2]) : undefined, + ); + break; + case "tap": + result = await native(() => tap(Number(args[0]), Number(args[1]))); + break; + case "key": + if (!args[0]) { + throw new Error("key requires an observed native key name"); + } + result = await native(async () => { + const element = await call("POST", "/element", { + using: "-ios predicate string", + value: `(type == 'XCUIElementTypeButton' OR type == 'XCUIElementTypeKey') AND name ==[c] ${JSON.stringify(args[0])}`, + }); + const id = element["element-6066-11e4-a52e-4f735466cecf"]; + const rect = await call("GET", `/element/${id}/rect`); + await tap(rect.x + rect.width / 2, rect.y + rect.height / 2); + return { key: args[0], rect }; + }); + break; + case "type": + result = await native(() => call("POST", "/keys", { value: [args[0]] })); + break; + case "native-source": + result = await native(() => call("GET", "/source")); + break; + case "state": { + const state = JSON.parse( + await call("POST", "/execute/sync", { + script: ` + const root = document.querySelector(arguments[0]); + const tiptap = root?.editor; + const editor = tiptap?.schema?.cached?.blockNoteEditor; + if (!editor) throw new Error('No mounted BlockNote editor at the requested selector'); + const { from, to } = tiptap.state.selection; + return JSON.stringify({ + document: editor.document, + selection: { from, to }, + focused: tiptap.isFocused, + dom: root.innerHTML, + });`, + args: [args[1] ?? ".ProseMirror"], + }), + ); + result = args[0] ? write(args[0], JSON.stringify(state, null, 2)) : state; + break; + } + case "screenshot": + if (!args[0]) { + throw new Error("screenshot requires an output path"); + } + result = write( + args[0], + Buffer.from(await call("GET", "/screenshot"), "base64"), + ); + break; + case "close": + result = await call("DELETE", ""); + unlinkSync(sessionFile); + break; + default: + throw new Error( + "Use create, exec, navigate, request, tap, key, type, native-source, state, screenshot, or close", + ); + } +} +console.log(JSON.stringify(result, null, 2)); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.inline.node.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.inline.node.test.ts new file mode 100644 index 0000000000..360ea75429 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.inline.node.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { TextSelection } from "prosemirror-state"; +import { expect, test } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js"; +import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { createInlineContentSpec } from "../../../../schema/inlineContent/createSpec.js"; +import { splitBlockTr } from "./splitBlock.js"; + +// Exercise the transform through a headless BlockNote editor and its real schema. +const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { + token: createInlineContentSpec( + { + type: "token", + content: "styled", + propSchema: { label: { default: "keep" } }, + }, + { + render() { + const dom = document.createElement("span"); + return { dom, contentDOM: dom }; + }, + }, + ), + }, +}); + +test.each([0, 1, 2])( + "split inside editable inline content at offset %s", + (offset) => { + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { + type: "paragraph", + content: [ + { type: "text", text: "ab", styles: {} }, + { + type: "token", + props: { label: "custom" }, + content: [{ type: "text", text: "cd", styles: {} }], + }, + { type: "text", text: "ef", styles: {} }, + ], + }, + ], + }); + try { + const marks = [editor.pmSchema.marks.bold.create()]; + editor.transact((tr) => { + // doc > blockGroup > blockContainer > paragraph > token > text + const pos = 6 + offset; + const token = tr.doc.nodeAt(5)!; + tr.setNodeMarkup(5, undefined, token.attrs, marks); + tr.setSelection(TextSelection.create(tr.doc, pos)); + expect(splitBlockTr(tr, pos)).toBe(true); + tr.doc.check(); + const group = tr.doc.firstChild!; + expect(group.childCount).toBe(2); + expect(group.child(0).firstChild!.textContent).toBe( + "ab" + "cd".slice(0, offset), + ); + expect(group.child(1).firstChild!.textContent).toBe( + "cd".slice(offset) + "ef", + ); + const left = group.child(0).firstChild!.lastChild!; + const right = group.child(1).firstChild!.firstChild!; + expect(left.type.name).toBe("token"); + expect(right.type.name).toBe("token"); + expect(left.attrs.label).toBe("custom"); + expect(right.attrs.label).toBe("custom"); + expect(left.marks).toEqual(marks); + expect(right.marks).toEqual(marks); + expect(tr.selection.$from.parent.type.name).toBe("token"); + expect(tr.selection.$from.parentOffset).toBe(0); + }); + expect(editor.document).toHaveLength(2); + } finally { + editor._tiptapEditor.destroy(); + } + }, +); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 96b82dbd22..816bee4479 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -63,6 +63,15 @@ export const splitBlockTr = ( }, ]; + // Split editable inline ancestors too. Omitted type overrides let ProseMirror + // copy them, preserving their attributes and marks on both sides. + const $pos = tr.doc.resolve(posInBlock); + // Measure from the block's content node, not the document root: nesting blocks + // adds unrelated depth. Only inline ancestors below it extend the usual + // two-level split (blockContainer + block content). + const contentDepth = tr.doc.resolve(info.contentStart).depth; + const splitDepth = 2 + $pos.depth - contentDepth; + // The children follow the text after the caret into the second block, as // `tr.split` leaves them. With `keepChildren` they move back to the first // half, within the same undo step. At the start, the whole content goes to @@ -74,7 +83,7 @@ export const splitBlockTr = ( if (children) { tr.delete(children.beforePos, children.afterPos); } - tr.split(posInBlock, 2, types); + tr.split(posInBlock, splitDepth, types); if (children) { const original = tr.doc.nodeAt(info.block.beforePos); if (!original?.firstChild) { diff --git a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts index 01c0357d03..810919f5ee 100644 --- a/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts +++ b/packages/core/src/extensions/InlineContentBoundaryEdit/InlineContentBoundaryEdit.ts @@ -5,9 +5,11 @@ const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit"); // Whether a Backspace/Delete at `selection` would remove the entire content // range `[content.from, content.to)` of an inline content node. +// Other keys aren't deletion attempts; treating a typed character as Delete +// would erase a single-character node instead of inserting the character. function emptiesInlineContent( selection: Selection, - key: string, + key: "Backspace" | "Delete", content: { from: number; to: number }, ) { if (!selection.empty) { @@ -87,6 +89,7 @@ export const InlineContentBoundaryEditExtension = createExtension( // one transaction, keeping the now-empty node (and the caret // inside it) so it stays editable. if ( + (event.key === "Backspace" || event.key === "Delete") && node.content.size > 0 && emptiesInlineContent(selection, event.key, { from: contentFrom, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index b770e038a3..f9efb7ff0d 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1031,6 +1031,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ return { Backspace: handleBackspace, + // iOS autocapitalization can set Shift on the first Delete after Return. + // Unlike Shift-Enter, this must run the same command, not a shifted variant. + "Shift-Backspace": handleBackspace, Delete: handleDelete, Enter: () => handleEnter(), "Shift-Enter": () => handleEnter(true), diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 7b01492892..0898988dd4 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -56,16 +56,18 @@ export function addInlineContentKeyboardShortcuts< ): { [p: string]: KeyboardShortcutCommand; } { - return { - Backspace: ({ editor }) => { - const resolvedPos = editor.state.selection.$from; + function handleBackspace({ editor }: Parameters[0]) { + const resolvedPos = editor.state.selection.$from; - return ( - editor.state.selection.empty && - resolvedPos.node().type.name === config.type && - resolvedPos.parentOffset === 0 - ); - }, + return ( + editor.state.selection.empty && + resolvedPos.node().type.name === config.type && + resolvedPos.parentOffset === 0 + ); + } + return { + Backspace: handleBackspace, + "Shift-Backspace": handleBackspace, }; } diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index fd64a6428f..facf1c0f84 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -579,9 +579,38 @@ export function createReactBlockSpec< ); } - return ReactNodeViewRenderer(BlockNodeView, { + const nodeView = ReactNodeViewRenderer(BlockNodeView, { className: "bn-react-node-view-renderer", - })(this.props!) as ReturnType< + })(this.props!); + // Before React mounts, TipTap returns a placeholder with no + // contentDOM. Only the live ReactNodeView needs this workaround. + if (nodeView instanceof ReactNodeView) { + const ignoreMutation = nodeView.ignoreMutation.bind(nodeView); + Object.assign(nodeView, { + ignoreMutation(mutation) { + const contentDOM = nodeView.contentDOM; + if ( + contentDOM && + mutation.type === "childList" && + mutation.target === contentDOM.parentElement && + ![ + ...mutation.addedNodes, + ...mutation.removedNodes, + ].includes(contentDOM) + ) { + // TipTap's contentDOM.contains check misses native mobile + // Enter cloning contentDOM into a sibling. Observe that + // boundary, but not React attaching/detaching contentDOM. + // Open upstream PR (currently insufficient for this + // nested wrapper): https://github.com/ueberdosis/tiptap/pull/8355 + // Recheck this workaround when that behavior changes. + return false; + } + return ignoreMutation(mutation); + }, + } satisfies Pick); + } + return nodeView as ReturnType< NonNullable >; } else { diff --git a/tests/src/end-to-end/mobile/editableInlineEnter.test.tsx b/tests/src/end-to-end/mobile/editableInlineEnter.test.tsx new file mode 100644 index 0000000000..cd5a90bdc3 --- /dev/null +++ b/tests/src/end-to-end/mobile/editableInlineEnter.test.tsx @@ -0,0 +1,107 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { + BlockNoteViewRaw, + createReactInlineContentSpec, +} from "@blocknote/react"; +import { expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; + +async function renderInlineEditor() { + const token = createReactInlineContentSpec( + { type: "token", propSchema: {}, content: "styled" }, + { + render: function Token({ contentRef }) { + return ; + }, + }, + ); + const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { token }, + }); + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { + type: "paragraph", + content: [ + { type: "text", text: "ab", styles: {} }, + { + type: "token", + props: {}, + content: [{ type: "text", text: "cd", styles: {} }], + }, + { type: "text", text: "ef", styles: {} }, + ], + }, + ], + }); + const view = await render( + , + ); + return { editor, view }; +} + +test("Enter inside editable inline content allows typing and deletion", async () => { + const { editor, view } = await renderInlineEditor(); + try { + editor.focus(); + editor.setTextCursorPosition(editor.document[0], "start"); + editor._tiptapEditor.commands.setTextSelection( + editor._tiptapEditor.state.selection.from + 4, + ); + await userEvent.keyboard("{Enter}"); + await expect.poll(() => editor.document.length).toBe(2); + expect(editor.document[0].content).toMatchObject([ + { type: "text", text: "ab" }, + { type: "token", content: [{ type: "text", text: "c" }] }, + ]); + expect(editor.document[1].content).toMatchObject([ + { type: "token", content: [{ type: "text", text: "d" }] }, + { type: "text", text: "ef" }, + ]); + await userEvent.keyboard("X"); + await expect + .poll(() => editor.document[1].content) + .toMatchObject([ + { type: "token", content: [{ type: "text", text: "Xd" }] }, + { type: "text", text: "ef" }, + ]); + // Deletion can still empty the token, and typing can refill it. + await userEvent.keyboard("{Backspace}{Delete}Y"); + await expect + .poll(() => editor.document[1].content) + .toMatchObject([ + { type: "token", content: [{ type: "text", text: "Y" }] }, + { type: "text", text: "ef" }, + ]); + } finally { + await view.unmount(); + editor._tiptapEditor.destroy(); + } +}); + +test("Shift-Backspace at an inline boundary preserves outside text", async () => { + const { editor, view } = await renderInlineEditor(); + try { + editor.focus(); + editor.setTextCursorPosition(editor.document[0], "start"); + editor._tiptapEditor.commands.setTextSelection( + editor._tiptapEditor.state.selection.from + 3, + ); + const before = editor.document; + await userEvent.keyboard("{Shift>}{Backspace}{/Shift}"); + expect(editor.document).toEqual(before); + } finally { + await view.unmount(); + editor._tiptapEditor.destroy(); + } +}); diff --git a/tests/src/end-to-end/mobile/reactBlockEnter.test.tsx b/tests/src/end-to-end/mobile/reactBlockEnter.test.tsx new file mode 100644 index 0000000000..4776173fc3 --- /dev/null +++ b/tests/src/end-to-end/mobile/reactBlockEnter.test.tsx @@ -0,0 +1,76 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { BlockNoteViewRaw, createReactBlockSpec } from "@blocknote/react"; +import { expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; + +// Mobile Enter splits the DOM beside contentDOM. Reading that mutation must +// preserve the suffix in the model and reconcile the cloned editable wrapper. +test("native Enter in a React block preserves the suffix", async () => { + const alert = createReactBlockSpec( + { type: "alert", propSchema: {}, content: "inline" }, + { + render: function Alert({ contentRef }) { + return
; + }, + }, + )(); + const schema = BlockNoteSchema.create().extend({ blockSpecs: { alert } }); + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [{ type: "paragraph", content: "Before" }], + }); + const view = await render( + , + ); + try { + editor.focus(); + editor.insertBlocks( + [{ type: "alert", content: "boom" }], + editor.document[0], + "after", + ); + await expect + .poll( + () => + view.container.querySelector( + '[data-content-type="alert"] .bn-inline-content', + )?.textContent, + ) + .toBe("boom"); + editor.setTextCursorPosition(editor.document[1], "start"); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard("{ArrowRight}"); + await userEvent.keyboard("{Enter}"); + await expect.poll(() => editor.document.length).toBe(3); + expect(editor.document[1].content).toEqual([ + { type: "text", text: "bo", styles: {} }, + ]); + expect(editor.document[2].content).toEqual([ + { type: "text", text: "om", styles: {} }, + ]); + await userEvent.keyboard("After"); + await expect + .poll(() => editor.document[2].content) + .toEqual([{ type: "text", text: "Afterom", styles: {} }]); + for (const block of view.container.querySelectorAll( + '[data-content-type="alert"]', + )) { + expect(block.querySelectorAll(".bn-inline-content")).toHaveLength(1); + expect( + block.querySelectorAll("[data-node-view-content-react]"), + ).toHaveLength(1); + } + } finally { + await view.unmount(); + editor._tiptapEditor.destroy(); + } +}); diff --git a/tests/src/end-to-end/mobile/shiftBackspace.test.tsx b/tests/src/end-to-end/mobile/shiftBackspace.test.tsx new file mode 100644 index 0000000000..7d51727768 --- /dev/null +++ b/tests/src/end-to-end/mobile/shiftBackspace.test.tsx @@ -0,0 +1,43 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteViewRaw } from "@blocknote/react"; +import { expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; + +// The iOS software keyboard emitted Shift on the first Delete after Return. +// Supply that modifier explicitly; emulation does not reproduce its OS choice. +test("Shift-Backspace joins blocks after native Enter", async () => { + const editor = BlockNoteEditor.create({ + trailingBlock: false, + initialContent: [{ type: "paragraph", content: "boom" }], + }); + const view = await render( + , + ); + try { + editor.focus(); + editor.setTextCursorPosition(editor.document[0], "start"); + await userEvent.keyboard("{ArrowRight}{ArrowRight}"); + await userEvent.keyboard("{Enter}"); + await expect.poll(() => editor.document.length).toBe(2); + expect(editor.document).toMatchObject([ + { content: [{ type: "text", text: "bo" }] }, + { content: [{ type: "text", text: "om" }] }, + ]); + await userEvent.keyboard("{Shift>}{Backspace}{/Shift}"); + expect(editor.document).toHaveLength(1); + expect(editor.document[0].content).toMatchObject([ + { type: "text", text: "boom" }, + ]); + } finally { + await view.unmount(); + editor._tiptapEditor.destroy(); + } +}); From 9aadd7b6066ecfedee2cb07cdcd3fac3069257cb Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 12:27:08 +0100 Subject: [PATCH 3/4] fix: narrow React mutation handling and clean up Appium sessions --- .claude/skills/ios-simulator/SKILL.md | 2 + .../skills/ios-simulator/scripts/device.mjs | 44 +++++++++- .../schema/ReactBlockSpec.browser.test.tsx | 82 +++++++++++++++++++ packages/react/src/schema/ReactBlockSpec.tsx | 12 ++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/.claude/skills/ios-simulator/SKILL.md b/.claude/skills/ios-simulator/SKILL.md index 0fb1178dcb..b696b01271 100644 --- a/.claude/skills/ios-simulator/SKILL.md +++ b/.claude/skills/ios-simulator/SKILL.md @@ -17,6 +17,8 @@ not automated regression coverage or proof of physical-device/iPad/IME behavior. - macOS, full Xcode selected, an installed iOS runtime, and Node.js/pnpm. - Docker for browser tests. Use only `vp` / `pnpm`, not npm/yarn. +- Optional for recording review: FFmpeg (`ffmpeg` and `ffprobe`, available via + `brew install ffmpeg`) for the contact-sheet commands in the debugging reference. - A local Safari-accessible dev server from **this checkout**. Another worktree may own the usual port; verify the URL rather than assuming port 5173. - An approved external temporary directory for sessions, logs, captures and driver diff --git a/.claude/skills/ios-simulator/scripts/device.mjs b/.claude/skills/ios-simulator/scripts/device.mjs index e96ba7d1cc..7e6eb57a38 100644 --- a/.claude/skills/ios-simulator/scripts/device.mjs +++ b/.claude/skills/ios-simulator/scripts/device.mjs @@ -18,6 +18,13 @@ mkdirSync(artifacts, { recursive: true }); const sessionFile = resolve(artifacts, "session.json"); const server = process.env.APPIUM_URL ?? "http://127.0.0.1:4723"; +class AppiumError extends Error { + constructor(message, code) { + super(message); + this.code = code; + } +} + function request(method, path, body) { const url = new URL(`${server.replace(/\/$/, "")}${path}`); const data = body === undefined ? undefined : JSON.stringify(body); @@ -40,7 +47,12 @@ function request(method, path, body) { try { const result = JSON.parse(text); if (res.statusCode >= 400 || result.value?.error) { - reject(new Error(`${method} ${path}: ${text}`)); + reject( + new AppiumError( + `${method} ${path}: ${text}`, + result.value?.error, + ), + ); } else { resolveRequest(result.value); } @@ -59,6 +71,19 @@ function request(method, path, body) { }); } +async function deleteSession(sessionId) { + try { + return await request("DELETE", `/session/${sessionId}`); + } catch (error) { + // An expired session is already closed. Do not mistake transport/server + // failures for expiry: keep its saved ID available for a later retry. + if (error instanceof AppiumError && error.code === "invalid session id") { + return null; + } + throw error; + } +} + function write(path, data) { mkdirSync(dirname(resolve(path)), { recursive: true }); writeFileSync(path, data); @@ -95,7 +120,20 @@ if (action === "create") { }, }, }); - write(sessionFile, JSON.stringify(session, null, 2)); + try { + write(sessionFile, JSON.stringify(session, null, 2)); + } catch (error) { + // Without a saved ID, a later `close` cannot find the new remote session. + try { + await deleteSession(session.sessionId); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `Could not save or close Appium session ${session.sessionId}`, + ); + } + throw error; + } await request("POST", `/session/${session.sessionId}/timeouts`, { script: 30_000, }); @@ -196,7 +234,7 @@ if (action === "create") { ); break; case "close": - result = await call("DELETE", ""); + result = await deleteSession(sessionId); unlinkSync(sessionFile); break; default: diff --git a/packages/react/src/schema/ReactBlockSpec.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.browser.test.tsx index 4eb1c0c3ef..7388a9ecd0 100644 --- a/packages/react/src/schema/ReactBlockSpec.browser.test.tsx +++ b/packages/react/src/schema/ReactBlockSpec.browser.test.tsx @@ -7,6 +7,88 @@ import { expect, it } from "vite-plus/test"; import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; import { createReactBlockSpec } from "./ReactBlockSpec.js"; +it("keeps inline block state when React adds or removes contentDOM siblings", async () => { + let mounts = 0; + const stateful = createReactBlockSpec( + { type: "stateful", propSchema: {}, content: "inline" }, + { + render: function StatefulBlock({ contentRef }) { + const [count, setCount] = useState(0); + const [showDetails, setShowDetails] = useState(false); + useEffect(() => { + mounts++; + }, []); + return ( +
+ + +
+ {showDetails && } +
+
+ ); + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create().extend({ blockSpecs: { stateful } }), + initialContent: [{ type: "stateful", content: "Editable text" }], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + try { + flushSync(() => { + root.render( + , + ); + }); + await expect.poll(() => host.querySelectorAll("button").length).toBe(2); + const button = host.querySelector("button")!; + const toggle = host.querySelectorAll("button")[1]; + const content = host.querySelector("[data-node-view-content-react]"); + flushSync(() => button.click()); + const initialMounts = mounts; + const documentBefore = editor.document; + for (const visible of [true, false]) { + flushSync(() => toggle.click()); + // Deliver mutations and allow ProseMirror's deferred DOM read to run. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(host.querySelector("button")).toBe(button); + expect(button.textContent).toBe("Count: 1"); + expect(host.querySelector("aside") !== null).toBe(visible); + expect(host.querySelector("[data-node-view-content-react]")).toBe( + content, + ); + expect(mounts).toBe(initialMounts); + expect(editor.document).toEqual(documentBefore); + } + content!.textContent = "Native edit"; + await expect + .poll(() => editor.document[0]) + .toMatchObject({ + content: [{ type: "text", text: "Native edit" }], + }); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } +}); + it.each([false, true])( "keeps custom block state across wrapper attribute changes, framed=%s", async (framed) => { diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index facf1c0f84..15f00938bb 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -596,11 +596,19 @@ export function createReactBlockSpec< ![ ...mutation.addedNodes, ...mutation.removedNodes, - ].includes(contentDOM) + ].includes(contentDOM) && + [...mutation.addedNodes, ...mutation.removedNodes].some( + (node) => + node.nodeType === 1 && + (node as Element).hasAttribute( + "data-node-view-content-react", + ), + ) ) { // TipTap's contentDOM.contains check misses native mobile // Enter cloning contentDOM into a sibling. Observe that - // boundary, but not React attaching/detaching contentDOM. + // boundary only for cloned content containers, not React + // chrome or attaching/detaching the real contentDOM. // Open upstream PR (currently insufficient for this // nested wrapper): https://github.com/ueberdosis/tiptap/pull/8355 // Recheck this workaround when that behavior changes. From a13923a2c070c899c98932b7e95de96f14ad07d0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 4 Nov 2026 13:23:43 +0100 Subject: [PATCH 4/4] fix: retain Appium session endpoints and remove partial session files --- .claude/skills/ios-simulator/SKILL.md | 6 ++-- .../skills/ios-simulator/scripts/device.mjs | 32 +++++++++++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.claude/skills/ios-simulator/SKILL.md b/.claude/skills/ios-simulator/SKILL.md index b696b01271..cf593790ea 100644 --- a/.claude/skills/ios-simulator/SKILL.md +++ b/.claude/skills/ios-simulator/SKILL.md @@ -90,8 +90,10 @@ node "$SKILL_DIR/scripts/device.mjs" create "$UDID" "$PAGE_URL" The helper disables hardware-keyboard connection, sets Safari's initial URL, allows 60 seconds for webviews, and sets script timeout to 30 seconds. Session -IDs are saved outside the checkout and become stale after session deletion. -Set `APPIUM_URL` if not using `http://127.0.0.1:4723`. +IDs and their Appium endpoint are saved outside the checkout. Subsequent commands +use that saved endpoint, including cleanup. Set `APPIUM_URL` before creation if +not using `http://127.0.0.1:4723`. For an older session file without an endpoint, +add its confirmed URL as the `server` field before running commands. ## 3. Drive real input, inspect through WebKit diff --git a/.claude/skills/ios-simulator/scripts/device.mjs b/.claude/skills/ios-simulator/scripts/device.mjs index 7e6eb57a38..c5dab61127 100644 --- a/.claude/skills/ios-simulator/scripts/device.mjs +++ b/.claude/skills/ios-simulator/scripts/device.mjs @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, + rmSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -25,8 +26,8 @@ class AppiumError extends Error { } } -function request(method, path, body) { - const url = new URL(`${server.replace(/\/$/, "")}${path}`); +function request(method, path, body, endpoint = server) { + const url = new URL(`${endpoint.replace(/\/$/, "")}${path}`); const data = body === undefined ? undefined : JSON.stringify(body); return new Promise((resolveRequest, reject) => { const send = url.protocol === "https:" ? httpsRequest : httpRequest; @@ -71,9 +72,14 @@ function request(method, path, body) { }); } -async function deleteSession(sessionId) { +async function deleteSession(sessionId, endpoint = server) { try { - return await request("DELETE", `/session/${sessionId}`); + return await request( + "DELETE", + `/session/${sessionId}`, + undefined, + endpoint, + ); } catch (error) { // An expired session is already closed. Do not mistake transport/server // failures for expiry: keep its saved ID available for a later retry. @@ -121,15 +127,16 @@ if (action === "create") { }, }); try { - write(sessionFile, JSON.stringify(session, null, 2)); + write(sessionFile, JSON.stringify({ ...session, server }, null, 2)); } catch (error) { // Without a saved ID, a later `close` cannot find the new remote session. try { await deleteSession(session.sessionId); + rmSync(sessionFile, { force: true }); } catch (cleanupError) { throw new AggregateError( [error, cleanupError], - `Could not save or close Appium session ${session.sessionId}`, + `Could not save or clean up Appium session ${session.sessionId}`, ); } throw error; @@ -139,10 +146,17 @@ if (action === "create") { }); result = session; } else { - const { sessionId } = JSON.parse(readFileSync(sessionFile, "utf8")); + const { sessionId, server: savedServer } = JSON.parse( + readFileSync(sessionFile, "utf8"), + ); + if (!savedServer) { + throw new Error( + "session.json has no saved Appium endpoint; add its confirmed server URL before using this session", + ); + } const base = `/session/${sessionId}`; function call(method, path, body) { - return request(method, `${base}${path}`, body); + return request(method, `${base}${path}`, body, savedServer); } async function native(run) { const context = await call("GET", "/context"); @@ -234,7 +248,7 @@ if (action === "create") { ); break; case "close": - result = await deleteSession(sessionId); + result = await deleteSession(sessionId, savedServer); unlinkSync(sessionFile); break; default: