diff --git a/.changeset/soft-worms-battle.md b/.changeset/soft-worms-battle.md new file mode 100644 index 00000000000..22fea01f0e2 --- /dev/null +++ b/.changeset/soft-worms-battle.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": patch +--- + +Fixed custom page icons not rendering in React 19 due to a forwarded ref overwriting the internal node reference. diff --git a/packages/ui/src/utils/ExternalElementMounter.tsx b/packages/ui/src/utils/ExternalElementMounter.tsx index 9ab73a7015c..ee4866af3e0 100644 --- a/packages/ui/src/utils/ExternalElementMounter.tsx +++ b/packages/ui/src/utils/ExternalElementMounter.tsx @@ -1,11 +1,21 @@ +import type { Ref } from 'react'; import { useEffect, useRef } from 'react'; type ExternalElementMounterProps = { mount: (el: HTMLDivElement) => void; unmount: (el?: HTMLDivElement) => void; + // In React 19, ref is a regular prop for function components (not wrapped in + // forwardRef). Without this field, ref ends up in ...rest and overwrites the + // internal nodeRef when spread onto the div, preventing mount from being called. + ref?: Ref; }; -export const ExternalElementMounter = ({ mount, unmount, ...rest }: ExternalElementMounterProps) => { +export const ExternalElementMounter = ({ + mount, + unmount, + ref: _forwardedRef, + ...rest +}: ExternalElementMounterProps) => { const nodeRef = useRef(null); useEffect(() => { diff --git a/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx b/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx new file mode 100644 index 00000000000..a1a84fdc0d4 --- /dev/null +++ b/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx @@ -0,0 +1,62 @@ +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ExternalElementMounter } from '../ExternalElementMounter'; + +describe('ExternalElementMounter', () => { + it('calls mount with the host div element', async () => { + const mount = vi.fn(); + const unmount = vi.fn(); + + render( + , + ); + + await waitFor(() => expect(mount).toHaveBeenCalledOnce()); + expect(mount).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + }); + + it('calls unmount with the same element when removed', async () => { + const mount = vi.fn(); + const unmount = vi.fn(); + + const { unmount: removeComponent } = render( + , + ); + + await waitFor(() => expect(mount).toHaveBeenCalledOnce()); + const mountedEl = mount.mock.calls[0][0]; + removeComponent(); + + expect(unmount).toHaveBeenCalledWith(mountedEl); + }); + + it('calls mount even when a forwarded ref is present in props (React 19 regression)', async () => { + // In React 19, ref is a regular prop. Before the fix, a ref forwarded through + // the component chain would land in ...rest and overwrite nodeRef in + // `
`, preventing mount from being called. + // React 18 strips ref before passing to non-forwardRef components, so the + // regression is only observable in a React 19 runtime — but this test guards + // against it being reintroduced. + const mount = vi.fn(); + const unmount = vi.fn(); + + render( + React.createElement(ExternalElementMounter, { + mount, + unmount, + ref: { current: null }, + }), + ); + + await waitFor(() => expect(mount).toHaveBeenCalledOnce()); + expect(mount).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + }); +});