feat(FN-1583): merge fusion/fn-1583

This commit is contained in:
gsxdsm
2026-04-10 22:20:39 -07:00
parent 05229a5c0f
commit 3fdd04d6cb
4 changed files with 240 additions and 6 deletions

View File

@@ -890,8 +890,7 @@ describe("useTheme", () => {
// The correct implementation produces file:///app/theme-data.css
// These regexes catch the malformed pattern
expect(href).not.toMatch(/apptheme-data\.css$/);
expect(href).not.toMatch(/theme-data\.css$/ && !/\/theme-data\.css$/.test(href || ""));
// Verify it's actually a valid file URL
// Verify it's actually a valid file URL (no concatenation bug)
expect(href).toMatch(/^file:\/\/.*\/theme-data\.css$/);
// Clean up
@@ -900,6 +899,221 @@ describe("useTheme", () => {
configurable: true,
});
});
it("moves pre-existing theme-data link to end of head for correct cascade", () => {
// Regression test: When index.html pre-hydration script injects #theme-data early,
// the runtime hook must move it to the end of <head> so color-theme rules
// take precedence over base token redefinitions in later stylesheets.
//
// CSS cascade failure mode:
// 1. Pre-hydration script injects <link id="theme-data"> early in <head>
// 2. styles.css loads and re-defines :root tokens, overriding color-theme rules
// 3. Dark color themes appear broken because base tokens win the cascade
//
// The fix: after href reconciliation, append the existing link to end of head
// so its rules are evaluated after all other stylesheets.
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Set up base styles that would normally load after theme-data in real HTML
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root { --bg: #0d1117; --surface: #161b22; }
[data-theme="light"] { --bg: #ffffff; --surface: #f6f8fa; }
`;
document.head.appendChild(baseStyles);
// Inject theme-data link BEFORE base-styles (simulating pre-hydration injecting early)
const earlyLink = document.createElement("link");
earlyLink.id = "theme-data";
earlyLink.rel = "stylesheet";
earlyLink.href = "/theme-data.css";
document.head.insertBefore(earlyLink, baseStyles);
// Verify theme-data is early and base styles are after it
const headChildren = Array.from(document.head.children);
const themeDataIndex = headChildren.findIndex((el) => el.id === "theme-data");
const baseStylesIndex = headChildren.findIndex((el) => el.id === "base-styles");
expect(themeDataIndex).toBe(0);
expect(baseStylesIndex).toBe(1);
// Now switch to a non-default color theme via runtime hook
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setColorTheme("ocean");
});
// After runtime hook runs, theme-data link should be MOVED to end of head
const updatedHeadChildren = Array.from(document.head.children);
const newThemeDataIndex = updatedHeadChildren.findIndex((el) => el.id === "theme-data");
const newBaseStylesIndex = updatedHeadChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIndex).toBeGreaterThan(newBaseStylesIndex);
// Still only one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
it("dark mode color theme precedence is protected by link reordering", async () => {
// This test verifies the specific dark-mode cascade failure scenario:
// - Dark mode + non-default color theme
// - Pre-existing #theme-data link from pre-hydration
// - Base styles that redefine tokens after theme-data
//
// The color theme rules must win over base token redefinitions.
localStorageMock[THEME_MODE_STORAGE_KEY] = "dark";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "ocean";
// Mock fetch to resolve so isHydrating becomes false and useEffect runs
mockFetchGlobalSettings.mockResolvedValue({
themeMode: "dark",
colorTheme: "ocean",
});
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Simulate the HTML structure with theme-data injected early by pre-hydration
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root {
--bg: #0d1117;
--surface: #161b22;
--card: #21262d;
}
`;
document.head.appendChild(baseStyles);
// Pre-existing theme-data link (from pre-hydration)
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
// Insert before base-styles to simulate pre-hydration injecting early
document.head.insertBefore(existingLink, baseStyles);
// Verify pre-conditions: theme-data at index 0, base-styles at index 1
const children = Array.from(document.head.children);
const themeDataIdx = children.findIndex((el) => el.id === "theme-data");
const baseIdx = children.findIndex((el) => el.id === "base-styles");
expect(themeDataIdx).toBe(0);
expect(baseIdx).toBe(1);
// Run the hook
const { result } = renderHook(() => useTheme());
// Wait for hydration AND the theme-data loading useEffect to complete.
// The theme-data loading useEffect runs after colorTheme state updates,
// so we need to wait for the link to actually move.
await waitFor(() => {
expect(result.current.colorTheme).toBe("ocean");
});
// Wait for the link to be moved to end of head (separate useEffect)
await waitFor(() => {
const updatedChildren = Array.from(document.head.children);
const newThemeDataIdx = updatedChildren.findIndex((el) => el.id === "theme-data");
const newBaseIdx = updatedChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIdx).toBeGreaterThan(newBaseIdx);
});
// Only one #theme-data link should exist
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
// Dark mode attribute should be set
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("ocean");
});
it("light mode still works correctly with pre-existing theme-data link", async () => {
// Regression test: ensure the link-reordering fix doesn't break light mode
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
localStorageMock[COLOR_THEME_STORAGE_KEY] = "factory";
// Mock fetch to resolve so isHydrating becomes false and useEffect runs
mockFetchGlobalSettings.mockResolvedValue({
themeMode: "light",
colorTheme: "factory",
});
// Clear any existing elements that might interfere
document.head.innerHTML = "";
// Set up base styles that would load after theme-data
const baseStyles = document.createElement("style");
baseStyles.id = "base-styles";
baseStyles.textContent = `
:root { --bg: #0d1117; }
[data-theme="light"] { --bg: #ffffff; }
`;
document.head.appendChild(baseStyles);
// Pre-existing theme-data link from pre-hydration (early in head)
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
document.head.insertBefore(existingLink, baseStyles);
const { result } = renderHook(() => useTheme());
// Wait for hydration AND the theme-data loading useEffect to complete.
// The theme-data loading useEffect runs after colorTheme state updates,
// so we need to wait for the link to actually move.
await waitFor(() => {
expect(result.current.colorTheme).toBe("factory");
});
// Wait for the link to be moved to end of head (separate useEffect)
await waitFor(() => {
const updatedChildren = Array.from(document.head.children);
const newThemeDataIdx = updatedChildren.findIndex((el) => el.id === "theme-data");
const newBaseIdx = updatedChildren.findIndex((el) => el.id === "base-styles");
expect(newThemeDataIdx).toBeGreaterThan(newBaseIdx);
});
// Verify light mode works
expect(result.current.themeMode).toBe("light");
expect(result.current.colorTheme).toBe("factory");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("factory");
// Only one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
it("only one theme-data link exists after multiple theme changes", () => {
// Ensure link reordering doesn't create duplicates
const { result } = renderHook(() => useTheme());
// Clear any existing elements
document.head.innerHTML = "";
// Pre-existing link
const existingLink = document.createElement("link");
existingLink.id = "theme-data";
existingLink.rel = "stylesheet";
existingLink.href = "/theme-data.css";
document.head.appendChild(existingLink);
// Multiple theme changes
act(() => result.current.setColorTheme("ocean"));
act(() => result.current.setColorTheme("forest"));
act(() => result.current.setColorTheme("nord"));
act(() => result.current.setColorTheme("default"));
act(() => result.current.setColorTheme("dracula"));
// Should still only have one #theme-data link
const links = document.querySelectorAll('link[id="theme-data"]');
expect(links.length).toBe(1);
});
});
});

