Skip to content

refactor: add cache for template literal parsing #390

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 17, 2025

Conversation

nwalters512
Copy link
Contributor

@nwalters512 nwalters512 commented Jul 16, 2025

Adding per-node caching of parse results gives a 94% performance improvement in my project. Before, the rules collectively took ~21s. Now, they take just ~1.2s.

Before:

TIMING=all yarn eslint --stats "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}" | grep "@html-eslint/"
@html-eslint/no-positive-tabindex                                         |   723.208 |     3.0%
@html-eslint/require-lang                                                 |   721.106 |     3.0%
@html-eslint/no-empty-headings                                            |   718.906 |     3.0%
@html-eslint/no-extra-spacing-attrs                                       |   714.442 |     3.0%
@html-eslint/quotes                                                       |   714.293 |     3.0%
@html-eslint/no-script-style-type                                         |   714.260 |     3.0%
@html-eslint/lowercase                                                    |   713.597 |     3.0%
@html-eslint/no-duplicate-class                                           |   712.460 |     3.0%
@html-eslint/no-restricted-attrs                                          |   712.457 |     3.0%
@html-eslint/max-element-depth                                            |   712.232 |     3.0%
@html-eslint/no-accesskey-attrs                                           |   711.985 |     3.0%
@html-eslint/no-multiple-empty-lines                                      |   710.888 |     3.0%
@html-eslint/no-extra-spacing-text                                        |   709.951 |     3.0%
@html-eslint/no-invalid-role                                              |   709.517 |     3.0%
@html-eslint/no-duplicate-id                                              |   708.535 |     3.0%
@html-eslint/no-duplicate-attrs                                           |   707.221 |     2.9%
@html-eslint/no-aria-hidden-on-focusable                                  |   707.086 |     2.9%
@html-eslint/prefer-https                                                 |   706.865 |     2.9%
@html-eslint/require-frame-title                                          |   706.811 |     2.9%
@html-eslint/no-invalid-entity                                            |   706.053 |     2.9%
@html-eslint/no-nested-interactive                                        |   705.981 |     2.9%
@html-eslint/no-obsolete-tags                                             |   705.818 |     2.9%
@html-eslint/require-closing-tags                                         |   705.428 |     2.9%
@html-eslint/no-duplicate-in-head                                         |   704.789 |     2.9%
@html-eslint/require-attrs                                                |   704.250 |     2.9%
@html-eslint/no-abstract-roles                                            |   703.733 |     2.9%
@html-eslint/require-img-alt                                              |   703.546 |     2.9%
@html-eslint/no-restricted-attr-values                                    |   703.156 |     2.9%
@html-eslint/no-target-blank                                              |   701.685 |     2.9%
@html-eslint/no-aria-hidden-body                                          |   699.780 |     2.9%
@html-eslint/require-open-graph-protocol                                  |     0.863 |     0.0%
@html-eslint/require-doctype                                              |     0.215 |     0.0%
@html-eslint/no-multiple-h1                                               |     0.214 |     0.0%
@html-eslint/no-skip-heading-levels                                       |     0.194 |     0.0%
@html-eslint/require-meta-description                                     |     0.174 |     0.0%
@html-eslint/require-meta-charset                                         |     0.163 |     0.0%
@html-eslint/require-li-container                                         |     0.154 |     0.0%
@html-eslint/require-title                                                |     0.151 |     0.0%
@html-eslint/require-meta-viewport                                        |     0.146 |     0.0%
@html-eslint/no-non-scalable-viewport                                     |     0.142 |     0.0%

After:

