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,
}}>