View File

@@ -38,7 +38,7 @@ function getThemeDataUrl(): string {
return base.slice(0, -1) + `/${THEME_DATA_FILENAME}`;
} else {
// Filename path: replace last segment with "/theme-data.css"
return base.replace(/\/[^\/]+$/, `/${THEME_DATA_FILENAME}`);
return base.replace(/\/[^/]+$/, `/${THEME_DATA_FILENAME}`);
}
}
@@ -129,7 +129,14 @@ function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, syst
/**
* Load theme-data.css for non-default themes.
* Safely handles existing links by checking href and updating if stale.
* This ensures correct URL resolution when baseURI changes between renders.
* After href reconciliation, existing links are moved to the end of <head>
* to ensure color-theme CSS rules take precedence over base token
* redefinitions in subsequent stylesheets (CSS cascade correctness).
*
* This is critical for dark mode: if #theme-data is injected early by
* pre-hydration scripts but styles.css loads later and redefines base
* tokens, those redefinitions win the cascade unless theme-data is
* repositioned to come after them.
*/
function loadThemeDataStylesheet(): void {
if (!isBrowser) return;
@@ -142,7 +149,14 @@ function loadThemeDataStylesheet(): void {
if (existingLink.href !== expectedHref) {
existingLink.href = expectedHref;
}
// If href matches, link is already correct - nothing to do
// Move existing link to end of <head> for CSS cascade correctness.
// This ensures color-theme rules (which use [data-color-theme="..."] selectors)
// are evaluated AFTER any subsequent stylesheets that might redefine base tokens.
// Without this, dark color themes can appear broken because base token
// redefinitions win the cascade over color-theme rules.
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
document.head.appendChild(existingLink);
}
return;
}

View File

@@ -57,6 +57,12 @@
if (existingLink.href !== themeDataUrl) {
existingLink.href = themeDataUrl;
}
// Move existing link to end of <head> for CSS cascade correctness.
// This ensures color-theme rules take precedence over base token
// redefinitions in subsequent stylesheets.
if (existingLink.parentNode === document.head && document.head.lastChild !== existingLink) {
document.head.appendChild(existingLink);
}
} else {
// No existing link - create one
var link = document.createElement('link');