TIMING=all yarn eslint --stats "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}" | grep "@html-eslint/"
@html-eslint/require-lang                                                 |   884.916 |    21.8%
@html-eslint/lowercase                                                    |    21.117 |     0.5%
@html-eslint/no-extra-spacing-text                                        |    15.848 |     0.4%
@html-eslint/no-duplicate-attrs                                           |    15.593 |     0.4%
@html-eslint/require-closing-tags                                         |    15.490 |     0.4%
@html-eslint/no-restricted-attr-values                                    |    14.099 |     0.3%
@html-eslint/no-extra-spacing-attrs                                       |    14.003 |     0.3%
@html-eslint/quotes                                                       |    13.205 |     0.3%
@html-eslint/no-duplicate-class                                           |    13.096 |     0.3%
@html-eslint/no-duplicate-id                                              |    12.442 |     0.3%
@html-eslint/no-duplicate-in-head                                         |    12.129 |     0.3%
@html-eslint/no-invalid-entity                                            |    11.666 |     0.3%
@html-eslint/no-nested-interactive                                        |    11.341 |     0.3%
@html-eslint/max-element-depth                                            |    11.340 |     0.3%
@html-eslint/no-empty-headings                                            |    10.995 |     0.3%
@html-eslint/no-positive-tabindex                                         |    10.920 |     0.3%
@html-eslint/prefer-https                                                 |    10.885 |     0.3%
@html-eslint/require-attrs                                                |    10.769 |     0.3%
@html-eslint/no-restricted-attrs                                          |    10.661 |     0.3%
@html-eslint/require-img-alt                                              |    10.593 |     0.3%
@html-eslint/no-accesskey-attrs                                           |    10.588 |     0.3%
@html-eslint/no-invalid-role                                              |    10.546 |     0.3%
@html-eslint/no-obsolete-tags                                             |    10.417 |     0.3%
@html-eslint/no-abstract-roles                                            |    10.298 |     0.3%
@html-eslint/no-aria-hidden-on-focusable                                  |    10.287 |     0.3%
@html-eslint/no-script-style-type                                         |    10.271 |     0.3%
@html-eslint/no-target-blank                                              |    10.183 |     0.3%
@html-eslint/require-frame-title                                          |    10.109 |     0.2%
@html-eslint/no-aria-hidden-body                                          |     9.953 |     0.2%
@html-eslint/no-multiple-empty-lines                                      |     8.756 |     0.2%
@html-eslint/require-open-graph-protocol                                  |     0.821 |     0.0%
@html-eslint/no-multiple-h1                                               |     0.219 |     0.0%
@html-eslint/no-skip-heading-levels                                       |     0.215 |     0.0%
@html-eslint/require-doctype                                              |     0.194 |     0.0%
@html-eslint/require-title                                                |     0.177 |     0.0%
@html-eslint/require-li-container                                         |     0.173 |     0.0%
@html-eslint/require-meta-description                                     |     0.172 |     0.0%
@html-eslint/require-meta-viewport                                        |     0.162 |     0.0%
@html-eslint/no-non-scalable-viewport                                     |     0.156 |     0.0%
@html-eslint/require-meta-charset                                         |     0.138 |     0.0%

This code was developed with the help of Claude Sonnet 4.

@@ -20,7 +20,8 @@
* @property {Record<string, number>} [Option2.tagChildrenIndent]
*/

const { parse } = require("@html-eslint/template-parser");
const { getCachedParseResult } = require("../utils/template-cache");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative implementation would be to make caching a part of the parse function itself so that this is completely transparent to rules. I opted to go this route to avoid making changes that might be undesirable to anyone who is using the @html-eslint/template-parser package in isolation.

@nwalters512 nwalters512 changed the title Add cache for template literal parsing refactor: add cache for template literal parsing Jul 16, 2025
@nwalters512 nwalters512 marked this pull request as ready for review July 16, 2025 16:52
@yeonjuan yeonjuan requested review from Copilot and yeonjuan July 17, 2025 15:36
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR improves performance by introducing a per-node cache for parsed template literals and switching rule implementations to use the cached AST plus a separate traversal step rather than reparsing on each rule invocation.

  • Added template-cache.js to store parse results in a WeakMap keyed by AST nodes.
  • Updated core visitor utilities (visitors.js) and all affected rules to replace direct parse calls with getCachedParseResult + traverse.
  • Removed direct imports of parse from @html-eslint/template-parser in favor of the new caching layer.

Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/eslint-plugin/lib/rules/utils/template-cache.js New module providing WeakMap-based caching of parse results
packages/eslint-plugin/lib/rules/utils/visitors.js Switched from parse to getCachedParseResult + traverse
packages/eslint-plugin/lib/rules/no-trailing-spaces.js Updated to use cached parse results
packages/eslint-plugin/lib/rules/no-multiple-empty-lines.js Updated to use cached parse results
packages/eslint-plugin/lib/rules/no-duplicate-in-head.js Updated to use cached parse results and traverse
packages/eslint-plugin/lib/rules/no-duplicate-id.js Updated to use cached parse results and traverse
packages/eslint-plugin/lib/rules/indent/indent.js Updated to use cached parse results and traverse
Comments suppressed due to low confidence (2)

