import type {} from '../../types/globals'; /* CSS reload/preload utilities for HMR */ import type { CSSUpdateResult } from '../../types/client'; import { CSS_ERROR_RESOLVE_DELAY_MS, CSS_MAX_CHECK_ATTEMPTS, CSS_MAX_PARSE_TIMEOUT_MS, CSS_SHEET_READY_TIMEOUT_MS, DOM_UPDATE_DELAY_MS, RAF_BATCH_COUNT } from './constants'; import { hmrState } from './hmrState'; export const getCSSBaseName = (href: string) => { const fileName = href.split('?')[0]?.split('/').pop() || ''; return fileName.split('.')[0] ?? ''; }; const baseNamesMatch = (baseA: string, baseB: string) => baseA === baseB || baseA.includes(baseB) || baseB.includes(baseA); const findMatchingLink = (baseNew: string) => { const links = document.head.querySelectorAll('link[rel="stylesheet"]'); for (const existing of links) { if (!(existing instanceof HTMLLinkElement)) continue; const existingHref = existing.getAttribute('href') || ''; const baseExisting = getCSSBaseName(existingHref); if (baseNamesMatch(baseExisting, baseNew)) { return existing; } } return null; }; const createTimestampedLink = (href: string) => { const newLinkElement = document.createElement('link'); newLinkElement.rel = 'stylesheet'; newLinkElement.media = 'print'; const newHref = `${href + (href.includes('?') ? '&' : '?')}t=${Date.now()}`; newLinkElement.href = newHref; return { newHref, newLinkElement }; }; const processNewLink = ( newLink: Element, linksToRemove: HTMLLinkElement[], linksToActivate: HTMLLinkElement[], linksToWaitFor: Promise[] ) => { const href = newLink.getAttribute('href'); if (!href) return; const baseNew = getCSSBaseName(href); const existingLink = findMatchingLink(baseNew); if (!existingLink) { const { newHref, newLinkElement } = createTimestampedLink(href); linksToActivate.push(newLinkElement); const loadPromise = createCSSLoadPromise(newLinkElement, newHref); document.head.appendChild(newLinkElement); linksToWaitFor.push(loadPromise); return; } const existingHrefAttr = existingLink.getAttribute('href'); const existingHref = existingHrefAttr ? existingHrefAttr.split('?')[0] : ''; const [newHrefBase] = href.split('?'); if (existingHref === newHrefBase) return; const { newHref, newLinkElement } = createTimestampedLink(href); linksToRemove.push(existingLink); linksToActivate.push(newLinkElement); const loadPromise = createCSSLoadPromise(newLinkElement, newHref); document.head.appendChild(newLinkElement); linksToWaitFor.push(loadPromise); }; export const processCSSLinks = (headHTML: string) => { const tempDiv = document.createElement('div'); tempDiv.innerHTML = headHTML; const newStylesheets = tempDiv.querySelectorAll('link[rel="stylesheet"]'); const existingStylesheets = Array.from( document.head.querySelectorAll( 'link[rel="stylesheet"]' ) ); const newHrefs = Array.from(newStylesheets).map((link) => { const href = link.getAttribute('href') || ''; return getCSSBaseName(href); }); const linksToRemove: HTMLLinkElement[] = []; const linksToWaitFor: Promise[] = []; const linksToActivate: HTMLLinkElement[] = []; newStylesheets.forEach((newLink) => { processNewLink(newLink, linksToRemove, linksToActivate, linksToWaitFor); }); existingStylesheets.forEach((existingLink) => { const existingHref = existingLink.getAttribute('href') || ''; const baseExisting = getCSSBaseName(existingHref); const stillExists = newHrefs.some((newBase) => baseNamesMatch(baseExisting, newBase) ); if (stillExists) return; const wasHandled = Array.from(newStylesheets).some((newLink) => { const newHref = newLink.getAttribute('href') || ''; const baseNewLocal = getCSSBaseName(newHref); return baseNamesMatch(baseExisting, baseNewLocal); }); if (!wasHandled) { linksToRemove.push(existingLink); } }); return { linksToActivate, linksToRemove, linksToWaitFor }; }; const findManifestHref = ( manifest: Record, baseName: string ) => { const manifestKey = `${baseName .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join('')}CSS`; if (manifest[manifestKey]) { return manifest[manifestKey]; } for (const [key, value] of Object.entries(manifest)) { if (key.endsWith('CSS') && value.includes(baseName)) { return value; } } return null; }; const updateStylesheetLink = ( link: Element, manifest: Record ) => { if (!(link instanceof HTMLLinkElement)) return; const href = link.getAttribute('href'); if (!href || href.includes('htmx.min.js')) return; let newHref: string | null = null; if (manifest) { const baseName = getCSSBaseName(href); newHref = findManifestHref(manifest, baseName); } if (newHref && newHref !== href) { link.href = `${newHref}?t=${Date.now()}`; } else { const url = new URL(href, window.location.origin); url.searchParams.set('t', Date.now().toString()); link.href = url.toString(); } }; export const reloadCSSStylesheets = (manifest: Record) => { const stylesheets = document.querySelectorAll('link[rel="stylesheet"]'); stylesheets.forEach((link) => { updateStylesheetLink(link, manifest); }); }; const createCSSLoadPromise = ( linkElement: HTMLLinkElement, newHref: string ) => { const { promise, resolve } = Promise.withResolvers(); let resolved = false; const doResolve = function () { if (resolved) return; resolved = true; resolve(); }; const verifyCSSOM = function () { try { const sheets = Array.from(document.styleSheets); return sheets.some( (sheet) => sheet.href && sheet.href.includes(newHref.split('?')[0] ?? '') ); } catch { return false; } }; linkElement.onload = function () { let checkCount = 0; const checkCSSOM = function () { checkCount++; if (verifyCSSOM() || checkCount > CSS_MAX_CHECK_ATTEMPTS) { doResolve(); } else { requestAnimationFrame(checkCSSOM); } }; requestAnimationFrame(checkCSSOM); }; linkElement.onerror = function () { setTimeout(() => { doResolve(); }, CSS_ERROR_RESOLVE_DELAY_MS); }; setTimeout(() => { if (linkElement.sheet && !resolved) { doResolve(); } }, CSS_SHEET_READY_TIMEOUT_MS); setTimeout(() => { if (!resolved) { doResolve(); } }, CSS_MAX_PARSE_TIMEOUT_MS); return promise; }; const removeLinks = (linksToRemove: HTMLLinkElement[]) => { linksToRemove.forEach((link) => { if (link.parentNode) { link.remove(); } }); }; const activateLinks = (linksToActivate: HTMLLinkElement[]) => { linksToActivate.forEach((link) => { link.media = 'all'; }); }; const chainRAF = (depth: number, callback: () => void) => { if (depth <= 0) { callback(); return; } requestAnimationFrame(() => { chainRAF(depth - 1, callback); }); }; /* Coordinate CSS load with body update: waits for CSS, patches body, activates new CSS, removes old CSS. Handles first-update delay. */ export const waitForCSSAndUpdate = ( cssResult: CSSUpdateResult, updateBody: () => void ) => { const { linksToActivate, linksToRemove, linksToWaitFor } = cssResult; if (linksToWaitFor.length > 0) { void Promise.all(linksToWaitFor).then(() => { setTimeout(() => { chainRAF(RAF_BATCH_COUNT, () => { updateBody(); activateLinks(linksToActivate); requestAnimationFrame(() => { removeLinks(linksToRemove); if (hmrState.isFirstHMRUpdate) { hmrState.isFirstHMRUpdate = false; } }); }); }, DOM_UPDATE_DELAY_MS); return undefined; }); return; } const doUpdate = function () { chainRAF(RAF_BATCH_COUNT, () => { updateBody(); requestAnimationFrame(() => { removeLinks(linksToRemove); }); }); }; if (hmrState.isFirstHMRUpdate) { hmrState.isFirstHMRUpdate = false; setTimeout(doUpdate, DOM_UPDATE_DELAY_MS); } else { doUpdate(); } };