Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 95 additions & 41 deletions src/server/renderer/extract.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import path from "path"
import fs from "fs"

const readFile = fs.promises.readFile

export function cachePreloadJSLinks(key, data) {
if (!process.preloadJSLinkCache) {
process.preloadJSLinkCache = {}
}

// Skip if already cached
if (process.preloadJSLinkCache[key]) {
return
}

let preloadJSLinks = []
if (Array.isArray(data)) {
try {
Expand All @@ -21,50 +29,84 @@ export function cachePreloadJSLinks(key, data) {
* Stores css chunks styles into cache in string format
* @param {string} key - router path
* @param {object} data - css elements array extracted through loadable chunk extracter
* @param {boolean} skipIfCached - if true, skip processing if cache already exists
* @returns {Promise<string>} - cached CSS content
*/
export function cacheCSS(key, data) {
export async function cacheCSS(key, data, skipIfCached = false) {
if (!process.cssCache) {
process.cssCache = {}
}

// If cache exists and skipIfCached is true, just return cached CSS
if (skipIfCached && process.cssCache[key]) {
return process.cssCache[key].pageCss
}

let pageCss = ""
let listOfCachedAssets = {}
const existingCache = process.cssCache[key]
const existingCachedAssets = existingCache?.listOfCachedAssets || {}

if (Array.isArray(data)) {
try {
if (process.env.NODE_ENV === "production") {
data.map((assetChunk) => {
// Read all CSS files in parallel for better performance
const readPromises = []
const assetMap = new Map() // Track which assets we need to read

for (const assetChunk of data) {
const assetPathArr = assetChunk.key.split("/")
const assetName = assetPathArr[assetPathArr.length - 1]
const ext = path.extname(assetName)

if (ext === ".css") {
// if css file has not already been cached, add the content of this CSS file in pageCSS
if (
!listOfCachedAssets[assetName] &&
!process.cssCache?.[key]?.listOfCachedAssets?.[assetName]
) {
pageCss += fs.readFileSync(
path.resolve(
process.env.src_path,
`${process.env.BUILD_OUTPUT_PATH}/public`,
assetName
)
if (!listOfCachedAssets[assetName] && !existingCachedAssets[assetName]) {
const filePath = path.resolve(
process.env.src_path,
`${process.env.BUILD_OUTPUT_PATH}/public`,
assetName
)
assetMap.set(assetName, filePath)
listOfCachedAssets[assetName] = true
}
}
})
}

// Read all files in parallel (non-blocking)
if (assetMap.size > 0) {
const readResults = await Promise.allSettled(
Array.from(assetMap.entries()).map(async ([assetName, filePath]) => {
try {
const cssContent = await readFile(filePath, "utf8")
return { assetName, cssContent }
} catch (fileError) {
logger.error(`Error reading CSS file ${assetName}: ${fileError.message}`)
return { assetName, cssContent: "" }
}
})
)

// Combine all CSS content
for (const result of readResults) {
if (result.status === "fulfilled" && result.value.cssContent) {
pageCss += result.value.cssContent
}
}
}
}
} catch (error) {
logger.error("Error in caching CSS:" + error)
}
}

// if css cache exists for a route and there are some uncached css, add that css to the cache
// this will run on subsequent hits and will add css of uncached widgets to the cache
if (process.cssCache[key]) {
if (existingCache) {
if (pageCss !== "") {
let existingListOfCachedAssets = process.cssCache[key].listOfCachedAssets
const newPageCSS = process.cssCache[key].pageCss + pageCss
let newListOfCachedAssets = { ...existingListOfCachedAssets, ...listOfCachedAssets }
// Use array join instead of string concatenation for better memory efficiency
const newPageCSS = existingCache.pageCss + pageCss
const newListOfCachedAssets = { ...existingCachedAssets, ...listOfCachedAssets }
process.cssCache[key] = { pageCss: newPageCSS, listOfCachedAssets: newListOfCachedAssets }
}
} else {
Expand Down Expand Up @@ -101,6 +143,9 @@ export default function extract(res, route) {
const cachedCss = fetchCachedCSS(requestPath)
const cachedPreloadJSLinks = fetchPreloadJSLinkCache(requestPath)

// Mark that we've checked the cache (regardless of hit/miss)
res.locals.assetsExtracted = true

if (cachedCss || cachedPreloadJSLinks) {
res.locals.pageCss = cachedCss
res.locals.preloadJSLinks = cachedPreloadJSLinks
Expand All @@ -116,38 +161,47 @@ export default function extract(res, route) {
}
}

export const cacheAndFetchAssets = ({ webExtractor, res, isBot }) => {
export const cacheAndFetchAssets = async ({ webExtractor, res, isBot }) => {
// For bot first fold css and js would become complete page css and js
let firstFoldCss = ""
let firstFoldJS = ""
const isProd = process.env.NODE_ENV === "production"

const { routePath, preloadJSLinks } = res.locals

const linkElements = webExtractor.getLinkElements()
const { routePath, preloadJSLinks, pageCss } = res.locals

// We want to cache/or check for update css on every call
// We want to extract script tags for every call that will get added to body.
// Their corresponding preloaded link script tags are already present in head.
if (routePath) {
if (isProd) {
firstFoldCss = cacheCSS(routePath, linkElements)
if (firstFoldCss?.length) firstFoldCss = `<style>${firstFoldCss}</style>`
} else {
cacheCSS(routePath, linkElements)
firstFoldCss = webExtractor.getStyleTags()
}
// firstFoldJS = webExtractor.getScriptTags({ nonce: cspNonce })
// If CSS is already cached and set in res.locals, use it directly to avoid unnecessary processing
if (pageCss && isProd) {
firstFoldCss = pageCss
// Still need to get JS tags even if CSS is cached
firstFoldJS = !isBot ? webExtractor.getScriptTags() : ""
}
} else {
const linkElements = webExtractor.getLinkElements()

// We want to cache/or check for update css on every call
// We want to extract script tags for every call that will get added to body.
// Their corresponding preloaded link script tags are already present in head.
if (routePath) {
if (isProd) {
// Skip file reads if cache already exists (pageCss would be set if cache hit)
const skipIfCached = !!pageCss
firstFoldCss = await cacheCSS(routePath, linkElements, skipIfCached)
if (firstFoldCss?.length) firstFoldCss = `<style>${firstFoldCss}</style>`
} else {
await cacheCSS(routePath, linkElements)
firstFoldCss = webExtractor.getStyleTags()
}
// firstFoldJS = webExtractor.getScriptTags({ nonce: cspNonce })
firstFoldJS = !isBot ? webExtractor.getScriptTags() : ""
}

// This block will run for the first time and cache preloaded JS Links for second render
// firstFoldJS ->scripts gets inject in body
// firstFoldCss -> Inline css gets injected in body only for the first render
if (!isProd || isBot || (routePath && !preloadJSLinks)) {
// For production, we inject link tags with preload/prefetch using getLinkElements and inlining them via file reads
// For local, given we have assets in memory we dont read from file rather directly inject via link elements returned without preload/prefetch
!isBot && cachePreloadJSLinks(routePath, linkElements)
// This block will run for the first time and cache preloaded JS Links for second render
// firstFoldJS ->scripts gets inject in body
// firstFoldCss -> Inline css gets injected in body only for the first render
if (!isProd || isBot || (routePath && !preloadJSLinks)) {
// For production, we inject link tags with preload/prefetch using getLinkElements and inlining them via file reads
// For local, given we have assets in memory we dont read from file rather directly inject via link elements returned without preload/prefetch
!isBot && cachePreloadJSLinks(routePath, linkElements)
}
}

return { firstFoldCss, firstFoldJS }
Expand Down
11 changes: 7 additions & 4 deletions src/server/renderer/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ const getMatchRoutes = (routes, req, res, store, context, fetcherData, basePath
)

if (match) {
if (!res.locals.pageCss && !res.locals.preloadJSLinks && !res.locals.routePath) {
if (!res.locals.assetsExtracted && !res.locals.routePath) {
res.locals.routePath = path
extractAssets(res, route)
}
if (!res.locals.pageCss && !res.locals.preloadJSLinks) {
// Only run expensive renderToString if cache was MISSED
// If assetsExtracted is true AND (pageCss OR preloadJSLinks exists), cache was hit - skip renderToString
const cacheHit = res.locals.assetsExtracted && (res.locals.pageCss || res.locals.preloadJSLinks)
if (!cacheHit) {
//moving routing logic outside of the App and using ServerRoutes for creating routes on server instead
renderToString(
<ChunkExtractorManager extractor={webExtractor}>
Expand Down Expand Up @@ -194,8 +197,8 @@ const renderMarkUp = async (
res.setHeader("content-type", "text/html")
pipe(res)
},
onAllReady() {
const { firstFoldCss, firstFoldJS } = cacheAndFetchAssets({ webExtractor, res, isBot })
async onAllReady() {
const { firstFoldCss, firstFoldJS } = await cacheAndFetchAssets({ webExtractor, res, isBot })
res.write(firstFoldCss)
res.write(firstFoldJS)
res.end()
Expand Down