From 2c924237a21e02adbf4676c896e84e80f5326ede Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 12 May 2026 10:53:00 -0700 Subject: [PATCH 1/4] fix(ui): Extract ref from ExternalElementMounter props to prevent nodeRef overwrite (React 19) --- .../ui/src/utils/ExternalElementMounter.tsx | 12 +++- .../__tests__/ExternalElementMounter.test.tsx | 62 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx 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..ff2b5f275a4 --- /dev/null +++ b/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/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)); + }); +}); From b25229d8e939eb4a42f4ed9fe7ad6cd7c9de9f2b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 12 May 2026 10:58:47 -0700 Subject: [PATCH 2/4] chore: run format --- packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx b/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx index ff2b5f275a4..a1a84fdc0d4 100644 --- a/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx +++ b/packages/ui/src/utils/__tests__/ExternalElementMounter.test.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import { render, waitFor } from '@testing-library/react'; +import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ExternalElementMounter } from '../ExternalElementMounter'; From 01c777eec6c95df2dfc71cd99ba31ff300f68238 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 12 May 2026 11:04:00 -0700 Subject: [PATCH 3/4] chore: add changeset --- .changeset/soft-worms-battle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-worms-battle.md diff --git a/.changeset/soft-worms-battle.md b/.changeset/soft-worms-battle.md new file mode 100644 index 00000000000..54f2396c011 --- /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. From 88acc26c068fb7be204ed49cdf32d46dcfdce4d6 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 12 May 2026 14:30:26 -0700 Subject: [PATCH 4/4] chore: update changeset --- .changeset/soft-worms-battle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/soft-worms-battle.md b/.changeset/soft-worms-battle.md index 54f2396c011..22fea01f0e2 100644 --- a/.changeset/soft-worms-battle.md +++ b/.changeset/soft-worms-battle.md @@ -2,4 +2,4 @@ "@clerk/ui": patch --- - Fixed custom page icons not rendering in React 19 due to a forwarded ref overwriting the internal node reference. +Fixed custom page icons not rendering in React 19 due to a forwarded ref overwriting the internal node reference.