|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { useEffect } from 'react'; |
4 | | -import { createRoot } from 'react-dom/client'; |
| 3 | +import { useEffect, useState } from 'react'; |
| 4 | +import { createPortal } from 'react-dom'; |
5 | 5 | import { NavbarDropdown } from './navbar-dropdown'; |
6 | 6 |
|
7 | 7 | /** |
8 | | - * Injects custom navbar dropdown at ≤1023px breakpoint |
9 | | - * Replaces fumadocs' dropdown which has CSS specificity issues with Tailwind v4 |
| 8 | + * Mounts the custom navbar dropdown at ≤1023px breakpoint. |
| 9 | + * |
| 10 | + * Renders via createPortal (not createRoot) so the portal stays inside the |
| 11 | + * parent React tree and inherits providers like SessionProvider. A previous |
| 12 | + * createRoot-based version broke when NavbarDropdown started calling |
| 13 | + * useSession() — the detached root had no provider and the component crashed, |
| 14 | + * leaving an empty <li> with no burger button. |
10 | 15 | */ |
11 | 16 | export function NavbarDropdownInjector() { |
| 17 | + const [container, setContainer] = useState<HTMLLIElement | null>(null); |
| 18 | + const [isMobile, setIsMobile] = useState(false); |
| 19 | + |
12 | 20 | useEffect(() => { |
13 | | - const checkAndInject = () => { |
14 | | - const isMobile = window.innerWidth <= 1023; |
15 | | - |
16 | | - if (!isMobile) { |
17 | | - // Remove if exists |
18 | | - const existing = document.querySelector('[data-custom-navbar-dropdown]'); |
19 | | - if (existing) { |
20 | | - existing.remove(); |
21 | | - } |
22 | | - return; |
23 | | - } |
| 21 | + const update = () => setIsMobile(window.innerWidth <= 1023); |
| 22 | + update(); |
| 23 | + window.addEventListener('resize', update); |
| 24 | + return () => window.removeEventListener('resize', update); |
| 25 | + }, []); |
24 | 26 |
|
25 | | - const navbar = document.querySelector('nav[aria-label="Main"]') || document.querySelector('header[aria-label="Main"]'); |
26 | | - if (!navbar) return; |
| 27 | + useEffect(() => { |
| 28 | + if (!isMobile) { |
| 29 | + document |
| 30 | + .querySelectorAll('[data-custom-navbar-dropdown]') |
| 31 | + .forEach((el) => el.remove()); |
| 32 | + setContainer(null); |
| 33 | + return; |
| 34 | + } |
27 | 35 |
|
28 | | - // Check if already exists |
29 | | - if (navbar.querySelector('[data-custom-navbar-dropdown]')) { |
30 | | - return; |
31 | | - } |
| 36 | + let injected: HTMLLIElement | null = null; |
| 37 | + let cancelled = false; |
32 | 38 |
|
33 | | - // Find the right side container (where search icon is) |
34 | | - const rightContainer = navbar.querySelector('ul.flex.flex-row.items-center.ms-auto') || |
35 | | - navbar.querySelector('ul.ms-auto'); |
36 | | - if (!rightContainer) return; |
| 39 | + const tryInject = (): boolean => { |
| 40 | + if (cancelled) return false; |
37 | 41 |
|
38 | | - // Create container |
39 | | - const container = document.createElement('li'); |
40 | | - container.setAttribute('data-custom-navbar-dropdown', 'true'); |
41 | | - container.className = 'list-none'; |
42 | | - |
43 | | - // Append at the end (after search icon) |
44 | | - rightContainer.appendChild(container); |
| 42 | + const navbar = |
| 43 | + document.querySelector('nav[aria-label="Main"]') || |
| 44 | + document.querySelector('header[aria-label="Main"]'); |
| 45 | + if (!navbar) return false; |
45 | 46 |
|
46 | | - // Render React component |
47 | | - const root = createRoot(container); |
48 | | - root.render(<NavbarDropdown />); |
| 47 | + const existing = navbar.querySelector<HTMLLIElement>( |
| 48 | + '[data-custom-navbar-dropdown]', |
| 49 | + ); |
| 50 | + if (existing) { |
| 51 | + injected = existing; |
| 52 | + setContainer(existing); |
| 53 | + return true; |
| 54 | + } |
| 55 | + |
| 56 | + const rightContainer = |
| 57 | + navbar.querySelector('ul.flex.flex-row.items-center.ms-auto') || |
| 58 | + navbar.querySelector('ul.ms-auto'); |
| 59 | + if (!rightContainer) return false; |
| 60 | + |
| 61 | + const li = document.createElement('li'); |
| 62 | + li.setAttribute('data-custom-navbar-dropdown', 'true'); |
| 63 | + li.className = 'list-none'; |
| 64 | + rightContainer.appendChild(li); |
| 65 | + injected = li; |
| 66 | + setContainer(li); |
| 67 | + return true; |
49 | 68 | }; |
50 | 69 |
|
51 | | - checkAndInject(); |
52 | | - const resizeHandler = () => checkAndInject(); |
53 | | - window.addEventListener('resize', resizeHandler); |
54 | | - const timeout = setTimeout(checkAndInject, 100); |
| 70 | + if (tryInject()) { |
| 71 | + return () => { |
| 72 | + cancelled = true; |
| 73 | + if (injected?.parentNode) injected.parentNode.removeChild(injected); |
| 74 | + }; |
| 75 | + } |
| 76 | + |
| 77 | + // Navbar wasn't ready yet — watch for it to appear / re-render. |
| 78 | + const observer = new MutationObserver(() => { |
| 79 | + if (tryInject()) observer.disconnect(); |
| 80 | + }); |
| 81 | + observer.observe(document.body, { childList: true, subtree: true }); |
55 | 82 |
|
56 | 83 | return () => { |
57 | | - window.removeEventListener('resize', resizeHandler); |
58 | | - clearTimeout(timeout); |
| 84 | + cancelled = true; |
| 85 | + observer.disconnect(); |
| 86 | + if (injected?.parentNode) injected.parentNode.removeChild(injected); |
59 | 87 | }; |
60 | | - }, []); |
| 88 | + }, [isMobile]); |
61 | 89 |
|
62 | | - return null; |
| 90 | + if (!container) return null; |
| 91 | + return createPortal(<NavbarDropdown />, container); |
63 | 92 | } |
64 | | - |
|
0 commit comments