Skip to content

feat(codegen): add --cache option to skip emitting unchanged files#418

Draft
mizdra wants to merge 2 commits into
mainfrom
feat/codegen-cache
Draft

feat(codegen): add --cache option to skip emitting unchanged files#418
mizdra wants to merge 2 commits into
mainfrom
feat/codegen-cache

Conversation

@mizdra

@mizdra mizdra commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds --cache flag to cmk. When enabled, files whose content has not changed since the last run skip the emit phase (generateDts + writeDtsFile).
  • The cache is stored at <dtsOutDir>/.cache. Running --clean automatically discards it since the entire output directory is removed.
  • Parse and check phases still run on every invocation, so diagnostics remain accurate even when a dependency changes without the file itself changing.

How it works

On each run with --cache:

  1. Load <dtsOutDir>/.cache (JSON). If the codegen version or the config hash differs from what is stored, discard the cache entirely.
  2. For each CSS module file, compute a SHA-256 hash of its content. If it matches the stored hash, skip emit for that file.
  3. After emitting, write the updated cache back to disk.

The cache file format:

{
  "version": "<codegen version>",
  "configHash": "<SHA-256 of JSON.stringify(CMKConfig)>",
  "files": {
    "src/a.module.css": "<SHA-256 of file content>",
    ...
  }
}

Performance

Measured with hyperfine on a synthetic project (30 selectors and 1 @import per file). Times include Node.js startup (~200 ms).

Files no-cache cache-cold cache-warm
100 172 ± 1 ms 175 ± 1 ms (+2%) 154 ± 1 ms (−10%)
500 249 ± 5 ms 265 ± 3 ms (+6%) 195 ± 2 ms (−22%)
1000 333 ± 6 ms 351 ± 3 ms (+6%) 242 ± 4 ms (−27%)
10000 1772 ± 18 ms 2052 ± 24 ms (+16%) 975 ± 3 ms (−45%)
  • cache-cold (first run with --cache, no existing cache) adds a small overhead for hashing every file and writing the cache, growing with file count (+2% at 100 files, +16% at 10000).
  • cache-warm (subsequent run, source files unchanged) is 10–45% faster. The speedup grows with the number of files and is bounded by the parse + check phases, which always run.
Benchmark code

setup.mjs — generates a synthetic project with N files

import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const N = parseInt(process.argv[2], 10);
if (!N || N <= 0) {
  console.error('Usage: node setup.mjs <N>');
  process.exit(1);
}

const SELECTORS = 30;

const dir = join(tmpdir(), 'cmk-bench-v2', `proj-${N}`);
rmSync(dir, { recursive: true, force: true });
mkdirSync(join(dir, 'src'), { recursive: true });

writeFileSync(
  join(dir, 'tsconfig.json'),
  JSON.stringify({ cmkOptions: { enabled: true, dtsOutDir: 'generated' } }),
);

for (let i = 0; i < N; i++) {
  let css = '';
  if (i !== 1) {
    css += `@import './file1.module.css';\n`;
  }
  for (let s = 0; s < SELECTORS; s++) {
    css += `.file${i}_${s + 1} { color: red; }\n`;
  }
  writeFileSync(join(dir, 'src', `file${i}.module.css`), css);
}

console.log(dir);

bench.sh — runs hyperfine for each scenario

#!/usr/bin/env bash
set -euo pipefail

CMK="node /path/to/css-modules-kit/packages/codegen/bin/cmk.js"

for N in 100 500 1000 10000; do
  PROJ=$(node setup.mjs "$N")

  # no-cache vs cache-cold: delete generated before each run
  hyperfine \
    --warmup 3 \
    --prepare "rm -rf $PROJ/generated" \
    --command-name "no-cache"    "$CMK --project $PROJ" \
    --command-name "cache-cold"  "$CMK --project $PROJ --cache"

  # cache-warm: cache + .d.ts files already exist, nothing changed
  hyperfine \
    --warmup 3 \
    --command-name "cache-warm" \
    "$CMK --project $PROJ --cache"

  rm -rf "$PROJ"
done

Test plan

  • Run vp test packages/codegen and confirm all tests pass.

Skips generateDts + writeDtsFile for files whose content hash matches
the previous run. parse and check still run every time, so diagnostics
remain accurate even when dependencies change.

Cache is stored at <dtsOutDir>/.cache as JSON containing the codegen
version, a hash of the full CMKConfig, and per-file content hashes.
The cache is invalidated entirely when the version or config hash
differs from the stored value. --clean discards the cache automatically
since it removes dtsOutDir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@mizdra mizdra added the Type: Feature New Feature label Jun 20, 2026
@changeset-bot

changeset-bot Bot commented Jun 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0285cab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@css-modules-kit/codegen Minor
@css-modules-kit/core Minor
@css-modules-kit/ts-plugin Minor
@css-modules-kit/eslint-plugin Minor
@css-modules-kit/stylelint-plugin Minor
css-modules-kit-vscode Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Feature New Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant