-
Notifications
You must be signed in to change notification settings - Fork 73
fix: stabilize Jitsi meeting initialization #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import React from 'react'; | ||
| import { act, fireEvent, render, screen } from '@testing-library/react'; | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| const sdkMeetingMock = vi.fn(); | ||
|
|
||
| vi.mock('@jitsi/react-sdk', () => ({ | ||
| JitsiMeeting: (props) => { | ||
| sdkMeetingMock(props); | ||
| return <iframe data-testid="jitsi-frame" ref={props.getIFrameRef} title="Jitsi meeting" />; | ||
| }, | ||
| })); | ||
|
|
||
| import JitsiMeetComponent from '@/components/organisms/jitsi/JitsiMeeting'; | ||
|
|
||
| describe('JitsiMeetComponent', () => { | ||
| beforeEach(() => { | ||
| vi.useFakeTimers(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('uses the React SDK with a normalized domain and room settings', () => { | ||
| render( | ||
| <JitsiMeetComponent | ||
| domain="https://meet.example.com/" | ||
| roomName="space-42" | ||
| displayName="Amina" | ||
| jwt="signed-token" | ||
| /> | ||
| ); | ||
|
|
||
| expect(sdkMeetingMock).toHaveBeenCalled(); | ||
| const props = sdkMeetingMock.mock.calls.at(-1)[0]; | ||
| expect(props.domain).toBe('meet.example.com'); | ||
| expect(props.roomName).toBe('space-42'); | ||
| expect(props.jwt).toBe('signed-token'); | ||
| expect(props.userInfo).toEqual({ displayName: 'Amina' }); | ||
| expect(screen.getByRole('status')).toHaveTextContent('Loading video room'); | ||
| }); | ||
|
|
||
| it('shows a retryable message when the SDK never becomes ready', () => { | ||
| render(<JitsiMeetComponent roomName="space-42" />); | ||
|
|
||
| act(() => vi.advanceTimersByTime(20000)); | ||
|
|
||
| expect(screen.getByRole('alert')).toHaveTextContent('video room could not be loaded'); | ||
| expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: /try again/i })); | ||
| expect(screen.getByTestId('jitsi-frame')).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('surfaces Jitsi connection errors instead of leaving an empty iframe', () => { | ||
| render(<JitsiMeetComponent roomName="space-42" />); | ||
| const api = { addEventListener: vi.fn(), dispose: vi.fn() }; | ||
| const props = sdkMeetingMock.mock.calls.at(-1)[0]; | ||
|
|
||
| act(() => props.onApiReady(api)); | ||
| const errorHandler = api.addEventListener.mock.calls.find( | ||
| ([eventName]) => eventName === 'errorOccurred' | ||
| )[1]; | ||
| act(() => errorHandler({ name: 'conference.connectionError' })); | ||
|
|
||
| expect(screen.getByRole('alert')).toHaveTextContent('could not connect to the meeting'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,133 +1,181 @@ | ||
| 'use client'; | ||
|
|
||
| import React, { useEffect, useMemo, useRef } from 'react'; | ||
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | ||
| import { JitsiMeeting } from '@jitsi/react-sdk'; | ||
|
|
||
| const INITIALIZATION_TIMEOUT_MS = 20000; | ||
|
|
||
| const getNormalizedDomain = (domain = 'meet.jit.si') => | ||
| domain.replace(/^https?:\/\//i, '').replace(/\/+$/g, ''); | ||
|
|
||
| const loadExternalApi = (domain) => | ||
| new Promise((resolve, reject) => { | ||
| if (typeof window === 'undefined') { | ||
| reject(new Error('Window is undefined')); | ||
| return; | ||
| } | ||
|
|
||
| if (window.JitsiMeetExternalAPI) { | ||
| resolve(); | ||
| return; | ||
| } | ||
|
|
||
| const normalizedDomain = getNormalizedDomain(domain); | ||
| const script = document.createElement('script'); | ||
| script.src = `https://${normalizedDomain}/external_api.js`; | ||
| script.async = true; | ||
| script.onload = resolve; | ||
| script.onerror = (err) => reject(err); | ||
| document.body.appendChild(script); | ||
| }); | ||
| domain.replace(/^https?:\/\//i, '').replace(/\/+$/g, ''); | ||
|
|
||
| const getErrorMessage = (error) => { | ||
| const name = error?.name || error?.type; | ||
|
|
||
| if (name === 'conference.connectionError' || name === 'CONNECTION_ERROR') { | ||
| return 'We could not connect to the meeting. Check your connection and try again.'; | ||
| } | ||
|
|
||
| return 'The video room could not be loaded. Please try again or open it in a new window.'; | ||
| }; | ||
|
|
||
| /** | ||
| * Mounts Jitsi through its React SDK instead of manually injecting | ||
| * external_api.js. The SDK owns script loading and iframe teardown, which | ||
| * prevents duplicate-script races when a meeting is reopened. | ||
| */ | ||
| const JitsiMeetComponent = ({ | ||
| roomName, | ||
| displayName = 'Guest User', | ||
| domain = 'meet.jit.si', | ||
| height = '80vh', | ||
| onReadyToClose, | ||
| className, | ||
| jwt, | ||
| requiresJwt = false, | ||
| roomName, | ||
| displayName = 'Guest User', | ||
| domain = 'meet.jit.si', | ||
| height = '80vh', | ||
| onReadyToClose, | ||
| className, | ||
| jwt, | ||
| requiresJwt = false, | ||
| }) => { | ||
| const containerRef = useRef(null); | ||
| const apiRef = useRef(null); | ||
| const normalizedDomain = useMemo(() => getNormalizedDomain(domain), [domain]); | ||
|
|
||
| useEffect(() => { | ||
| if (!roomName || typeof window === 'undefined') return undefined; | ||
| if (requiresJwt && !jwt) return undefined; | ||
|
|
||
| let isMounted = true; | ||
|
|
||
| const initializeMeeting = async () => { | ||
| try { | ||
| await loadExternalApi(normalizedDomain); | ||
|
|
||
| if (!isMounted || !containerRef.current || !window.JitsiMeetExternalAPI) { | ||
| return; | ||
| } | ||
|
|
||
| containerRef.current.innerHTML = ''; | ||
|
|
||
| const options = { | ||
| roomName, | ||
| parentNode: containerRef.current, | ||
| width: '100%', | ||
| height: '100%', | ||
| userInfo: { | ||
| displayName, | ||
| }, | ||
| configOverwrite: { | ||
| prejoinPageEnabled: false, | ||
| startWithAudioMuted: true, | ||
| startWithVideoMuted: false, | ||
| }, | ||
| interfaceConfigOverwrite: { | ||
| DEFAULT_REMOTE_DISPLAY_NAME: 'Guest', | ||
| SHOW_JITSI_WATERMARK: false, | ||
| SHOW_BRAND_WATERMARK: false, | ||
| SHOW_POWERED_BY: false, | ||
| SHOW_CHROME_EXTENSION_BANNER: false, | ||
| SUPPORT_URL: 'https://deenbridge.com/support', | ||
| }, | ||
| }; | ||
|
|
||
| if (jwt) { | ||
| options.jwt = jwt; | ||
| } | ||
|
|
||
| const api = new window.JitsiMeetExternalAPI(normalizedDomain, options); | ||
|
|
||
| apiRef.current = api; | ||
|
|
||
| const handleReadyToClose = () => { | ||
| apiRef.current?.dispose(); | ||
| apiRef.current = null; | ||
| onReadyToClose?.(); | ||
| }; | ||
|
|
||
| api.addEventListener('readyToClose', handleReadyToClose); | ||
|
|
||
| return () => { | ||
| api.removeEventListener('readyToClose', handleReadyToClose); | ||
| }; | ||
| } catch (error) { | ||
| console.error('Failed to initialize Jitsi meeting:', error); | ||
| } | ||
| }; | ||
|
|
||
| initializeMeeting(); | ||
|
|
||
| return () => { | ||
| isMounted = false; | ||
| if (apiRef.current) { | ||
| apiRef.current.dispose(); | ||
| apiRef.current = null; | ||
| } | ||
| }; | ||
| }, [roomName, displayName, normalizedDomain, onReadyToClose, jwt, requiresJwt]); | ||
| const apiRef = useRef(null); | ||
| const apiErrorListenerRef = useRef(null); | ||
| const timeoutRef = useRef(null); | ||
| const [attempt, setAttempt] = useState(0); | ||
| const [status, setStatus] = useState('loading'); | ||
| const [errorMessage, setErrorMessage] = useState(''); | ||
| const normalizedDomain = getNormalizedDomain(domain); | ||
|
|
||
| const clearInitializationTimeout = useCallback(() => { | ||
| if (timeoutRef.current) { | ||
| window.clearTimeout(timeoutRef.current); | ||
| timeoutRef.current = null; | ||
| } | ||
| }, []); | ||
|
|
||
| const showError = useCallback( | ||
| (error) => { | ||
| clearInitializationTimeout(); | ||
| setStatus('error'); | ||
| setErrorMessage(getErrorMessage(error)); | ||
| }, | ||
| [clearInitializationTimeout] | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (!roomName || (requiresJwt && !jwt)) return undefined; | ||
|
|
||
| setStatus('loading'); | ||
| setErrorMessage(''); | ||
| timeoutRef.current = window.setTimeout(() => showError(), INITIALIZATION_TIMEOUT_MS); | ||
|
Comment on lines
+63
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Description: Look for token refresh or re-issue paths that update meetingToken while the meeting is active.
rg -nP -C6 '\bmeetingToken\b|setMeetingToken|refresh(Token|Meeting)' --glob '!**/node_modules/**' || trueRepository: Deen-Bridge/dnb-frontend Length of output: 162 🏁 Script executed: printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/deen-bridge-dnb-frontend-d982c7f8 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- JitsiMeeting outline ---'
ast-grep outline components/organisms/jitsi/JitsiMeeting.jsx --view compact
printf '%s\n' '--- JitsiMeeting relevant source ---'
cat -n components/organisms/jitsi/JitsiMeeting.jsx | sed -n '1,180p'Repository: Deen-Bridge/dnb-frontend Length of output: 7245 🏁 Script executed: printf '%s\n' '--- applicable conventions ---'
cat /tmp/coderabbit-repo-knowledge/deen-bridge-dnb-frontend-d982c7f8/conventions/app.md
cat /tmp/coderabbit-repo-knowledge/deen-bridge-dnb-frontend-d982c7f8/conventions/repo-wide.md
printf '%s\n' '--- package binding ---'
rg -n -C3 '"`@jitsi/react-sdk`"|JitsiMeetComponent|<JitsiMeetComponent|<JitsiMeeting' package.json package-lock.json components app
printf '%s\n' '--- domain and JWT bindings ---'
rg -n -P -C5 '\b(jwt|domain|meetingToken|setMeetingToken|refreshToken|refreshMeeting)\b' components app --glob '*.{js,jsx,ts,tsx}' | head -240Repository: Deen-Bridge/dnb-frontend Length of output: 23600 🏁 Script executed: printf '%s\n' '--- direct caller state and meeting lifecycle ---'
cat -n components/organisms/dashboard/JaasMeetingClientSection.jsx | sed -n '55,245p'
cat -n components/organisms/dashboard/JaasMeetingClientSection.jsx | sed -n '315,465p'
printf '%s\n' '--- dependency lock entry ---'
rg -n -A18 -B4 '"node_modules/@jitsi/react-sdk"' package-lock.jsonRepository: Deen-Bridge/dnb-frontend Length of output: 13511 🌐 Web query:
💡 Result: In Citations:
🏁 Script executed: set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSL https://registry.npmjs.org/@jitsi%2freact-sdk/1.4.4 -o "$tmpdir/metadata.json"
tarball="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["dist"]["tarball"])' "$tmpdir/metadata.json")"
curl -fsSL "$tarball" -o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
printf '%s\n' '--- package files ---'
find "$tmpdir/package" -maxdepth 3 -type f -print
printf '%s\n' '--- lifecycle and callback references ---'
rg -n -C8 'onApiReady|roomName|jwt|useEffect|componentDidUpdate|componentWillUnmount' "$tmpdir/package" --glob '*.{js,jsx,mjs,cjs}'Repository: Deen-Bridge/dnb-frontend Length of output: 14169 Do not restart the loading timeout for an existing meeting. When 🤖 Prompt for AI Agents |
||
|
|
||
| return () => { | ||
| clearInitializationTimeout(); | ||
| if (apiRef.current && apiErrorListenerRef.current) { | ||
| apiRef.current.removeEventListener?.('errorOccurred', apiErrorListenerRef.current); | ||
| } | ||
| apiRef.current = null; | ||
| apiErrorListenerRef.current = null; | ||
| }; | ||
| }, [attempt, roomName, normalizedDomain, jwt, requiresJwt, clearInitializationTimeout, showError]); | ||
|
|
||
| const handleApiReady = useCallback( | ||
| (api) => { | ||
| apiRef.current = api; | ||
| apiErrorListenerRef.current = showError; | ||
| clearInitializationTimeout(); | ||
| setStatus('ready'); | ||
| api.addEventListener?.('errorOccurred', showError); | ||
| }, | ||
| [clearInitializationTimeout, showError] | ||
| ); | ||
|
|
||
| const handleReadyToClose = useCallback(() => { | ||
| clearInitializationTimeout(); | ||
| apiRef.current = null; | ||
| onReadyToClose?.(); | ||
| }, [clearInitializationTimeout, onReadyToClose]); | ||
|
|
||
| const retry = useCallback(() => { | ||
| apiRef.current?.dispose?.(); | ||
| apiRef.current = null; | ||
| apiErrorListenerRef.current = null; | ||
| setStatus('loading'); | ||
| setErrorMessage(''); | ||
| setAttempt((value) => value + 1); | ||
| }, []); | ||
|
|
||
| if (!roomName) { | ||
| return ( | ||
| <div className="mt-4 rounded-xl border border-red-300 bg-red-50 p-4 text-sm text-red-800" role="alert"> | ||
| This live session does not have a meeting room yet. Please contact the host. | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (requiresJwt && !jwt) { | ||
| return ( | ||
| <div className="mt-4 rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-800" role="status"> | ||
| Preparing your secure meeting session... | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (status === 'error') { | ||
| return ( | ||
| <div | ||
| ref={containerRef} | ||
| className={className} | ||
| style={{ | ||
| width: '100%', | ||
| height, | ||
| marginTop: '1rem', | ||
| borderRadius: '12px', | ||
| overflow: 'hidden', | ||
| backgroundColor: '#0a0f14', | ||
| }} | ||
| /> | ||
| <div className="mt-4 rounded-xl border border-red-300 bg-red-50 p-5 text-sm text-red-800" role="alert"> | ||
| <p>{errorMessage}</p> | ||
| <button | ||
| type="button" | ||
| className="mt-3 rounded-md bg-red-700 px-3 py-2 font-medium text-white hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-red-500" | ||
| onClick={retry} | ||
| > | ||
| Try again | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| className={className} | ||
| style={{ | ||
| width: '100%', | ||
| height, | ||
| marginTop: '1rem', | ||
| borderRadius: '12px', | ||
| overflow: 'hidden', | ||
| backgroundColor: '#0a0f14', | ||
| }} | ||
| > | ||
| <JitsiMeeting | ||
| key={attempt} | ||
| domain={normalizedDomain} | ||
| roomName={roomName} | ||
| jwt={jwt} | ||
| userInfo={{ displayName }} | ||
| configOverwrite={{ | ||
| prejoinPageEnabled: false, | ||
| startWithAudioMuted: true, | ||
| startWithVideoMuted: false, | ||
| }} | ||
| interfaceConfigOverwrite={{ | ||
| DEFAULT_REMOTE_DISPLAY_NAME: 'Guest', | ||
| SHOW_JITSI_WATERMARK: false, | ||
| SHOW_BRAND_WATERMARK: false, | ||
| SHOW_POWERED_BY: false, | ||
| SHOW_CHROME_EXTENSION_BANNER: false, | ||
| SUPPORT_URL: 'https://deenbridge.com/support', | ||
| }} | ||
| onApiReady={handleApiReady} | ||
| onReadyToClose={handleReadyToClose} | ||
| getIFrameRef={(iframe) => { | ||
| if (iframe) { | ||
| iframe.style.height = '100%'; | ||
| iframe.style.width = '100%'; | ||
| } | ||
| }} | ||
| /> | ||
| {status === 'loading' && ( | ||
| <div className="sr-only" role="status">Loading video room…</div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default JitsiMeetComponent; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Deen-Bridge/dnb-frontend
Length of output: 194
🏁 Script executed:
Repository: Deen-Bridge/dnb-frontend
Length of output: 9008
🏁 Script executed:
Repository: Deen-Bridge/dnb-frontend
Length of output: 10676
🏁 Script executed:
Repository: Deen-Bridge/dnb-frontend
Length of output: 18668
🏁 Script executed:
Repository: Deen-Bridge/dnb-frontend
Length of output: 6489
Use
meet.jit.siwhenNEXT_PUBLIC_JITSI_DOMAINis emptyIf
NEXT_PUBLIC_JITSI_DOMAIN="", the configuration accepts it,normalizeDomainpreserves it, andJitsiMeetingreceives an emptydomain. Treat empty domains as invalid before normalization.🤖 Prompt for AI Agents