Minimal reproduction for a Vite+ task-cache bug: a vp build task whose build.outDir is nested under a tracked directory (e.g. laravel-vite-plugin's public/build) is never cached on any rebuild where a prior build is present.
The cause is in Vite+'s task-cache fs tracker — not in Vite, Rolldown, or Laravel (this repo uses none of them).
Two identical apps; the only difference is build.outDir:
| App | build.outDir |
Re-run over prior output |
|---|---|---|
apps/control |
dist/ (default) |
✅ 100% cache hit |
apps/bug |
public/build/ (nested under the tracked public/) |
❌ 0% — Not cached: read and wrote 'apps/bug/public/build/manifest.json' |
pnpm install
# CONTROL — caches:
vp cache clean && vp run --filter control build # build #1 (writes output)
vp cache clean && vp run --filter control build # build #2 (executes over prior output, repopulates cache)
vp run --filter control build && vp run --last-details # -> 100% cache hit
# BUG — never caches:
vp cache clean && vp run --filter bug build # build #1
vp cache clean && vp run --filter bug build # build #2 (executes over prior output)
vp run --filter bug build && vp run --last-details # -> 0%, "read and wrote …/public/build/manifest.json"The second vp cache clean (keeping the output on disk) is what arms the case: it forces build #2 to execute while a prior build is present, so emptyOutDir performs its recursive delete.
- Expected: the
bugapp caches on re-run, exactly likecontrol— nothing in the build genuinely reads its own output. - Actual: the
bugapp reportsnot cached because it modified its input, blaming a stable-named output file underpublic/build/(manifest.json,index.html, or a copiedpublic/asset — varies by run).
With a prior build present, Vite's emptyOutDir recursively deletes the old output. Node's recursive remove probes each existing file with openat(path, O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY), which fails with ENOTDIR on a regular file, then unlinks it. The task-cache fs tracker counts that failed O_RDONLY probe as a content read; the build then writes a fresh same-named file, so the task is flagged as having read and wrote the same path → self-modified input → uncacheable.
strace -f -e trace=%file of the bug build (over prior output) — the only read-like syscall on the manifest is the delete-probe; there is no content read:
lstat(".../public/build/manifest.json") = 0
openat(".../public/build/manifest.json", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 ENOTDIR
unlink(".../public/build/manifest.json") = 0
# ...later, in a worker thread, the fresh write:
openat(".../public/build/manifest.json", O_WRONLY|O_CREAT|O_TRUNC) = 29
control caches because its manifest lands in the default dist/, which the tracker excludes from input scope, so the identical probe is ignored. bug differs only in that public/build/ is nested under public/ — a directory the build reads from (it copies public/robots.txt), so the tracker treats it as input scope and the probe under it is counted.
Tested on the bug app, building over prior output:
| Build-task config | Result |
|---|---|
| baseline (this repo) | ❌ 0% — read and wrote …/public/build/… |
exclude the outDir from input: input: [{ auto: true }, "!public/build/**"] |
✅ 100% cache hit |
declare output: output: ["public/build/**"] |
❌ 0% — still read and wrote … |
So the cure is input-side exclusion of the output dir, not output declaration. (build.emptyOutDir: false — no recursive delete, hence no probe — also makes it cache, but leaves stale hashed assets un-pruned.)
Add an input exclusion for the output dir to the build task — keeps emptyOutDir: true (output still pruned) and caches:
run: { tasks: { build: { command: "vp build", cache: true, input: [{ auto: true }, "!public/build/**"] } } }The recursive delete and the layout are both legitimate (Vite's emptyOutDir, a custom outDir). The defect is purely in Vite+'s task-cache fs tracker classifying an ENOTDIR-failing O_DIRECTORY delete-probe as a content read — proprietary Vite+ code with no equivalent in Vite/Rolldown. This repo reproduces it with no framework, no Laravel, no Rolldown-specific config.
- voidzero-dev/vite-plus#1187, voidzero-dev/vite-plus#1095 — same "task modifies its own input" class, fixed for the default output dir; this is the residual case for a custom
outDirnested under a tracked dir. - voidzero-dev/vite-plus#1774 (PR,
auto output + tracked envs) — routes Vite's real inputs/outputs to the runner viaignoreInput/ignoreOutput; thatignoreInputchannel is exactly the input-side exclusion shown above to fix this, so it likely resolves this case.
vp v0.1.12
Local vite-plus: 0.2.1 (vite 8.0.16, rolldown 1.1.1)
Node.js 24.17.0
pnpm 11.8.0
Linux