From 44f6acec774eafc6cba21c84dc4d8fbc002c1128 Mon Sep 17 00:00:00 2001 From: agentic-sanyam <264568151+agentic-sanyam@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:00:37 +0530 Subject: [PATCH] Perf: Memoize Render plugin's compile functions (#3915) --- src/Plugins/RenderPlugin.js | 41 +++++++++++++++++++++-- test/TemplateRenderPluginTest.js | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/src/Plugins/RenderPlugin.js b/src/Plugins/RenderPlugin.js index 0da8c6a75..ad1bc4c71 100644 --- a/src/Plugins/RenderPlugin.js +++ b/src/Plugins/RenderPlugin.js @@ -15,6 +15,9 @@ import Liquid from "../Adapters/Engines/Liquid.js"; class EleventyNunjucksError extends EleventyBaseError {} +const compileCache = new WeakMap(); +const compileFileCache = new WeakMap(); + /** @this {object} */ async function compile(content, templateLang, options = {}) { let { templateConfig, extensionMap } = options; @@ -31,6 +34,15 @@ async function compile(content, templateLang, options = {}) { templateLang = this.page.templateSyntax; } + if (!compileCache.has(templateConfig)) { + compileCache.set(templateConfig, new Map()); + } + let projectCache = compileCache.get(templateConfig); + let cacheKey = `${content}###${templateLang || ""}`; + if (projectCache.has(cacheKey)) { + return projectCache.get(cacheKey); + } + if (!extensionMap) { if (strictMode) { throw new Error("Internal error: missing `extensionMap` in RenderPlugin->compile."); @@ -62,7 +74,9 @@ async function compile(content, templateLang, options = {}) { ); } - return tr.getCompiledTemplate(content); + let fn = await tr.getCompiledTemplate(content); + projectCache.set(cacheKey, fn); + return fn; } // No templateLang default, it should infer from the inputPath. @@ -94,6 +108,12 @@ async function compileFile(inputPath, options = {}, templateLang) { ); } + if (!compileFileCache.has(templateConfig)) { + compileFileCache.set(templateConfig, new Map()); + } + let projectCache = compileFileCache.get(templateConfig); + let cacheKey = `${normalizedPath}###${templateLang || ""}`; + if (!extensionMap) { if (strictMode) { throw new Error("Internal error: missing `extensionMap` in RenderPlugin->compileFile."); @@ -112,12 +132,27 @@ async function compileFile(inputPath, options = {}, templateLang) { } if (!tr.engine.needsToReadFileContents()) { - return tr.getCompiledTemplate(null); + let fn = await tr.getCompiledTemplate(null); + // Note: we don't cache these yet, but we could. + return fn; } // TODO we could make this work with full templates (with front matter?) let content = readFileSync(inputPath, "utf8"); - return tr.getCompiledTemplate(content); + + if (projectCache.has(cacheKey)) { + let cached = projectCache.get(cacheKey); + if (cached.content === content) { + return cached.fn; + } + } + + let fn = await tr.getCompiledTemplate(content); + projectCache.set(cacheKey, { + content, + fn, + }); + return fn; } /** @this {object} */ diff --git a/test/TemplateRenderPluginTest.js b/test/TemplateRenderPluginTest.js index 2a073adcb..1d269e6d0 100644 --- a/test/TemplateRenderPluginTest.js +++ b/test/TemplateRenderPluginTest.js @@ -1,4 +1,5 @@ import test from "ava"; +import fs from "node:fs"; import { default as RenderPlugin, @@ -7,9 +8,12 @@ import { RenderManager, } from "../src/Plugins/RenderPlugin.js"; import Eleventy from "../src/Eleventy.js"; +import TemplateConfig from "../src/TemplateConfig.js"; +import ProjectDirectories from "../src/Util/ProjectDirectories.js"; import { normalizeNewLines } from "./Util/normalizeNewLines.js"; + async function getTestOutput(input, configCallback = function () {}) { let elev = new Eleventy(input, "./_site/", { config: function (eleventyConfig) { @@ -321,3 +325,55 @@ test("#3368 #3810 config init bug with RenderManager", async (t) => { let results = await elev.toJSON(); t.is(results[0].content, `

Sign up for our newsletter!

`); }); + +async function getSharedConfig() { + let templateConfig = new TemplateConfig(null, false); + templateConfig.setDirectories(new ProjectDirectories()); + await templateConfig.init(); + return templateConfig; +} + +test("Verify compileFile returns the same function with memoization (shared config)", async (t) => { + const filePath = "./test/stubs-render-plugin/11tyjs-file.njk"; + let templateConfig = await getSharedConfig(); + let options = { templateConfig }; + + let fn1 = await RenderPluginFile(filePath, options); + let fn2 = await RenderPluginFile(filePath, options); + + t.is(fn1, fn2); +}); + +test("Verify compileFile returns different functions for different content (shared config)", async (t) => { + const filePath = "./test/stubs-render-plugin/temp-file.njk"; + fs.writeFileSync(filePath, "content 1"); + + let templateConfig = await getSharedConfig(); + let options = { templateConfig }; + + let fn1 = await RenderPluginFile(filePath, options); + + fs.writeFileSync(filePath, "content 2"); + let fn2 = await RenderPluginFile(filePath, options); + + t.not(fn1, fn2); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +}); + +test("Verify compile (string) returns the same function with memoization (shared config)", async (t) => { + let templateConfig = await getSharedConfig(); + let options = { templateConfig }; + const content = "Hello {{ name }}"; + + // Need to mock this.page.templateSyntax if templateLang is not passed + const context = { page: { templateSyntax: "njk" } }; + + let fn1 = await RenderPluginString.call(context, content, "njk", options); + let fn2 = await RenderPluginString.call(context, content, "njk", options); + + t.is(fn1, fn2); +}); +