packages/eslint-plugin/lib/rules/utils/template-cache.js:2

  • [nitpick] JSDoc tag @import is not a standard annotation. Consider using @typedef or @template to reference these types so editors and type-checkers recognize them.
 * @import {TemplateLiteral} from "@html-eslint/types";

packages/eslint-plugin/lib/rules/utils/template-cache.js:30

  • There are no unit tests verifying that getCachedParseResult actually caches parse results. Consider adding tests to ensure the cache is used and parse is invoked only once per node.
  const result = parse(node, sourceCode, {});

@@ -6,7 +6,8 @@ const {
shouldCheckTaggedTemplateExpression,
shouldCheckTemplateLiteral,
} = require("./settings");
const { parse } = require("@html-eslint/template-parser");
const { getCachedParseResult } = require("./template-cache");
const { traverse } = require("@html-eslint/template-parser/lib/traverser");
Copy link
Preview

Copilot AI Jul 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The traverse import and usage pattern is duplicated across multiple rule files. Consider centralizing a helper that wraps parse + traverse to reduce boilerplate and ease future updates.

Copilot uses AI. Check for mistakes.

Copy link

codecov bot commented Jul 17, 2025

Codecov Report

Attention: Patch coverage is 90.00000% with 4 lines in your changes missing coverage. Please review.

Project coverage is 98.30%. Comparing base (bdfef79) to head (6104f1c).

Files with missing lines Patch % Lines
...ackages/eslint-plugin/lib/rules/no-duplicate-id.js 66.66% 2 Missing ⚠️
...ages/eslint-plugin/lib/rules/no-trailing-spaces.js 66.66% 1 Missing ⚠️
...es/eslint-plugin/lib/rules/utils/template-cache.js 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #390      +/-   ##
==========================================
- Coverage   98.36%   98.30%   -0.07%     
==========================================
  Files          81       82       +1     
  Lines        2635     2657      +22     
  Branches      725      726       +1     
==========================================
+ Hits         2592     2612      +20     
- Misses         43       45       +2     
Flag Coverage Δ
unittest 98.30% <90.00%> (-0.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/eslint-plugin/lib/rules/indent/indent.js 99.44% <100.00%> (+<0.01%) ⬆️
...es/eslint-plugin/lib/rules/no-duplicate-in-head.js 100.00% <100.00%> (ø)
...eslint-plugin/lib/rules/no-multiple-empty-lines.js 100.00% <100.00%> (ø)
packages/eslint-plugin/lib/rules/utils/visitors.js 100.00% <100.00%> (ø)
...ages/eslint-plugin/lib/rules/no-trailing-spaces.js 92.85% <66.66%> (ø)
...es/eslint-plugin/lib/rules/utils/template-cache.js 90.00% <90.00%> (ø)
...ackages/eslint-plugin/lib/rules/no-duplicate-id.js 95.23% <66.66%> (-2.20%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yeonjuan
Copy link
Owner

Hi @nwalters512 Thank you for your contribution — great work! I think there's some room for a bit of refactoring. Would it be alright if I continue with that after merging this PR?

@nwalters512
Copy link
Contributor Author

Absolutely! Feel free to run with this as a starting point. And let me know if there's anything I can do to help. My team would love to use this plugin so the sooner we can get a release with these improvements the better 🙂

@yeonjuan
Copy link
Owner

Thanks! I'm planning to release a version including this PR this weekend.

@yeonjuan yeonjuan merged commit 8823fb3 into yeonjuan:main Jul 17, 2025
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants