Skip to content

Fix issues #56–#60: dev-server paths, runtime helpers, pre-bundling, Vue script blocks and their tsconfig - #61

Merged
dannote merged 5 commits into
masterfrom
fix-dev-server-issues
Oct 5, 2026
Merged

dannote merged 5 commits into
masterfrom
fix-dev-server-issues

Conversation

@dannote

@dannote dannote commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #56, fixes #57, fixes #58, fixes #59, fixes #60. One commit per issue.

#56 — vendor cache written relative to the working directory

Phoenix.CodeReloader changes the VM's working directory while it compiles a reloadable path dependency. A vendor request served in that window resolved the relative cache directory under the dependency: the bundle landed in the dependency's _build, or the write failed with could not write to file.

Volt now remembers the working directory when it starts (Volt.Paths.root/0), and the dev server resolves from it: the vendor cache, the asset root, watched and reload directories, the public directory and the Tailwind input.

Build tasks, which run with the project as the working directory, are unchanged. Paths resolved deeper in the Tailwind compiler and for tsconfig.json and aliases still use the current working directory; they are shared with the build, and I left them alone here.

#57 — <script lang="elixir"> treated as JavaScript

Volt.Plugin.Vue returned every <script> block as a script module and named any unknown lang .js, so mix volt.js.check linted and type-checked Elixir as JavaScript. Only blocks without a lang, or with js, jsx, ts or tsx, are script modules now, and jsx blocks are named .jsx.

#58 — lowered class fields import helpers /@vendor/ cannot serve

With a :target below ES2022, OXC lowers a class field into an import of an @oxc-project/runtime helper. /@vendor/ answered 404 unless the npm package was installed, so the importing module, and every module importing it, failed to load silently.

The production build already leaves these imports to OXC.bundle/2, which provides the helpers itself. Vendor bundling now does the same through an entry that only names the helper. The specifier check moves to Volt.JS.Specifier, shared by the builder and the dev server.

#59 — pre-bundling on every request

Phoenix runs init/1 per request in development, and Volt.DevServer.init/1 pre-bundles vendor packages. Two things made that expensive and unsafe:

  • A bare import that resolves to no package, such as one the host page provides, is never written to the cache, so it never counted as fresh and every call bundled all packages again. Concurrent requests then replaced cache files others were reading. Such imports are no longer part of the freshness check.
  • Even with a fresh cache, each request scanned every source file. With a watcher, the dev server now pre-bundles once and again only after a source file changes, and requests with the same options take turns. Without a watcher nothing says when sources change, so it scans on every init as before.

I did not make it run strictly once: packages added later would then be bundled one by one on demand, each with its own copy of shared dependencies, which an existing test for shared chunks caught.

The second failure in #59, a Volt watcher is already running for this asset root with different options after a restart, fits #56: a request resolving the asset root during the code reloader's directory change gets a different root. That is addressed by the #56 commit, but I have not reproduced it.

The rest of init/1 (reading config, tsconfig.json and .env files) still runs per request.

#60 — component scripts type-checked without the project's tsconfig

A .vue or .svelte script goes to tsgolint as a virtual module. tsgolint (v0.22.1, cmd/tsgolint/overlayfs.go) overlays source overrides for "does this file exist" and "read it", but directory listings still come from disk. A tsconfig's include is expanded by listing directories, so a virtual module is never included and lands in an inferred project: no paths, none of the project's declaration files.

tsgolint reads a tsconfig.json through the same overlay. So the nearest tsconfig.json is overridden with one that extends the original, carried unchanged under another name beside it, and names the virtual modules in files. The original's own files are repeated, because files replaces the extended config's, and the default include is spelled out when the original relied on it. Nothing is written to disk.

Known limit: an original that gets its include only through its own extends chain is treated as having one; if that chain has none either, the default include is lost for that run.

Verification

Changelog: three entries under Unreleased. #54 and #55 also add an Unreleased section, so later merges need their changelog entries rebased.

Volt.Plugin.Vue returned every <script> block as a script module and
named any lang it did not know .js. A block in another language, such as
PhoenixVapor's <script lang="elixir">, became a virtual .js module, so
mix volt.js.check linted and type-checked Elixir as JavaScript.

Only blocks without a lang, or with js, jsx, ts or tsx, are script
modules now, and jsx blocks are named .jsx.

Fixes #57
With a :target below ES2022, OXC lowers a class field into an import of
a helper from @oxc-project/runtime. The dev server rewrote that import
to /@vendor/, which resolved the package from node_modules and answered
404 when it was not installed. The importing module then failed to load,
and every module importing it, with nothing in the page or the log.

The production build already leaves these imports to OXC.bundle/2,
which provides the helpers itself. Vendor bundling now does the same
through an entry that only names the helper. The check for such
specifiers moves to Volt.JS.Specifier, shared by both.

Fixes #58
The working directory belongs to the whole VM, and Mix changes it while
it compiles a dependency. Phoenix.CodeReloader does that for a
reloadable path dependency during a request, so a vendor request served
at the same time resolved the relative cache directory under the
dependency: the bundle was written into the dependency's _build, or the
write failed with enoent and the page's module graph did not load.

Volt now remembers the working directory when it starts, and the dev
server resolves from it: the vendor cache, the asset root, watched and
reload directories, the public directory and the Tailwind input. Build
tasks, which run with the project as the working directory, are
unchanged.

Fixes #56
Phoenix runs a plug's init/1 on every request in development, and
Volt.DevServer.init/1 pre-bundles vendor packages. Two things made that
expensive and unsafe:

A bare import that resolves to no package, such as one the host page
provides, is never written to the cache, so it never counted as fresh
and every call bundled all packages again. Concurrent requests then
replaced cache files that other requests were reading, which fits the
module graph failing to load right after a restart. Such imports are no
longer part of the freshness check.

Even with a fresh cache, each request scanned every source file for
imports. With a watcher, the dev server now pre-bundles once and again
only after the watcher sees a source file change, and requests with the
same options take turns. Without a watcher nothing says when sources
change, so it scans on every init as before.

Fixes #59
@dannote dannote changed the title Fix vendor cache location, OXC runtime helpers in dev, and non-JS Vue script blocks Fix dev-server issues #56, #58, #59 and non-JS Vue script blocks (#57) Oct 5, 2026
The script of a .vue or .svelte file goes to tsgolint as a virtual
module in its source overrides. tsgolint assigns a file to a project by
the nearest tsconfig.json that includes it, and expands include by
listing directories on disk. Its overlay for the overrides answers
"does this file exist" and "read it", but directory listings still come
from disk, so a virtual module is never included and lands in an
inferred project: no paths, and none of the project's declaration
files.

tsgolint reads a tsconfig.json through the same overlay. So the nearest
tsconfig.json is overridden with one that extends the original, carried
unchanged under another name beside it, and names the virtual modules
in files. The original's own files are repeated, since files replaces
the extended config's, and the default include is spelled out when the
original relied on it.

Fixes #60
@dannote dannote changed the title Fix dev-server issues #56, #58, #59 and non-JS Vue script blocks (#57) Fix issues #56–#60: dev-server paths, runtime helpers, pre-bundling, Vue script blocks and their tsconfig Oct 5, 2026
@dannote
dannote merged commit 781b0bb into master Oct 5, 2026
2 checks passed
@dannote dannote mentioned this pull request Oct 5, 2026
@dannote
dannote deleted the fix-dev-server-issues branch October 5, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment