Skip to content

Fingerprint stacking: incremental builds produce duplicate hashed filenames #11

@sn

Description

@sn

Bug: fingerprint_assets() stacks hashes on incremental builds

File

nitro/core/bundler.py, method fingerprint_assets() (line 236)

What happens

On each nitro build, the build sequence is:

  1. generator.generate() copies fresh source files to build/ (nav.js, matrix.js, main.css)
  2. bundler.fingerprint_assets() renames them with content hashes

On a clean first build, this works correctly:

nav.js → nav.36da3320.js       (stem="nav", hash=36da3320)
matrix.js → matrix.3cc73026.js

On the second build (incremental), step 1 copies fresh nav.js into build/, but the previously fingerprinted nav.36da3320.js is still there from the last build. Then fingerprint_assets() runs self.build_dir.rglob("*.js") which finds both files:

  • nav.36da3320.js — stem is nav.36da3320, hash is 36da3320, new name: nav.36da3320.36da3320.js
  • nav.js — stem is nav, hash is 36da3320, new name: nav.36da3320.js

Result: both nav.36da3320.js and nav.36da3320.36da3320.js exist. Each subsequent build adds another hash layer. After 6 builds you get filenames like:

nav.613004d2.613004d2.613004d2.613004d2.613004d2.613004d2.36da3320.js

The HTML references stay correct (they point to the single-hash version), so the site works — but the build directory accumulates garbage files that grow with every build.

Root cause

Line 239-240 — the glob collects all .css and .js files in the build directory, including previously fingerprinted ones:

for pattern in ["*.css", "*.js"]:
    asset_files.extend(self.build_dir.rglob(pattern))

Line 253-255 — the new filename is built by appending the hash to asset_path.stem, which for an already-fingerprinted file like nav.36da3320.js produces nav.36da3320.{hash}.js:

stem = asset_path.stem        # "nav.36da3320"
suffix = asset_path.suffix    # ".js"
new_name = f"{stem}.{content_hash}{suffix}"  # "nav.36da3320.36da3320.js"

Suggested fix

Before fingerprinting a file, skip it if the stem already ends with a fingerprint hash. Add this check inside the loop at line 247, before processing:

import re

FINGERPRINT_RE = re.compile(r'\.[0-9a-f]{8}$')

for asset_path in asset_files:
    # Skip files that were already fingerprinted in a previous build
    if FINGERPRINT_RE.search(asset_path.stem):
        continue
    
    # ... rest of the fingerprinting logic

This skips nav.36da3320.js (stem nav.36da3320 matches \.[0-9a-f]{8}$) but processes nav.js (stem nav doesn't match). The previously fingerprinted file becomes an orphan, which gets overwritten by the fresh rename on line 262 since it produces the same filename.

Alternatively, you could delete stale fingerprinted files before the loop:

for asset_path in asset_files:
    if FINGERPRINT_RE.search(asset_path.stem):
        asset_path.unlink()

# Re-collect after cleanup
asset_files = []
for pattern in ["*.css", "*.js"]:
    asset_files.extend(self.build_dir.rglob(pattern))

Both approaches solve it. The skip approach is simpler; the delete approach keeps the build directory cleaner.

Reproduction

rm -rf build/ .nitro/
nitro build   # clean build — correct output
nitro build   # second build — stacked hashes appear
ls build/*.js # nav.36da3320.js AND nav.36da3320.36da3320.js

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions