Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ function GenericContentSidekickAreaManager(

const genericContentId = useRef<string | undefined>('');
// The client may call contentFunction more than once for the same container (for
// instance when the item is re-registered). Calling createRoot twice on one element
// detaches the previously rendered tree and leaves the panel blank, so the root is
// kept and reused for as long as the container is the same node.
// instance when the item is re-registered, which happens whenever this effect below
// reruns for a new intl - e.g. a language change). Calling createRoot twice on one
// element detaches the previously rendered tree and leaves the panel blank, so the root
// is kept and reused for as long as the container is the same node.
//
// The client also owns the returned root's lifecycle: while the panel is open, a
// re-registration can make it unmount the root it was previously handed for this same
// container before calling contentFunction again. That unmount happens from outside this
// closure, so the cached entry below is wrapped to notice it and drop itself - otherwise
// `render` is called on an already-unmounted root and the client crashes with
// "Cannot update an unmounted root." (issue #135).
const panelRoot = useRef<{ element: HTMLElement; root: ReactDOM.Root } | null>(null);

const sidekickAreaName = intl.formatMessage(intlMessages.sidekickAreaTitle);
Expand All @@ -57,7 +65,19 @@ function GenericContentSidekickAreaManager(
new GenericContentSidekickArea({
contentFunction: (element: HTMLElement) => {
if (!panelRoot.current || panelRoot.current.element !== element) {
panelRoot.current = { element, root: ReactDOM.createRoot(element) };
const root = ReactDOM.createRoot(element);
panelRoot.current = {
element,
root: {
render: (node) => root.render(node),
unmount: () => {
root.unmount();
if (panelRoot.current?.element === element) {
panelRoot.current = null;
}
},
},
};
}
panelRoot.current.root.render(
<PickRandomUserPanel
Expand Down
61 changes: 60 additions & 1 deletion tests/unit/sidekick-area-registration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import * as React from 'react';
import {
describe, it, expect, vi,
} from 'vitest';
import { render, renderHook } from '@testing-library/react';
import {
render, renderHook, act,
} from '@testing-library/react';
import { createIntl } from 'react-intl';
import GenericContentSidekickAreaManager from '../../src/components/extensible-areas/generic-content-sidekick-area/component';
import { useGetInternationalization } from '../../src/commons/hooks';
Expand Down Expand Up @@ -148,6 +150,63 @@ describe('sidekick area registration', () => {
});
});

// Regression test for https://github.com/bigbluebutton/bbb-plugin-pick-random-user/issues/135:
// switching the client's display language while the sidekick panel is open crashed the
// client with "Cannot update an unmounted root.". The client re-registers the item's
// contentFunction whenever this plugin's effect re-runs for a new intl/locale, and it may
// unmount the root it was previously handed before calling contentFunction again for the
// very same container — that sequence is what the reused id from the second test above
// makes possible.
function makePluginApiForPanel(setGenericContentItems: ReturnType<typeof vi.fn>) {
return {
setGenericContentItems,
useCurrentUser: () => ({ data: { userId: 'presenter-1', presenter: true } }),
useDataChannel: () => ({
data: { loading: false, data: [] },
pushEntry: vi.fn(),
deleteEntry: vi.fn(),
}),
} as never;
}

describe('sidekick area contentFunction (issue #135)', () => {
it('recovers instead of crashing when the client re-invokes contentFunction on a container whose root it already unmounted', () => {
const setGenericContentItems = vi.fn(() => ['generated-id-1']);
const pluginApi = makePluginApiForPanel(setGenericContentItems);

render(
<GenericContentSidekickAreaManager
pluginApi={pluginApi}
intl={intl}
currentUser={currentUser(true)}
pickRandomUserSettings={settings()}
/>,
);

const { contentFunction } = setGenericContentItems.mock.calls[0][0][0];
const container = document.createElement('div');
document.body.appendChild(container);

let firstRoot: { unmount: () => void };
act(() => {
firstRoot = contentFunction(container);
});

// The client tears down the root it was handed for the previously registered content
// (e.g. while processing a re-registration triggered by a locale change) but keeps
// reusing the same container element.
act(() => {
firstRoot.unmount();
});

expect(() => act(() => {
contentFunction(container);
})).not.toThrow();

expect(container.querySelector('[data-test="pickRandomUserPanel"]')).not.toBeNull();
});
});

describe('useGetInternationalization', () => {
it('keeps the same intl instance across renders', () => {
const messages = { 'pickRandomUserPlugin.modal.title': 'Pick random user' };
Expand Down
Loading