diff --git a/.changeset/2026-08-18-youtube-live-urls.md b/.changeset/2026-08-18-youtube-live-urls.md new file mode 100644 index 0000000000..5d696668f0 --- /dev/null +++ b/.changeset/2026-08-18-youtube-live-urls.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-youtube': patch +--- + +YouTube live URLs (`/live/`) now embed the video. diff --git a/.changeset/fix-react-node-view-renderer.md b/.changeset/fix-react-node-view-renderer.md new file mode 100644 index 0000000000..9f2ae627fb --- /dev/null +++ b/.changeset/fix-react-node-view-renderer.md @@ -0,0 +1,5 @@ +--- +"@tiptap/react": patch +--- + +Fix ReactNodeViewRenderer crash when contentComponent is not available diff --git a/packages/extension-youtube/__tests__/youtube.spec.ts b/packages/extension-youtube/__tests__/youtube.spec.ts index ab050cbdc9..ac3bec9b60 100644 --- a/packages/extension-youtube/__tests__/youtube.spec.ts +++ b/packages/extension-youtube/__tests__/youtube.spec.ts @@ -80,6 +80,72 @@ describe('extension-youtube', () => { getEditorEl()?.remove() }) + describe('YouTube Live URL handling', () => { + it('generates correct embed URL for a YouTube Live URL', () => { + const result = getEmbedUrlFromYoutubeUrl({ + url: 'https://www.youtube.com/live/EkRHhOCdZjw', + controls: true, + }) + + expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw') + }) + + it('generates correct embed URL for a YouTube Live URL without www prefix', () => { + const result = getEmbedUrlFromYoutubeUrl({ + url: 'https://youtube.com/live/EkRHhOCdZjw', + controls: true, + autoplay: true, + }) + + expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1') + }) + + it('generates correct embed URL for a YouTube Live URL with multiple parameters', () => { + const result = getEmbedUrlFromYoutubeUrl({ + url: 'https://www.youtube.com/live/EkRHhOCdZjw', + autoplay: true, + controls: false, + rel: 0, + }) + + expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw?autoplay=1&controls=0&rel=0') + }) + + it('generates correct embed URL for a YouTube Live URL with nocookie option', () => { + const result = getEmbedUrlFromYoutubeUrl({ + url: 'https://www.youtube.com/live/EkRHhOCdZjw', + nocookie: true, + controls: true, + rel: 1, + }) + + expect(result).toBe('https://www.youtube-nocookie.com/embed/EkRHhOCdZjw?rel=1') + }) + + it('keeps a live URL with a query string on the video id', () => { + const result = getEmbedUrlFromYoutubeUrl({ + url: 'https://www.youtube.com/live/EkRHhOCdZjw?feature=share', + controls: true, + }) + + expect(result).toBe('https://www.youtube.com/embed/EkRHhOCdZjw') + }) + }) + + describe('YouTube path-like near misses', () => { + const nearMisses = [ + 'https://www.youtube.com/notlive/EkRHhOCdZjw', + 'https://www.youtube.com/notshorts/EkRHhOCdZjw', + 'https://www.youtube.com/watch?redirect=live/EkRHhOCdZjw', + ] + + nearMisses.forEach(url => { + it(`does not embed a video id for ${url}`, () => { + expect(getEmbedUrlFromYoutubeUrl({ url, controls: true })).toBe(null) + }) + }) + }) + describe('YouTube Shorts URL handling', () => { it('generates correct embed URL for YouTube Shorts with rel parameter', () => { const result = getEmbedUrlFromYoutubeUrl({ diff --git a/packages/extension-youtube/src/utils.ts b/packages/extension-youtube/src/utils.ts index af60a04545..436ab33f47 100644 --- a/packages/extension-youtube/src/utils.ts +++ b/packages/extension-youtube/src/utils.ts @@ -83,7 +83,7 @@ export const getEmbedUrlFromYoutubeUrl = (options: GetEmbedUrlOptions) => { return `${getYoutubeEmbedUrl(nocookie)}${id}` } - const videoIdRegex = /(?:(v|list)=|shorts\/)([-\w]+)/gm + const videoIdRegex = /(?:(v|list)=|\/(?:shorts|live)\/)([-\w]+)/gm const matches = videoIdRegex.exec(url) if (!matches || !matches[2]) { diff --git a/packages/react/src/ReactNodeViewRenderer.spec.ts b/packages/react/src/ReactNodeViewRenderer.spec.ts index 22fdfb9aa9..bda154c193 100644 --- a/packages/react/src/ReactNodeViewRenderer.spec.ts +++ b/packages/react/src/ReactNodeViewRenderer.spec.ts @@ -1,5 +1,7 @@ import { act, render } from '@testing-library/react' import { Editor, Node } from '@tiptap/core' +import type { NodeViewRendererProps } from '@tiptap/core' +import type { NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' @@ -213,6 +215,34 @@ describe('ReactNodeViewRenderer', () => { document.body.innerHTML = '' }) + it('returns a valid fallback node view before EditorContent initializes', () => { + let fallbackNodeView: ProseMirrorNodeView | undefined + const FallbackParagraph = Paragraph.extend({ + addNodeView() { + const renderNodeView = ReactNodeViewRenderer(ReactParagraphComponent) + + return (props: NodeViewRendererProps) => { + fallbackNodeView = renderNodeView(props) + + return fallbackNodeView + } + }, + }) + let editor: Editor | undefined + + expect(() => { + editor = new Editor({ + extensions: [Document, FallbackParagraph, Text], + content: '

Hello

', + }) + }).not.toThrow() + + expect(fallbackNodeView?.dom).toBeInstanceOf(HTMLElement) + expect(fallbackNodeView?.contentDOM).toBeNull() + expect((fallbackNodeView?.update as (() => boolean) | undefined)?.()).toBe(false) + expect(() => editor?.destroy()).not.toThrow() + }) + it('renders nested node views and resolves getPos during render', async () => { const editor = createEditorWithContainers() const { container } = render(React.createElement(EditorContent, { editor })) diff --git a/packages/react/src/ReactNodeViewRenderer.tsx b/packages/react/src/ReactNodeViewRenderer.tsx index da7ebd3cc7..67e63da1a1 100644 --- a/packages/react/src/ReactNodeViewRenderer.tsx +++ b/packages/react/src/ReactNodeViewRenderer.tsx @@ -467,9 +467,21 @@ export function ReactNodeViewRenderer( return props => { // try to get the parent component // this is important for vue devtools to show the component hierarchy correctly - // maybe it’s `undefined` because isn’t rendered yet + // maybe it's `undefined` because isn't rendered yet if (!(props.editor as EditorWithContentComponent).contentComponent) { - return {} as unknown as ProseMirrorNodeView + // Return a minimal valid NodeView with a placeholder DOM element + // to prevent ProseMirror from crashing when calling hasAttribute on undefined + const placeholder = document.createElement('span') + return { + dom: placeholder, + contentDOM: null, + update: () => false, + destroy: () => {}, + selectNode: () => {}, + deselectNode: () => {}, + stopEvent: () => false, + ignoreMutation: () => true, + } as unknown as ProseMirrorNodeView } return new ReactNodeView(component, props, options)