Filesystem-Based Slow Render Cache
Design Log #110 — Filesystem-Based Slow Render Cache
Written for AI agents. See Log Methodology Note below for details.
Background
The dev server's SlowRenderCache uses a hybrid approach: pre-rendered jay-html files are stored on disk (so Vite can compile them), but the cache metadata (slowViewState, carryForward with __instances) is stored in-memory. Additionally, loadPageParts runs on every request even though its output is stable per route until source files change.
Problem
1. withLoadParams runs on every request
compDefinition.loadParams is called inside runSlowlyForPage on every request. It returns an async iterable of all valid param combinations (e.g., all product slugs) and checks if the current request params match. This can be expensive (e.g., querying a database). The result is stable for the lifetime of the dev server — param routes don't change while developing.
2. Cache metadata is in-memory only
SlowRenderCache stores slowViewState and carryForward (including __instances with discovered instances, their props, coordinates, and slow-phase carryForwards) in a Map<CacheKey, SlowRenderCacheEntry>. On dev server restart:
- The pre-rendered jay-html files on disk survive
- But the metadata is gone — the entry can't be reconstructed
- First request after restart always re-runs the full slow render pipeline
Design
Change 1: Cache loadParams results per route
Cache the loadParams result per route (jayHtmlPath). On first request for a route, runSlowlyForPage calls compDefinition.loadParams, iterates all param combinations, and caches the result. On subsequent requests, use the cached result to validate params without re-running the callback.
Cache location: In DevSlowlyChangingPhase (or a new cache passed to it). Keyed by jayHtmlPath.
Invalidation: When page.ts or .jay-contract changes (same triggers as slow render cache), invalidate the loadParams cache for that route. New param sets are discovered on the next request.
Change 2: Embed cache metadata in the pre-rendered jay-html
Instead of storing slowViewState and carryForward in memory, embed them in the pre-rendered jay-html file as a <script type="application/jay-cache"> tag.
<script type="application/jay-cache">
{
"slowViewState": { "title": "Hello" },
"carryForward": { "__instances": { ... } }
}
</script>
On cache set: Prepend the script tag to the pre-rendered jay-html before writing to disk.
On cache read: Parse the script tag from the file, extract metadata, strip the tag before returning.
The SlowRenderCache simplifies:
- Remove in-memory
cachemap — the filesystem IS the cache get()checks if the file exists, reads it, extracts the<script>tagset()embeds the metadata and writes the fileinvalidate()deletes the file- On startup, existing cache files are automatically available
Questions
Q1: Should we cache the full loadParams result or just the match outcome?
A: Cache the full UrlParams[] result. loadParams returns an async iterable of all valid param combinations. Collect into an array and cache so findMatchingParams can check against it without re-running the callback.
Q2: What about pages with URL params? Each param combination has a different cache file.
A: Same as current behavior — each param combination gets its own file with its own jay-cache metadata. The loadPageParts cache is per route (not per params) since component definitions don't change with params.
Q3: Should we strip the <script> tag in SlowRenderCache.get() or in the consumer?
A: In get(). Return the entry with content already stripped. The consumer never sees the cache tag.
Q4: What about the pathToKeys mapping for invalidation?
A: Keep it for invalidation. But instead of mapping to cache keys, map source paths to cache file paths on disk. On invalidation, delete the files. On startup, reconstruct the mapping by scanning the cache directory.
Implementation Plan
Phase 1: Cache loadParams per route
- Add
loadParamsCache: Map<string, UrlParams[]>toDevSlowlyChangingPhase(or dev server state) - On first call to
runSlowlyForPagefor a route, collect all params fromloadParamsand cache - On subsequent calls, use cached params for
findMatchingParams - Invalidate on file changes (same watcher as slow render cache)
Phase 2: Embed cache metadata in jay-html
SlowRenderCache.set(): prepend<script type="application/jay-cache">with JSON metadataSlowRenderCache.get(): read file, extract and parse the script tag, return entry- Remove in-memory
cachemap - Strip the
<script>tag in the returned content
Phase 3: Startup cache recovery
- On
get(), if the file exists on disk but isn't in the path mapping, read and parse it - Lazy recovery — no startup scan, just discover on first access
Files to modify
packages/jay-stack/stack-server-runtime/lib/slow-render-cache.ts— embed metadata, filesystem-onlypackages/jay-stack/stack-server-runtime/lib/slowly-changing-runner.ts— loadParams cachingpackages/jay-stack/dev-server/lib/dev-server.ts— invalidation for loadParams cache
Verification
- Dev server restart with existing cache files → no slow render on first request
- File change → cache invalidated → next request triggers full pre-render
loadParamscalled once per route per dev server lifecycle- All hydration tests pass (195 tests)
stack-server-runtimetests pass- Pages with URL params work correctly (param validation uses cached result)
Implementation Results
Changes Made
slow-render-cache.ts:
- Removed in-memory
cachemap — filesystem is now the only cache set()embeds<script type="application/jay-cache">with JSON metadata (slowViewState, carryForward, sourcePath), returns fullSlowRenderCacheEntrywith stripped contentget()is now async — reads file from disk, extracts and strips the cache tag, returns entry or undefinedhas()is now async — checks file existence- Added
preRenderedContenttoSlowRenderCacheEntry— consumers get pre-stripped content pathToKeysrenamed topathToFiles(maps source paths to pre-rendered file paths)- Added
scanAndDeleteCacheFiles()for invalidation after restart when pathToFiles is not populated - Lazy recovery:
get()registers discovered files in pathToFiles automatically
slowly-changing-runner.ts:
- Added
loadParamsCache: Map<string, UrlParams[][]>toDevSlowlyChangingPhase - Added
jayHtmlPath?: stringparameter toSlowlyChangingPhaseinterface - On first call, collects all params from async iterable per part and caches them
- On subsequent calls, validates against cached params without re-running loadParams
- Added
invalidateLoadParamsCache(jayHtmlPath)method
load-page-parts.ts:
- Added
preRenderedContent?: stringtoLoadPagePartsOptions - When provided, uses content directly instead of reading from disk
dev-server.ts:
- Build folder is now fully cleared on startup (was: preserving
pre-rendered/). Server elements, CSS files, and pre-rendered cache all go stale when package code or jay-html templates change between restarts. The cost of re-running the slow render pipeline on first request is small compared to the debugging cost of stale artifacts get()calls are now awaited (async API)- Removed
fs.accesscheck —get()handles file existence internally handleCachedRequestpassespreRenderedContentto bothloadPagePartsandsendResponsehandlePreRenderRequestusesset()return value (full entry) directlysendResponseaccepts optionalpreLoadedContentparametersetupSlowRenderCacheInvalidationalso invalidates loadParams cache viaslowlyPhase.invalidateLoadParamsCache()- Both
runSlowlyForPagecall sites passroute.jayHtmlPathfor loadParams caching
Deviations from Design
loadParamsCachestoresUrlParams[][](array of arrays, one per part with loadParams) instead of flatUrlParams[], correctly handling multiple parts with independent loadParams- Added
preRenderedContentto the cache entry and plumbed it throughloadPagePartsandsendResponseto avoid re-reading the file that now contains the cache tag - Added route filtering in
mkDevServerto exclude routes found inside the build folder. Preservingpre-rendered/across restarts means cachedpage.jay-htmlfiles could be picked up byscanRoutesas additional routes (when build folder is inside pages root, e.g., in tests)
Test Results
- stack-server-runtime: 89/89 passed (10 test files)
- hydration: 195/195 passed
- dev-server: 4/4 passed
- TypeScript: zero type errors in both packages
Bug Fix: Server Element Cache Not Invalidated on File Change
Problem
Two caches were not properly invalidated when jay-html, page.ts, or .jay-contract files changed:
serverModuleCachekey mismatch — The cache ingenerate-ssr-response.tsis keyed by the pre-rendered path (e.g.,build/pre-rendered/products/[slug]/page_abc123.jay-html), butinvalidateServerElementCache(changedPath)was called with the source path (e.g.,src/pages/products/[slug]/page.jay-html). These never matched, so the server element cache was never invalidated. Additionally,invalidateServerElementCachewas only called for.jay-htmlchanges — not forpage.tsor.jay-contractchanges.Vite module graph staleness —
compileAndLoadServerElementwrites a.server-element.tsfile intobuild/pre-rendered/and loads it viavite.ssrLoadModule(). Butbuild/is in the watcher's ignore list (vite-factory.ts), so Vite never detects the file was overwritten and returns a stale cached module.
Fix
dev-server.ts: Replaced invalidateServerElementCache(changedPath) with clearServerElementCache() in all three watcher branches (.jay-html, page.ts, .jay-contract). Since pre-rendered paths include param hashes, we can't map source → pre-rendered paths. Clearing all entries is safe in dev — the server element is recompiled on next request.
generate-ssr-response.ts: Added Vite module graph invalidation in compileAndLoadServerElement before calling vite.ssrLoadModule(). This ensures Vite reloads the newly written .server-element.ts instead of returning a stale version from its ignored-directory cache.
Bug Fix: Hydration Script Not Reloaded on jay-html Change
Problem
After editing a jay-html file, the SSR HTML output updated correctly but the hydration script served to the browser did not change, causing hydration mismatch warnings (DOM structure from new SSR vs old hydration code).
Two caches held stale data for pre-rendered file paths:
Vite module graph — The hydrate module is imported from the pre-rendered path (e.g.,
build/pre-rendered/.../page_hash.jay-html?jay-hydrate). The rollup plugin'swatchChangehook invalidated modules keyed by the source path, but the hydrate module was registered under the pre-rendered path.getModuleByIdnever found it.Rollup plugin
jayFileCache— The plugin caches parsed jay-html results inJayPluginContext.jayFileCache, keyed byoriginId(the resolved file path). When a source file changed,watchChangecalleddeleteCachedJayFile(sourceFilePath), but the hydrate module's cache entry was keyed by the pre-rendered path. After Vite module invalidation, re-transformation still returned the stale cached parse result (getJayFileStructureline 21-22 returns cached result without checking the new file content).
Fix
runtime-compiler.ts (watchChange): Changed jayContext.deleteCachedJayFile(id) to jayContext.jayFileCache.clear(). When any source jay-html changes, all cached parse results are cleared — including entries for derived build-directory files. This is safe since jay-html changes are infrequent and reparsing is fast.
generate-ssr-response.ts (compileAndLoadServerElement): Added invalidateJayHtmlModules() which invalidates all Vite module graph entries derived from the pre-rendered jay-html file. Uses three lookup strategies (by file, by known ID patterns, and full idToModuleMap scan) since Vite may store modules under different keys depending on resolution context.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.