From 37843c550eddb405ffca7e694a443452fb775b76 Mon Sep 17 00:00:00 2001 From: Max Engine Date: Sun, 26 Jul 2026 04:08:11 -0600 Subject: [PATCH] fix: custom header content cannot be placed below the site header Custom header HTML is inserted at the top of , which puts it above the nav bar and outside #root. Since the nav bar and the page are both inside #root and the injected node is a sibling of it, there is no CSS that can move it back into place -- so a banner under the site's own header, which is what most sites want a header area for, is unreachable. The layout now renders a slot below
and the server template emits into the same element, so both render paths agree on where custom header content lives. Two details this needs to work: - Layout renders the content rather than Customize injecting it. React replaces #root's contents when it takes over, so anything injected there is destroyed the moment the page hydrates, and restoring it afterwards makes the banner visibly appear, vanish and return. - The rendered value falls back to whatever is already in the slot. The customize store is filled by an API call and is empty on the first render, so rendering it empty would blank the server's markup for as long as that request takes. Customize keeps the head and footer areas, which are outside #root and unaffected. --- ui/src/components/Customize/index.tsx | 36 ++++++++++++--------------- ui/src/pages/Layout/index.tsx | 24 +++++++++++++++++- ui/template/header.html | 12 ++++++--- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/ui/src/components/Customize/index.tsx b/ui/src/components/Customize/index.tsx index 7da85b057..13387377f 100644 --- a/ui/src/components/Customize/index.tsx +++ b/ui/src/components/Customize/index.tsx @@ -23,7 +23,9 @@ import { useLocation } from 'react-router-dom'; import { customizeStore } from '@/stores'; const CUSTOM_MARK_HEAD = 'customize_head'; -const CUSTOM_MARK_HEADER = 'customize_header'; +// The header area is NOT handled here. It is rendered by pages/Layout as part +// of the React tree, because it lives inside #root and anything injected there +// is destroyed when React takes over -- see the comment on the slot. const CUSTOM_MARK_FOOTER = 'customize_footer'; const makeMarker = (mark) => { @@ -104,34 +106,28 @@ const handleCustomHead = (content) => { renderCustomArea(el, CUSTOM_MARK_HEAD, 'beforeend', content); }; -const handleCustomHeader = (content) => { - const el = document.body; - renderCustomArea(el, CUSTOM_MARK_HEADER, 'afterbegin', content); -}; - const handleCustomFooter = (content) => { const el = document.body; renderCustomArea(el, CUSTOM_MARK_FOOTER, 'beforeend', content); }; const Index: FC = () => { - const { custom_head, custom_header, custom_footer } = customizeStore( - (state) => state, - ); + const { custom_head, custom_footer } = customizeStore((state) => state); const { pathname } = useLocation(); useEffect(() => { - const isSeo = document.querySelector('meta[name="go-template"]'); - if (!isSeo) { - setTimeout(() => { - handleCustomHead(custom_head); - }, 1000); - handleCustomHeader(custom_header); - handleCustomFooter(custom_footer); - } else { - isSeo.remove(); - } - }, [custom_head, custom_header, custom_footer]); + // Drop the server-render marker, then refresh the head and footer areas + // regardless of whether the server had already written them. Both live + // outside #root, so re-running is a replace, not a duplicate: + // renderCustomArea brackets its output with comment markers and clears what + // lies between them first. + document.querySelector('meta[name="go-template"]')?.remove(); + + setTimeout(() => { + handleCustomHead(custom_head); + }, 1000); + handleCustomFooter(custom_footer); + }, [custom_head, custom_footer]); useEffect(() => { /** diff --git a/ui/src/pages/Layout/index.tsx b/ui/src/pages/Layout/index.tsx index 048ca812e..374665598 100644 --- a/ui/src/pages/Layout/index.tsx +++ b/ui/src/pages/Layout/index.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { FC, memo, useEffect } from 'react'; +import { FC, memo, useEffect, useState } from 'react'; import { Outlet, useLocation, ScrollRestoration } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; @@ -30,6 +30,7 @@ import { errorCodeStore, siteSecurityStore, themeSettingStore, + customizeStore, } from '@/stores'; import { Header, @@ -59,6 +60,14 @@ const Layout: FC = () => { const { show: showLoginToContinueModal } = loginToContinueStore(); const { data: notificationData } = useQueryNotificationStatus(); const layout = themeSettingStore((state) => state.layout); + const storedCustomHeader = customizeStore((state) => state.custom_header); + // Seeded from whatever the server rendered into the slot. The store is filled + // by an API call, so it is empty on the first render, and rendering that empty + // would blank the server's markup for as long as the request takes. + const [paintedCustomHeader] = useState( + () => document.getElementById('custom-header-slot')?.innerHTML ?? '', + ); + const customHeader = storedCustomHeader || paintedCustomHeader; useEffect(() => { // handle footnote links const fixFootnoteLinks = () => { @@ -211,6 +220,19 @@ const Layout: FC = () => { revalidateOnFocus: false, }}>
+ {/* Custom header content, below the site header rather than above it, + which is where a site wants a banner. + + Rendered here rather than injected after mount: React owns everything + inside #root, so injected markup is destroyed when it takes over, and + re-adding it afterwards makes the banner visibly appear, vanish and + return. Rendering it as part of the tree means the server's markup and + React's output are the same thing. */} +
- - {{if .HeaderCode }} {{.HeaderCode | templateHTML}} {{end}} -
@@ -187,6 +184,15 @@
+ +
+ + {{if .HeaderCode }} {{.HeaderCode | templateHTML}} {{end}} + +