From f713c692c71a2a02f7eb554a47bbfdd7f1b0641f Mon Sep 17 00:00:00 2001 From: shlroland Date: Thu, 11 Jun 2026 20:56:05 +0800 Subject: [PATCH 1/4] feat: add React node view DOM controls --- e2e/src/react/components/Paragraph.tsx | 17 +- e2e/tests/node-view.spec.ts | 34 ++ packages/core/src/index.ts | 8 +- packages/core/src/nodeView/CoreNodeView.ts | 31 +- .../core/src/nodeView/CoreNodeViewOptions.ts | 2 + packages/core/src/nodeView/index.ts | 7 +- packages/react/src/index.ts | 4 + packages/react/src/nodeView/NodeViewDOM.ts | 323 ++++++++++++++++++ packages/react/src/nodeView/ReactNodeView.ts | 3 + packages/react/src/nodeView/index.ts | 1 + .../react/src/nodeView/nodeViewContext.ts | 9 + 11 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 packages/react/src/nodeView/NodeViewDOM.ts diff --git a/e2e/src/react/components/Paragraph.tsx b/e2e/src/react/components/Paragraph.tsx index cd9f1ae1..988915a8 100644 --- a/e2e/src/react/components/Paragraph.tsx +++ b/e2e/src/react/components/Paragraph.tsx @@ -1,6 +1,19 @@ -import { useNodeViewContext } from '@prosemirror-adapter/react' +import { NodeViewContent, NodeViewRoot, useNodeViewContext } from '@prosemirror-adapter/react' export function Paragraph() { const { contentRef, selected } = useNodeViewContext() - return
+ const shouldMixContentRef = + typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('mixed-content-ref') + + return ( + + + {shouldMixContentRef ? : null} + + ) } diff --git a/e2e/tests/node-view.spec.ts b/e2e/tests/node-view.spec.ts index 403736d9..e6c381f7 100644 --- a/e2e/tests/node-view.spec.ts +++ b/e2e/tests/node-view.spec.ts @@ -41,3 +41,37 @@ testAll(() => { await expect(h5).toBeVisible() }) }) + +testAll(() => { + test('react node view controls root and content DOM attributes', async ({ page }) => { + const root = page.locator( + '.editor blockquote [data-node-view-root="true"][data-react-node-view-root="true"]', + ) + const content = root.locator('[data-node-view-content="true"][data-react-node-view-content="true"]') + const anchor = root.locator('[data-node-view-content-anchor="true"]') + + await expect(root).toBeVisible() + await expect(root).toHaveAttribute('role', 'presentation') + await expect(root).toHaveAttribute('aria-hidden', 'true') + await expect(root).toHaveCSS('width', '10px') + await expect(content).toBeVisible() + await expect(content).toHaveClass(/react-node-view-content/) + await expect(anchor).not.toHaveClass(/react-node-view-content/) + }) + + test('react node view warns when mixing contentRef and NodeViewContent', async ({ page }) => { + const warnings: string[] = [] + + page.on('console', (message) => { + if (message.type() === 'warning') { + warnings.push(message.text()) + } + }) + + await page.goto('/stories/react/app/react?mixed-content-ref=true', { waitUntil: 'domcontentloaded' }) + + await expect.poll(() => warnings.some((warning) => warning.includes('Do not mix contentRef and NodeViewContent'))).toBe( + true, + ) + }) +}, ['react']) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 568dfba2..af738e5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,11 @@ export { CoreMarkView, type CoreMarkViewSpec, type CoreMarkViewUserOptions, type MarkViewDOMSpec } from './markView' -export { CoreNodeView, type CoreNodeViewSpec, type CoreNodeViewUserOptions, type NodeViewDOMSpec } from './nodeView' +export { + CoreNodeView, + type CoreNodeViewSpec, + type CoreNodeViewUserOptions, + type NodeViewContentMountSource, + type NodeViewDOMSpec, +} from './nodeView' export { CorePluginView, type CorePluginViewSpec, diff --git a/packages/core/src/nodeView/CoreNodeView.ts b/packages/core/src/nodeView/CoreNodeView.ts index 0d4454af..cc7ec460 100644 --- a/packages/core/src/nodeView/CoreNodeView.ts +++ b/packages/core/src/nodeView/CoreNodeView.ts @@ -4,7 +4,12 @@ import type { Decoration, DecorationSource, EditorView, NodeView, ViewMutationRe import { createKey } from '../create-key' -import type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions' +import type { + CoreNodeViewSpec, + CoreNodeViewUserOptions, + NodeViewContentMountSource, + NodeViewDOMSpec, +} from './CoreNodeViewOptions' export class CoreNodeView implements NodeView { key: string @@ -19,6 +24,8 @@ export class CoreNodeView implements NodeView { selected = false setSelection?: (anchor: number, head: number, root: Document | ShadowRoot) => void stopEvent?: (event: Event) => boolean + #contentMountSource: NodeViewContentMountSource | null = null + #didWarnContentMountSource = false #createElement(as?: NodeViewDOMSpec) { const { node } = this @@ -65,12 +72,30 @@ export class CoreNodeView implements NodeView { return this.options.component } - protected contentRef = (element: unknown): void => { - if (element && this.contentDOM && isElementLike(element) && element.firstChild !== this.contentDOM) { + mountContentDOM = (element: HTMLElement | null, source: NodeViewContentMountSource = 'contentRef'): void => { + if (!element || !this.contentDOM) return + + if (this.#contentMountSource && this.#contentMountSource !== source && !this.#didWarnContentMountSource) { + this.#didWarnContentMountSource = true + console.warn( + '[prosemirror-adapter] Do not mix contentRef and NodeViewContent in the same node view. ' + + 'They both control the same contentDOM mount point.', + ) + } + + this.#contentMountSource = source + + if (element.firstChild !== this.contentDOM) { element.appendChild(this.contentDOM) } } + protected contentRef = (element: unknown): void => { + if (element && isElementLike(element)) { + this.mountContentDOM(element as HTMLElement, 'contentRef') + } + } + selectNode = () => { this.selected = true this.options.selectNode?.() diff --git a/packages/core/src/nodeView/CoreNodeViewOptions.ts b/packages/core/src/nodeView/CoreNodeViewOptions.ts index 594b003e..4a5b117d 100644 --- a/packages/core/src/nodeView/CoreNodeViewOptions.ts +++ b/packages/core/src/nodeView/CoreNodeViewOptions.ts @@ -3,6 +3,8 @@ import type { Decoration, DecorationSource, EditorView, ViewMutationRecord } fro export type NodeViewDOMSpec = string | HTMLElement | ((node: Node) => HTMLElement) +export type NodeViewContentMountSource = 'contentRef' | 'component' + export interface CoreNodeViewUserOptions { // DOM as?: NodeViewDOMSpec diff --git a/packages/core/src/nodeView/index.ts b/packages/core/src/nodeView/index.ts index 537c9728..f2396595 100644 --- a/packages/core/src/nodeView/index.ts +++ b/packages/core/src/nodeView/index.ts @@ -1,2 +1,7 @@ export { CoreNodeView } from './CoreNodeView' -export type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions' +export type { + CoreNodeViewSpec, + CoreNodeViewUserOptions, + NodeViewContentMountSource, + NodeViewDOMSpec, +} from './CoreNodeViewOptions' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2ce61d78..3366d488 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -17,11 +17,15 @@ export { nodeViewContext, ReactNodeView, AbstractReactNodeView, + NodeViewContent, + NodeViewRoot, buildReactNodeViewCreator, useNodeViewContext, useNodeViewFactory, type NodeViewContentRef, + type NodeViewContentProps, type NodeViewContext, + type NodeViewRootProps, type ReactNodeViewComponent, type ReactNodeViewSpec, type ReactNodeViewUserOptions, diff --git a/packages/react/src/nodeView/NodeViewDOM.ts b/packages/react/src/nodeView/NodeViewDOM.ts new file mode 100644 index 00000000..c8cbd2e0 --- /dev/null +++ b/packages/react/src/nodeView/NodeViewDOM.ts @@ -0,0 +1,323 @@ +import { + createElement, + forwardRef, + Fragment, + useLayoutEffect, + useRef, + type AriaAttributes, + type AriaRole, + type CSSProperties, + type ReactNode, + type Ref, +} from 'react' + +import { useNodeViewContext } from './nodeViewContext' + +type NodeViewStyle = CSSProperties & Record<`--${string}`, string | number | null | undefined> + +type NodeViewDOMAttributeValue = string | number | boolean | null | undefined + +type NodeViewDOMProps = AriaAttributes & { + className?: string + contentEditable?: boolean | 'true' | 'false' | 'inherit' | 'plaintext-only' + dir?: string + draggable?: boolean | 'true' | 'false' + hidden?: boolean + id?: string + inert?: boolean + inputMode?: string + lang?: string + part?: string + role?: AriaRole + slot?: string + spellCheck?: boolean | 'true' | 'false' + style?: NodeViewStyle + tabIndex?: number + title?: string + translate?: 'yes' | 'no' +} & { + [key: `data-${string}`]: NodeViewDOMAttributeValue +} + +export interface NodeViewRootProps extends NodeViewDOMProps { + children?: ReactNode +} + +export interface NodeViewContentProps extends NodeViewDOMProps {} + +function assignRef(ref: Ref | undefined, element: HTMLElement | null): void { + if (!ref) return + + if (typeof ref === 'function') { + ref(element) + } else { + ref.current = element + } +} + +function getAttributeName(name: string): string { + if (name === 'className') return 'class' + if (name === 'htmlFor') return 'for' + if (name === 'tabIndex') return 'tabindex' + return name +} + +function isBooleanAttribute(name: string): boolean { + return name === 'hidden' || name === 'inert' +} + +function setAttribute(element: HTMLElement, name: string, value: unknown): void { + if (value == null || value === false) { + element.removeAttribute(getAttributeName(name)) + return + } + + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + element.removeAttribute(getAttributeName(name)) + return + } + + const attributeName = getAttributeName(name) + const attributeValue = value === true && isBooleanAttribute(name) ? '' : String(value) + + element.setAttribute(attributeName, attributeValue) +} + +function getStylePropertyName(name: string): string { + return name.startsWith('--') ? name : name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) +} + +const UNITLESS_STYLE_PROPS = new Set([ + 'animationIterationCount', + 'aspectRatio', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'gridArea', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'scale', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', +]) + +function getStyleValue(name: string, value: string | number): string { + if (typeof value === 'number' && value !== 0 && !UNITLESS_STYLE_PROPS.has(name)) { + return `${value}px` + } + + return String(value) +} + +function restoreAttribute(element: HTMLElement, name: string, value: string | null): void { + const attributeName = getAttributeName(name) + if (value == null) { + element.removeAttribute(attributeName) + } else { + element.setAttribute(attributeName, value) + } +} + +function restoreStyle(element: HTMLElement, name: string, value: string): void { + if (name.startsWith('--')) { + if (value) { + element.style.setProperty(name, value) + } else { + element.style.removeProperty(name) + } + } else { + element.style[name as never] = value + } +} + +function setStyle(element: HTMLElement, style: NodeViewStyle | null | undefined): void { + if (!style) return + + for (const [name, value] of Object.entries(style)) { + if (value == null) { + if (name.startsWith('--')) { + element.style.removeProperty(name) + } else { + element.style[name as never] = '' + } + } else if (typeof value !== 'string' && typeof value !== 'number') { + if (name.startsWith('--')) { + element.style.removeProperty(name) + } else { + element.style[name as never] = '' + } + } else if (name.startsWith('--')) { + element.style.setProperty(name, String(value)) + } else { + element.style[name as never] = getStyleValue(name, value) + } + } +} + +function restoreDOMAttrs( + element: HTMLElement, + originalAttrs: Map, + originalStyles: Map, +): void { + for (const [name, value] of originalAttrs) { + restoreAttribute(element, name, value) + } + + for (const [name, value] of originalStyles) { + restoreStyle(element, name, value) + } + + originalAttrs.clear() + originalStyles.clear() +} + +function restoreRemovedDOMAttrs( + element: HTMLElement, + prevAttrs: NodeViewDOMProps | null, + attrs: NodeViewDOMProps, + originalAttrs: Map, + originalStyles: Map, +): void { + if (!prevAttrs) return + + for (const name of Object.keys(prevAttrs)) { + if (name === 'style') continue + if (!(name in attrs) && originalAttrs.has(name)) { + restoreAttribute(element, name, originalAttrs.get(name) ?? null) + originalAttrs.delete(name) + } + } + + for (const name of Object.keys(prevAttrs.style ?? {})) { + if (!attrs.style || !(name in attrs.style)) { + restoreStyle(element, name, originalStyles.get(name) ?? '') + originalStyles.delete(name) + } + } +} + +function saveOriginalDOMAttrs( + element: HTMLElement, + attrs: NodeViewDOMProps, + originalAttrs: Map, + originalStyles: Map, +): void { + for (const name of Object.keys(attrs)) { + if (name === 'style') continue + if (!originalAttrs.has(name)) { + originalAttrs.set(name, element.getAttribute(getAttributeName(name))) + } + } + + for (const name of Object.keys(attrs.style ?? {})) { + if (!originalStyles.has(name)) { + originalStyles.set(name, element.style.getPropertyValue(getStylePropertyName(name))) + } + } +} + +function useDOMAttrs( + element: HTMLElement | null, + attrs: NodeViewDOMProps, + ref: Ref | undefined, +): void { + const prevAttrsRef = useRef(null) + const originalAttrsRef = useRef(new Map()) + const originalStylesRef = useRef(new Map()) + + useLayoutEffect(() => { + const originalAttrs = originalAttrsRef.current + const originalStyles = originalStylesRef.current + + return () => { + if (element) { + restoreDOMAttrs(element, originalAttrs, originalStyles) + } + } + }, [element]) + + useLayoutEffect(() => { + assignRef(ref, element) + + const prevAttrs = prevAttrsRef.current + + if (element) { + restoreRemovedDOMAttrs(element, prevAttrs, attrs, originalAttrsRef.current, originalStylesRef.current) + saveOriginalDOMAttrs(element, attrs, originalAttrsRef.current, originalStylesRef.current) + + for (const [name, value] of Object.entries(attrs)) { + if (name === 'style') continue + setAttribute(element, name, value) + } + + setStyle(element, attrs.style) + } + + prevAttrsRef.current = attrs + + return () => { + assignRef(ref, null) + } + }) +} + +export const NodeViewRoot = forwardRef((props, ref) => { + const { children, ...attrs } = props + const { rootDOM } = useNodeViewContext() + + useDOMAttrs(rootDOM, attrs, ref) + + return createElement(Fragment, null, children) +}) + +NodeViewRoot.displayName = 'NodeViewRoot' + +export const NodeViewContent = forwardRef((props, ref) => { + const { children, ...attrs } = props as NodeViewContentProps & { children?: ReactNode } + const { contentDOM, mountContentDOM } = useNodeViewContext() + const didWarnChildrenRef = useRef(false) + + useDOMAttrs(contentDOM, attrs, ref) + + useLayoutEffect(() => { + if (children != null && !didWarnChildrenRef.current) { + didWarnChildrenRef.current = true + console.warn('[prosemirror-adapter] NodeViewContent does not render children. Put custom UI next to it instead.') + } + }) + + return createElement('span', { + 'data-node-view-content-anchor': 'true', + ref: (element: HTMLElement | null) => { + mountContentDOM(element, 'component') + }, + style: { display: 'contents' }, + }) +}) + +NodeViewContent.displayName = 'NodeViewContent' diff --git a/packages/react/src/nodeView/ReactNodeView.ts b/packages/react/src/nodeView/ReactNodeView.ts index 8493bd8e..613a6d7b 100644 --- a/packages/react/src/nodeView/ReactNodeView.ts +++ b/packages/react/src/nodeView/ReactNodeView.ts @@ -21,6 +21,9 @@ export abstract class AbstractReactNodeView view: this.view, getPos: this.getPos, setAttrs: this.setAttrs, + rootDOM: this.dom, + contentDOM: this.contentDOM, + mountContentDOM: this.mountContentDOM, node: this.node, selected: this.selected, diff --git a/packages/react/src/nodeView/index.ts b/packages/react/src/nodeView/index.ts index c3805d8f..223432c6 100644 --- a/packages/react/src/nodeView/index.ts +++ b/packages/react/src/nodeView/index.ts @@ -6,6 +6,7 @@ export { type NodeViewContentRef, type NodeViewContext, } from './nodeViewContext' +export { NodeViewContent, NodeViewRoot, type NodeViewContentProps, type NodeViewRootProps } from './NodeViewDOM' export { ReactNodeView, AbstractReactNodeView } from './ReactNodeView' export { buildReactNodeViewCreator } from './useReactNodeViewCreator' export type { ReactNodeViewComponent, ReactNodeViewSpec, ReactNodeViewUserOptions } from './ReactNodeViewOptions' diff --git a/packages/react/src/nodeView/nodeViewContext.ts b/packages/react/src/nodeView/nodeViewContext.ts index 50ee90f6..20c3c698 100644 --- a/packages/react/src/nodeView/nodeViewContext.ts +++ b/packages/react/src/nodeView/nodeViewContext.ts @@ -1,3 +1,4 @@ +import type { NodeViewContentMountSource } from '@prosemirror-adapter/core' import type { Attrs, Node } from 'prosemirror-model' import type { Decoration, DecorationSource, EditorView, NodeViewConstructor } from 'prosemirror-view' import { createContext, useContext } from 'react' @@ -12,6 +13,9 @@ export interface NodeViewContext { view: EditorView getPos: () => number | undefined setAttrs: (attrs: Attrs) => void + rootDOM: HTMLElement + contentDOM: HTMLElement | null + mountContentDOM: (element: HTMLElement | null, source?: NodeViewContentMountSource) => void // changes between updates node: Node @@ -29,6 +33,11 @@ export const nodeViewContext = createContext({ setAttrs: () => { // nothing to do }, + rootDOM: null as never, + contentDOM: null, + mountContentDOM: () => { + // nothing to do + }, node: null as never, selected: false, From 87d2a69cbcec57df98871948c9580acaf421d8b1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:04:51 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- e2e/tests/node-view.spec.ts | 10 ++++------ packages/react/src/nodeView/NodeViewDOM.ts | 6 +----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/e2e/tests/node-view.spec.ts b/e2e/tests/node-view.spec.ts index e6c381f7..4f5415c0 100644 --- a/e2e/tests/node-view.spec.ts +++ b/e2e/tests/node-view.spec.ts @@ -44,9 +44,7 @@ testAll(() => { testAll(() => { test('react node view controls root and content DOM attributes', async ({ page }) => { - const root = page.locator( - '.editor blockquote [data-node-view-root="true"][data-react-node-view-root="true"]', - ) + const root = page.locator('.editor blockquote [data-node-view-root="true"][data-react-node-view-root="true"]') const content = root.locator('[data-node-view-content="true"][data-react-node-view-content="true"]') const anchor = root.locator('[data-node-view-content-anchor="true"]') @@ -70,8 +68,8 @@ testAll(() => { await page.goto('/stories/react/app/react?mixed-content-ref=true', { waitUntil: 'domcontentloaded' }) - await expect.poll(() => warnings.some((warning) => warning.includes('Do not mix contentRef and NodeViewContent'))).toBe( - true, - ) + await expect + .poll(() => warnings.some((warning) => warning.includes('Do not mix contentRef and NodeViewContent'))) + .toBe(true) }) }, ['react']) diff --git a/packages/react/src/nodeView/NodeViewDOM.ts b/packages/react/src/nodeView/NodeViewDOM.ts index c8cbd2e0..35407937 100644 --- a/packages/react/src/nodeView/NodeViewDOM.ts +++ b/packages/react/src/nodeView/NodeViewDOM.ts @@ -241,11 +241,7 @@ function saveOriginalDOMAttrs( } } -function useDOMAttrs( - element: HTMLElement | null, - attrs: NodeViewDOMProps, - ref: Ref | undefined, -): void { +function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: Ref | undefined): void { const prevAttrsRef = useRef(null) const originalAttrsRef = useRef(new Map()) const originalStylesRef = useRef(new Map()) From 6fb8fd53510698e6b1dde9209b1f1659fe5d36e7 Mon Sep 17 00:00:00 2001 From: shlroland Date: Fri, 12 Jun 2026 15:17:10 +0800 Subject: [PATCH 3/4] fix: avoid redundant node view DOM sync --- packages/react/src/nodeView/NodeViewDOM.ts | 57 +++++++++++++++++++--- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/react/src/nodeView/NodeViewDOM.ts b/packages/react/src/nodeView/NodeViewDOM.ts index 35407937..86389336 100644 --- a/packages/react/src/nodeView/NodeViewDOM.ts +++ b/packages/react/src/nodeView/NodeViewDOM.ts @@ -2,6 +2,7 @@ import { createElement, forwardRef, Fragment, + useCallback, useLayoutEffect, useRef, type AriaAttributes, @@ -241,8 +242,37 @@ function saveOriginalDOMAttrs( } } -function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: Ref | undefined): void { +function areStylesEqual(prevStyle: NodeViewStyle | null | undefined, style: NodeViewStyle | null | undefined): boolean { + if (prevStyle === style) return true + if (!prevStyle || !style) return !prevStyle && !style + + const prevKeys = Object.keys(prevStyle) + const keys = Object.keys(style) + + if (prevKeys.length !== keys.length) return false + + return prevKeys.every((name) => name in style && Object.is(prevStyle[name as keyof NodeViewStyle], style[name as keyof NodeViewStyle])) +} + +function areDOMAttrsEqual(prevAttrs: NodeViewDOMProps | null, attrs: NodeViewDOMProps): boolean { + if (prevAttrs === attrs) return true + if (!prevAttrs) return false + + const prevKeys = Object.keys(prevAttrs).filter((name) => name !== 'style') + const keys = Object.keys(attrs).filter((name) => name !== 'style') + + if (prevKeys.length !== keys.length) return false + + return prevKeys.every((name) => name in attrs && Object.is(prevAttrs[name as keyof NodeViewDOMProps], attrs[name as keyof NodeViewDOMProps])) && areStylesEqual(prevAttrs.style, attrs.style) +} + +function useDOMAttrs( + element: HTMLElement | null, + attrs: NodeViewDOMProps, + ref: Ref | undefined, +): void { const prevAttrsRef = useRef(null) + const prevElementRef = useRef(null) const originalAttrsRef = useRef(new Map()) const originalStylesRef = useRef(new Map()) @@ -260,7 +290,16 @@ function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: useLayoutEffect(() => { assignRef(ref, element) + return () => { + assignRef(ref, null) + } + }, [element, ref]) + + useLayoutEffect(() => { const prevAttrs = prevAttrsRef.current + const prevElement = prevElementRef.current + + if (prevElement === element && areDOMAttrsEqual(prevAttrs, attrs)) return if (element) { restoreRemovedDOMAttrs(element, prevAttrs, attrs, originalAttrsRef.current, originalStylesRef.current) @@ -275,10 +314,7 @@ function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: } prevAttrsRef.current = attrs - - return () => { - assignRef(ref, null) - } + prevElementRef.current = element }) } @@ -307,11 +343,16 @@ export const NodeViewContent = forwardRef((pr } }) - return createElement('span', { - 'data-node-view-content-anchor': 'true', - ref: (element: HTMLElement | null) => { + const handleAnchorRef = useCallback( + (element: HTMLElement | null) => { mountContentDOM(element, 'component') }, + [mountContentDOM], + ) + + return createElement('span', { + 'data-node-view-content-anchor': 'true', + ref: handleAnchorRef, style: { display: 'contents' }, }) }) From 99303c1df550afa1e6bc637db4838934dfc96aec Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:18:54 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- packages/react/src/nodeView/NodeViewDOM.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/react/src/nodeView/NodeViewDOM.ts b/packages/react/src/nodeView/NodeViewDOM.ts index 86389336..68514267 100644 --- a/packages/react/src/nodeView/NodeViewDOM.ts +++ b/packages/react/src/nodeView/NodeViewDOM.ts @@ -251,7 +251,9 @@ function areStylesEqual(prevStyle: NodeViewStyle | null | undefined, style: Node if (prevKeys.length !== keys.length) return false - return prevKeys.every((name) => name in style && Object.is(prevStyle[name as keyof NodeViewStyle], style[name as keyof NodeViewStyle])) + return prevKeys.every( + (name) => name in style && Object.is(prevStyle[name as keyof NodeViewStyle], style[name as keyof NodeViewStyle]), + ) } function areDOMAttrsEqual(prevAttrs: NodeViewDOMProps | null, attrs: NodeViewDOMProps): boolean { @@ -263,14 +265,15 @@ function areDOMAttrsEqual(prevAttrs: NodeViewDOMProps | null, attrs: NodeViewDOM if (prevKeys.length !== keys.length) return false - return prevKeys.every((name) => name in attrs && Object.is(prevAttrs[name as keyof NodeViewDOMProps], attrs[name as keyof NodeViewDOMProps])) && areStylesEqual(prevAttrs.style, attrs.style) + return ( + prevKeys.every( + (name) => + name in attrs && Object.is(prevAttrs[name as keyof NodeViewDOMProps], attrs[name as keyof NodeViewDOMProps]), + ) && areStylesEqual(prevAttrs.style, attrs.style) + ) } -function useDOMAttrs( - element: HTMLElement | null, - attrs: NodeViewDOMProps, - ref: Ref | undefined, -): void { +function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: Ref | undefined): void { const prevAttrsRef = useRef(null) const prevElementRef = useRef(null) const originalAttrsRef = useRef(new Map())