-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Bug: enable_asset_timestamp does not invalidate cached assets on file changes #4049
Description
Summary
The system.assets.enable_asset_timestamp setting is misleadingly named. When enabled, it appends a query string to asset URLs that implies per-file cache busting (a "timestamp"), but the value is a global cache key that only changes when Grav configuration or version changes — never when individual JS/CSS files change on disk.
Combined with long-lived Cache-Control headers, this causes browsers to serve stale assets indefinitely after deployment.
Expected Behavior
When enable_asset_timestamp: true:
user/themes/july/js/script.js (modified at 14:00)
→ script.js?1774017600 (or similar mtime-based hash)
user/themes/july/js/script.js (modified at 15:00)
→ script.js?1774021200 (different value, cache invalidated)
A developer edits script.js, deploys, and browsers fetch the new version.
Actual Behavior
When enable_asset_timestamp: true:
user/themes/july/js/script.js (modified at 14:00)
→ script.js?g-5fb7ccf4
user/themes/july/js/script.js (modified at 15:00)
→ script.js?g-5fb7ccf4 (unchanged — same value)
The query string does not change. Browsers serve the old file from cache.
Root Cause
Grav\Common\Assets.php:159-161:
if ($this->enable_asset_timestamp) {
$this->timestamp = Grav::instance()['cache']->getKey();
}getKey() returns the global cache key, computed once in Grav\Common\Cache.php:154-157:
$prefix = $this->config->get('system.cache.prefix'); // default: 'g'
$uniqueness = substr(md5($uri->rootUrl(true) . $this->config->key() . GRAV_VERSION), 2, 8);
$this->key = ($prefix ?: 'g') . '-' . $uniqueness; // e.g. 'g-5fb7ccf4'This hash is derived from:
rootUrl— the site's base URLconfig key— fingerprint of the merged YAML configurationGRAV_VERSION— the Grav version string
None of these change when a JS/CSS file is edited.
Why This Is Dangerous
Many production configurations — including Grav's own documentation recommendations — serve assets with the g- query string using aggressive caching headers:
Cache-Control: public, max-age=31536000, immutable
The immutable directive tells the browser: never revalidate, serve from cache until the URL changes. The browser trusts this because the URL contains what appears to be a unique, changing token (g-5fb7ccf4).
But the token doesn't change on file edits. The result:
| Scenario | Browser behavior |
|---|---|
User visits site, loads script.js?g-5fb7ccf4 |
Caches it for 1 year (immutable) |
Developer deploys updated script.js |
New file on server |
| User visits site again | Same URL script.js?g-5fb7ccf4 |
Browser sees immutable, serves from cache |
Stale JS served |
| Bug manifests: broken functionality | User sees old behavior |
The user has no way to recover except clearing browser cache manually. This can affect hundreds or thousands of users after every deployment that touches JS/CSS.
Reproduction
-
Enable asset timestamps in
user/config/system.yaml:assets: enable_asset_timestamp: true
-
Note the URL of a JS file in page source:
/user/themes/july/js/script.js?g-5fb7ccf4 -
Edit
user/themes/july/js/script.js(add a comment or change functionality). -
Reload the page. The URL is still:
/user/themes/july/js/script.js?g-5fb7ccf4 -
Clear Grav cache (
bin/grav cache --all). The URL is still:/user/themes/july/js/script.js?g-5fb7ccf4 -
The only way to change the URL is to edit a YAML config file or update Grav itself.
Proposed Fix
Option A: Per-file mtime-based timestamp (recommended)
Replace the global cache key with per-file modification time:
// In renderQueryString() or equivalent
if ($this->enable_asset_timestamp) {
$file = GRAV_ROOT . '/' . $this->asset;
if (file_exists($file)) {
$this->timestamp = dechex(filemtime($file));
}
}This gives each asset a unique, content-aware hash that changes on every file edit.
I prefer human-readable (and easy to debug) timestamp strings:
$this->timestamp = date('Ymd-His', filemtime($file));
// gives a nice `20260101-123456`Ideally the cache string is a hash – md5 or xxHash/xxh3 (since PHP 8.1) – over the assets content: Same hash = same content = caching forever, live’s good.
Option B: Hash of all asset mtimes
Compute a single hash from the modification times of all registered assets. Changes when any file changes, but not per-file. Simpler than A but coarser.
Option C: Rename the feature
If the global cache key is intentional behavior, rename the config key and documentation to accurately describe what it does:
enable_asset_timestamp→enable_cache_key- Document that it invalidates on config/version change only
- Document that developers must use a build step to rename files or use content-hashed filenames
Additional Notes
The name "timestamp" strongly implies filemtime() behavior. Any developer enabling this setting would reasonably expect it to bust cache on file changes. The current behavior silently fails to do so, creating a latent bug that only manifests in production with long cache headers.
The setting defaults to false, which may mask the issue during development (assets are not cached aggressively without the query string). Problems only appear when enable_asset_timestamp: true is deployed alongside immutable cache headers — exactly the scenario the feature is meant to enable.