Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-08-18-youtube-live-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-youtube': patch
---

YouTube live URLs (`/live/<id>`) now embed the video.
5 changes: 5 additions & 0 deletions .changeset/fix-react-node-view-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/react": patch
---

Fix ReactNodeViewRenderer crash when contentComponent is not available
66 changes: 66 additions & 0 deletions packages/extension-youtube/__tests__/youtube.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/extension-youtube/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/ReactNodeViewRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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: '<p>Hello</p>',
})
}).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 }))
Expand Down
16 changes: 14 additions & 2 deletions packages/react/src/ReactNodeViewRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,21 @@ export function ReactNodeViewRenderer<T = HTMLElement>(
return props => {
// try to get the parent component
// this is important for vue devtools to show the component hierarchy correctly
// maybe its `undefined` because <editor-content> isnt rendered yet
// maybe it's `undefined` because <editor-content> 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<T>(component, props, options)
Expand Down
Loading