fix: improve critical css generation (#770)

This commit is contained in:
Valerio Ageno
2025-05-11 19:11:30 +02:00
committed by GitHub
parent eef2cd26e0
commit 3027b828a8
11 changed files with 137 additions and 82 deletions
@@ -207,6 +207,7 @@ export async function routeGenerator(
rustHandlersNodes.includes(node.path || '') rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true' ? 'hasHandler: true'
: '', : '',
`filePath: '${node.path || '/'}'`,
] ]
.filter(Boolean) .filter(Boolean)
.join(',')} .join(',')}
+89 -71
View File
@@ -46,6 +46,94 @@ const isCssUrlWithoutSideEffects = (url: string): boolean => {
return false return false
} }
const normalizePath = (modulePath: string): string => {
return modulePath.startsWith('node_modules')
? path.join(process.cwd(), modulePath)
: modulePath
}
export const getStylesForModule = async (
viteDevServer: ViteDevServer,
moduleUrl: string,
/**
* All the CSS modules are preloaded and saved in this manifest
*/
cssModulesManifest: Record<string, string>,
): Promise<string | undefined> => {
const styles: Record<string, string> = {}
const deps: Set<ModuleNode> = new Set()
const moduleFilePath = normalizePath(moduleUrl)
try {
let node: ModuleNode | undefined =
await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
// If the module is only present in the client module graph, the module
// won't have been found on the first request to the server. If so, we
// request the module so it's in the module graph, then try again.
if (!node) {
try {
await viteDevServer.transformRequest(moduleFilePath)
} catch (err) {
console.error(err)
}
node = await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
}
if (!node) {
console.error(`Could not resolve module for file: ${moduleFilePath}`)
return
}
await findNodeDependencies(viteDevServer, node, deps)
} catch (error) {
console.error(error)
}
for (const dep of deps) {
if (
dep.file &&
isCssFile(dep.file) &&
!isCssUrlWithoutSideEffects(dep.url) // Ignore styles that resolved as URLs, inline or raw. These shouldn't get injected.
) {
try {
const css = isCssModulesFile(dep.file)
? cssModulesManifest[dep.file]
: ((
await viteDevServer.ssrLoadModule(
// We need the ?inline query in Vite v6 when loading CSS in SSR
// since it does not expose the default export for CSS in a
// server environment.
injectQuery(normalizePath(dep.file), 'inline'),
)
).default as string)
if (css === undefined) {
throw new Error()
}
styles[dep.url] = css
} catch {
// this can happen with dynamically imported modules
console.warn(`Could not load ${dep.file}`)
}
}
}
return (
Object.entries(styles)
.map(([fileName, css]) => [
`\n/* ${fileName
// Escape comment syntax in file paths
.replace(/\/\*/g, '/\\*')
.replace(/\*\//g, '*\\/')} */`,
css,
])
.flat()
.join('\n') || undefined
)
}
/** /**
* This function transform the componentId into a file path. * This function transform the componentId into a file path.
* File extension is not required for the vite.moduleGraph URL search. * File extension is not required for the vite.moduleGraph URL search.
@@ -80,77 +168,7 @@ export const getStylesForComponentId = async (
const fileUrl = path.join(process.cwd(), relativeFilePath) const fileUrl = path.join(process.cwd(), relativeFilePath)
const styles: Record<string, string> = {} return await getStylesForModule(viteDevServer, fileUrl, cssModulesManifest)
const deps: Set<ModuleNode> = new Set()
try {
let node: ModuleNode | undefined =
await viteDevServer.moduleGraph.getModuleByUrl(fileUrl)
// If the module is only present in the client module graph, the module
// won't have been found on the first request to the server. If so, we
// request the module so it's in the module graph, then try again.
if (!node) {
try {
await viteDevServer.transformRequest(fileUrl)
} catch (err) {
console.error(err)
}
node = await viteDevServer.moduleGraph.getModuleByUrl(fileUrl)
}
if (!node) {
console.error(`Could not resolve module for file: ${fileUrl}`)
return
}
await findNodeDependencies(viteDevServer, node, deps)
} catch (error) {
console.error(error)
}
for (const dep of deps) {
if (
dep.file &&
isCssFile(dep.file) &&
!isCssUrlWithoutSideEffects(dep.url) // Ignore styles that resolved as URLs, inline or raw. These shouldn't get injected.
) {
try {
const css = isCssModulesFile(dep.file)
? cssModulesManifest[dep.file]
: ((
await viteDevServer.ssrLoadModule(
// We need the ?inline query in Vite v6 when loading CSS in SSR
// since it does not expose the default export for CSS in a
// server environment.
injectQuery(dep.url, 'inline'),
)
).default as string)
if (css === undefined) {
throw new Error()
}
styles[dep.url] = css
} catch {
// this can happen with dynamically imported modules
console.warn(`Could not load ${dep.file}`)
}
}
}
return (
Object.entries(styles)
.map(([fileName, css]) => [
`\n/* ${fileName
// Escape comment syntax in file paths
.replace(/\/\*/g, '/\\*')
.replace(/\*\//g, '*\\/')} */`,
css,
])
.flat()
.join('\n') || undefined
)
} }
/** /**
@@ -21,12 +21,14 @@ const Postscatchall = createRoute({ component: PostscatchallImport })
const IndexRoute = Index.update({ const IndexRoute = Index.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/',
}) })
const PostscatchallRoute = Postscatchall.update({ const PostscatchallRoute = Postscatchall.update({
path: '/posts/[...catch_all]', path: '/posts/[...catch_all]',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
hasHandler: true, hasHandler: true,
filePath: '/posts/[...catch_all]',
}) })
// Create and export the route tree // Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({ const AboutRoute = About.update({
path: '/about', path: '/about',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/about',
}) })
const IndexRoute = Index.update({ const IndexRoute = Index.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/',
}) })
// Create and export the route tree // Create and export the route tree
@@ -36,31 +36,37 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const PostslayoutRoute = Postslayout.update({ const PostslayoutRoute = Postslayout.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/posts/__layout',
}) })
const AboutRoute = About.update({ const AboutRoute = About.update({
path: '/about', path: '/about',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/about',
}) })
const IndexRoute = Index.update({ const IndexRoute = Index.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/',
}) })
const PostspostRoute = Postspost.update({ const PostspostRoute = Postspost.update({
path: '/posts/[post]', path: '/posts/[post]',
getParentRoute: () => PostslayoutRoute, getParentRoute: () => PostslayoutRoute,
filePath: '/posts/[post]',
}) })
const PostsIndexRoute = PostsIndex.update({ const PostsIndexRoute = PostsIndex.update({
path: '/posts', path: '/posts',
getParentRoute: () => PostslayoutRoute, getParentRoute: () => PostslayoutRoute,
filePath: '/posts/',
}) })
const PostsMyPostRoute = PostsMyPost.update({ const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post', path: '/posts/my-post',
getParentRoute: () => PostslayoutRoute, getParentRoute: () => PostslayoutRoute,
filePath: '/posts/my-post',
}) })
// Create and export the route tree // Create and export the route tree
@@ -25,16 +25,19 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const AboutRoute = About.update({ const AboutRoute = About.update({
path: '/about', path: '/about',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/about',
}) })
const IndexRoute = Index.update({ const IndexRoute = Index.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/',
}) })
const PostsMyPostRoute = PostsMyPost.update({ const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post', path: '/posts/my-post',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/posts/my-post',
}) })
// Create and export the route tree // Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({ const AboutRoute = About.update({
path: '/about', path: '/about',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/about',
}) })
const IndexRoute = Index.update({ const IndexRoute = Index.update({
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
filePath: '/',
}) })
// Create and export the route tree // Create and export the route tree
@@ -6,7 +6,7 @@ const VITE_PROXY_PATH = '/vite-server'
const CRITICAL_CSS_PATH = VITE_PROXY_PATH + '/tuono_internal__critical_css' const CRITICAL_CSS_PATH = VITE_PROXY_PATH + '/tuono_internal__critical_css'
interface CriticalCssProps { interface CriticalCssProps {
routeId?: string routeFilePath?: string
mode?: Mode mode?: Mode
} }
@@ -16,16 +16,16 @@ interface CriticalCssProps {
* since vite does not support CSS injection without JS waterfall * since vite does not support CSS injection without JS waterfall
*/ */
export function CriticalCss({ export function CriticalCss({
routeId, routeFilePath,
mode, mode,
}: CriticalCssProps): JSX.Element | null { }: CriticalCssProps): JSX.Element | null {
if (!routeId || mode !== 'Dev') { if (!routeFilePath || mode !== 'Dev') {
return null return null
} }
return ( return (
<link <link
href={`${CRITICAL_CSS_PATH}?componentId=${routeId}`} href={`${CRITICAL_CSS_PATH}?componentId=${routeFilePath}`}
precedence="high" precedence="high"
rel="stylesheet" rel="stylesheet"
/> />
@@ -18,7 +18,7 @@ export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
if (custom404Route) { if (custom404Route) {
return ( return (
<> <>
<CriticalCss routeId={custom404Route.id} mode={mode} /> <CriticalCss routeFilePath={custom404Route.filePath} mode={mode} />
<RouteMatch route={custom404Route} mode={mode} serverInitialData={{}} /> <RouteMatch route={custom404Route} mode={mode} serverInitialData={{}} />
</> </>
) )
@@ -30,7 +30,7 @@ export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
return ( return (
<RootLayout data={null} isLoading={false}> <RootLayout data={null} isLoading={false}>
<CriticalCss routeId="__root__" mode={mode} /> <CriticalCss routeFilePath="__root__" mode={mode} />
<NotFoundDefaultContent /> <NotFoundDefaultContent />
</RootLayout> </RootLayout>
) )
@@ -42,7 +42,7 @@ export const RouteMatch = ({
mode={mode} mode={mode}
> >
<Suspense> <Suspense>
<CriticalCss routeId={route.id} mode={mode} /> <CriticalCss routeFilePath={route.filePath} mode={mode} />
<route.component data={routeData} isLoading={isTransitioning} /> <route.component data={routeData} isLoading={isTransitioning} />
</Suspense> </Suspense>
</TraverseRootComponents> </TraverseRootComponents>
@@ -77,9 +77,13 @@ const TraverseRootComponents = memo(
const route = routes[index] as Route const route = routes[index] as Route
const Parent = route.component const Parent = route.component
// Fallback to the route id if the filePath is not defined
// as is the case for the root route
const routeFilePath = route.filePath || route.id
return ( return (
<Parent data={data} isLoading={isLoading}> <Parent data={data} isLoading={isLoading}>
<CriticalCss routeId={route.id} mode={mode} /> <CriticalCss routeFilePath={routeFilePath} mode={mode} />
<TraverseRootComponents <TraverseRootComponents
routes={routes} routes={routes}
data={data} data={data}
+20 -3
View File
@@ -6,6 +6,7 @@ interface RouteOptions {
isRoot?: boolean isRoot?: boolean
getParentRoute?: () => Route getParentRoute?: () => Route
path?: string path?: string
filePath?: string
component: RouteComponent component: RouteComponent
hasHandler?: boolean hasHandler?: boolean
} }
@@ -19,11 +20,28 @@ export const ROOT_ROUTE_ID = '__root__'
export class Route { export class Route {
options: RouteOptions options: RouteOptions
/**
* The route id is used to identify the route in the router
* and is used to match the route with the URL.
*
* For now is the `path`
*/
id?: string id?: string
isRoot: boolean isRoot: boolean
/**
* Used for identify the route by matching the URL
*/
path?: string path?: string
fullPath!: string fullPath!: string
/**
* Utility to identify the route in the file system
* Used i.e. for finding the criticalCss to load
*
* The path does not include the file extension
*/
filePath?: string
children?: Array<Route> children?: Array<Route>
parentRoute?: Route parentRoute?: Route
originalIndex?: number originalIndex?: number
@@ -70,11 +88,10 @@ export class Route {
id = joinPaths(['/', id]) id = joinPaths(['/', id])
} }
const fullPath = id === ROOT_ROUTE_ID ? '/' : path this.filePath = this.options.filePath
this.path = path this.path = path
this.id = id this.id = id
this.fullPath = fullPath || '' this.fullPath = path || ''
} }
addChildren(routes: Array<Route>): this { addChildren(routes: Array<Route>): this {