|
| 1 | +import { render, screen } from '@testing-library/react'; |
| 2 | +import React, { createElement, useEffect, useRef } from 'react'; |
| 3 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 4 | + |
| 5 | +import { OrganizationProfilePage } from '../../components/uiComponents'; |
| 6 | +import { useOrganizationProfileCustomPages } from '../useCustomPages'; |
| 7 | + |
| 8 | +vi.mock('@clerk/shared/utils', () => ({ |
| 9 | + logErrorInDevMode: vi.fn(), |
| 10 | +})); |
| 11 | + |
| 12 | +// Per-page mount/unmount counters. A remount re-runs the mount effect. |
| 13 | +const mounts: Record<string, number> = {}; |
| 14 | +const unmounts: Record<string, number> = {}; |
| 15 | + |
| 16 | +// Stable component type, defined once. If it remounts across a rerender it is |
| 17 | +// because the portal wrapping it changed identity or render key. |
| 18 | +const TrackedContent = ({ id, text }: { id: string; text: string }) => { |
| 19 | + useEffect(() => { |
| 20 | + mounts[id] = (mounts[id] ?? 0) + 1; |
| 21 | + return () => { |
| 22 | + unmounts[id] = (unmounts[id] ?? 0) + 1; |
| 23 | + }; |
| 24 | + // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once instrument; id is stable per instance |
| 25 | + }, []); |
| 26 | + return <div data-testid={`content-${id}`}>{text}</div>; |
| 27 | +}; |
| 28 | + |
| 29 | +/** |
| 30 | + * Faithfully reproduces the production render path for custom pages: |
| 31 | + * - useOrganizationProfileCustomPages parses children into { customPages, customPagesPortals } |
| 32 | + * - clerk-js calls customPages[i].mount(node) once per logical page (by identity; here keyed by url) |
| 33 | + * - CustomPortalsRenderer renders each portal via createElement(portal, { key }) using the STABLE key |
| 34 | + */ |
| 35 | +const Harness = ({ children, tick }: { children: React.ReactNode; tick: number }) => { |
| 36 | + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(children); |
| 37 | + const hostRef = useRef<HTMLDivElement | null>(null); |
| 38 | + const mountedUrls = useRef<Set<string>>(new Set()); |
| 39 | + |
| 40 | + useEffect(() => { |
| 41 | + customPages.forEach(page => { |
| 42 | + if (page.mount && page.url && !mountedUrls.current.has(page.url)) { |
| 43 | + mountedUrls.current.add(page.url); |
| 44 | + const node = document.createElement('div'); |
| 45 | + hostRef.current?.appendChild(node); |
| 46 | + page.mount(node); |
| 47 | + } |
| 48 | + }); |
| 49 | + }); |
| 50 | + |
| 51 | + return ( |
| 52 | + <> |
| 53 | + <div |
| 54 | + data-tick={tick} |
| 55 | + ref={hostRef} |
| 56 | + /> |
| 57 | + {customPagesPortals.map(({ key, portal }) => createElement(portal, { key }))} |
| 58 | + </> |
| 59 | + ); |
| 60 | +}; |
| 61 | + |
| 62 | +const makePage = (id: string, label: string, url: string, text: string) => ( |
| 63 | + <OrganizationProfilePage |
| 64 | + key={id} |
| 65 | + label={label} |
| 66 | + labelIcon={<span>i</span>} |
| 67 | + url={url} |
| 68 | + > |
| 69 | + <TrackedContent |
| 70 | + id={id} |
| 71 | + text={text} |
| 72 | + /> |
| 73 | + </OrganizationProfilePage> |
| 74 | +); |
| 75 | + |
| 76 | +afterEach(() => { |
| 77 | + for (const k of Object.keys(mounts)) { |
| 78 | + delete mounts[k]; |
| 79 | + } |
| 80 | + for (const k of Object.keys(unmounts)) { |
| 81 | + delete unmounts[k]; |
| 82 | + } |
| 83 | +}); |
| 84 | + |
| 85 | +describe('custom pages remount behavior (integration through CustomPortalsRenderer path)', () => { |
| 86 | + it('does not remount custom page content when the parent rerenders', async () => { |
| 87 | + const { rerender } = render(<Harness tick={0}>{[makePage('p1', 'Page 1', 'page-1', 'first')]}</Harness>); |
| 88 | + |
| 89 | + await screen.findByText('first'); |
| 90 | + expect(mounts['p1']).toBe(1); |
| 91 | + |
| 92 | + // Parent rerenders for an unrelated reason; the page content prop changes but the |
| 93 | + // logical page (key/label/url) is identical. |
| 94 | + rerender(<Harness tick={1}>{[makePage('p1', 'Page 1', 'page-1', 'second')]}</Harness>); |
| 95 | + |
| 96 | + await screen.findByText('second'); |
| 97 | + expect(mounts['p1']).toBe(1); |
| 98 | + expect(unmounts['p1'] ?? 0).toBe(0); |
| 99 | + }); |
| 100 | + |
| 101 | + it('does not remount a surviving custom page when another page is inserted before it', async () => { |
| 102 | + const second = makePage('second', 'Second', 'second', 'second-content'); |
| 103 | + const first = makePage('first', 'First', 'first', 'first-content'); |
| 104 | + |
| 105 | + const { rerender } = render(<Harness tick={0}>{[second]}</Harness>); |
| 106 | + await screen.findByText('second-content'); |
| 107 | + expect(mounts['second']).toBe(1); |
| 108 | + |
| 109 | + // Insert a new page BEFORE the existing one. |
| 110 | + rerender(<Harness tick={1}>{[first, second]}</Harness>); |
| 111 | + await screen.findByText('first-content'); |
| 112 | + |
| 113 | + // The surviving page keeps its stable key + portal identity, so React reconciles it as an |
| 114 | + // update rather than a remount. |
| 115 | + expect(mounts['second']).toBe(1); |
| 116 | + expect(unmounts['second'] ?? 0).toBe(0); |
| 117 | + // The newly inserted page mounts exactly once. |
| 118 | + expect(mounts['first']).toBe(1); |
| 119 | + }); |
| 120 | +}); |
0 commit comments