Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

### Added

- `mix volt.js.check --type-aware` types imports of `.vue` files by the component's own declaration. The Vue plugin generates it from each checked `.vue` file with `Vize.generate_dts/2` and Volt sends it to tsgolint as `<file>.vue.d.ts`, which TypeScript resolves an import of the `.vue` file to. Props, emits and slots of imported components are then checked in `h(Button, {...})` and `InstanceType<typeof Button>`, and no `declare module "*.vue"` shim is needed for checked files. Plugins declare their own files through the new `c:Volt.Plugin.declaration/3` callback ([#63](https://github.com/elixir-volt/volt/issues/63)).

### Changed

- Browser tests share one browser per run, in `Volt.Test.Browser`, and each test gets a browser context of its own. Every test started a Playwright driver and launched a browser for itself, because the driver was linked to the test process and went away with it, which cost more than running the test. A test file is now bundled once for all of its tests and bundled again only when one of its source files changes, and the ExUnit modules generated for browser tests are `async: true` ([#65](https://github.com/elixir-volt/volt/issues/65)).

### Fixed

- Give vendor modules a new URL when a package changes. The dev server serves `/@vendor/<package>.js?v=<hash>` as immutable, and the hash covered the lockfiles and plugins but not the packages, so a package that changed in place, such as a linked one, kept its URL and browsers kept their copy of it. The hash now also covers the generation of the pre-bundle, which changes whenever a package is bundled again, and a compiled application module is compiled again when the hash it was compiled with is no longer current: after a lockfile change, unchanged modules kept importing the old URL, which answered 504. Packages linked from outside `node_modules` are watched, and a change bundles them again and reloads the page. `npm.lock`, the lockfile of `mix npm.install`, now counts as a lockfile; it was not read, so an install did not change the URL either ([#67](https://github.com/elixir-volt/volt/issues/67)).
- Treat Node built-in subpaths such as `timers/promises`, `fs/promises` and `stream/web` as built-ins, and read the `browser` field of packages. Bundling a package that requires `timers/promises`, such as `@sinonjs/fake-timers`, failed with `{:not_found, "timers/promises"}` even though its `browser` field maps the specifier to `false`. Specifiers a `browser` field maps to `false` now resolve to an empty module, and those mapped to a path resolve to that path, as in esbuild and webpack. The builder, the dev server and the Tailwind resolver share the built-in check ([#64](https://github.com/elixir-volt/volt/issues/64)).

## 0.21.0 - 2026-10-05

### Compatibility
Expand Down
2 changes: 2 additions & 0 deletions guides/features/formatting-and-linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ mix volt.js.check --type-aware --type-check

`--type-aware` also checks JavaScript-like scripts embedded in framework component files when the enabled plugin exposes them. Volt's built-in Vue and Svelte plugins expose `<script>` blocks as virtual `.js`, `.ts`, or `.tsx` modules for `tsgolint`, then map diagnostics back to the original `.vue` or `.svelte` file. Component templates are still handled by the normal syntax lint/format path; they are not passed to `tsgolint`.

Imports of `.vue` files are typed by the component itself: `import Button from "./Button.vue"` gives a component whose props, emits and slots the checker knows, so `h(Button, { ... })` and `InstanceType<typeof Button>` are checked against them. No `declare module "*.vue"` shim is needed for checked files.

Configure the executable when it is not on `PATH`:

```elixir
Expand Down
3 changes: 3 additions & 0 deletions guides/features/hmr.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ with the session's state generation.
| `.ts`, `.tsx`, `.js`, `.jsx`, `.vue`, `.svelte`, `.css`, `.scss`, `.sass` | Recompile, push update over WebSocket |
| `.ex`, `.heex`, `.eex` | Incremental Tailwind rebuild, CSS hot-swap |
| `.vue` (style-only change) | CSS hot-swap, no page reload |
| A linked package (a path dependency or `npm link`) | Bundle the package again, full page reload |

Packages are served under a versioned URL that browsers cache. The version changes when a lockfile changes, so an install is picked up on the next page load, and when a package is bundled again. Packages linked from outside `node_modules` are watched; one edited inside `node_modules` is bundled again on the next source change or server start.

The browser client auto-reconnects on disconnect, reloading the page when the connection returns, and shows compilation errors as an overlay. The dev server adds the client to HTML pages the app renders, so the overlay also appears when a page's scripts fail to load.

Expand Down
2 changes: 2 additions & 0 deletions guides/features/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ npx playwright install chromium

By default Volt uses `node_modules/playwright/cli.js` when present, otherwise `playwright` from `PATH`. Pass PlaywrightEx supervisor options with `playwright: [...]` if your executable lives elsewhere.

A test run shares one browser, launched the first time a test needs it, and every test gets a browser context of its own, so tests do not see each other's storage, cookies or pages. The ExUnit modules Volt generates for browser tests are `async: true`.

## ExUnit integration

Each collected JS/TS `test(...)` becomes an ExUnit test. That means normal ExUnit filtering, formatters, failures, and CI behavior continue to work.
Expand Down
6 changes: 5 additions & 1 deletion lib/volt/builder/collector.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,18 @@ defmodule Volt.Builder.Collector do
end

defp read_module(module_id, plugins) do
with nil <- read_embedded_module(module_id, plugins),
with false <- Volt.Builder.Resolver.empty_module?(module_id),
nil <- read_embedded_module(module_id, plugins),
nil <- read_plugin_module(module_id, plugins) do
path = module_path(module_id)

case File.read(path) do
{:ok, source} -> {:ok, source, nil}
error -> error
end
else
true -> {:ok, "export {};\n", nil}
other -> other
end
end

Expand Down
54 changes: 50 additions & 4 deletions lib/volt/builder/resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,62 @@ defmodule Volt.Builder.Resolver do

Returns `{:ok, path}`, `:skip` (for externals/node builtins), or `{:error, reason}`.
"""
# A module with nothing in it, for imports a package's `browser` field maps
# to `false`. The collector supplies its source; see `empty_module?/1`.
@empty_module "volt:empty.js"

def resolve(specifier, importer, ctx) do
{path_specifier, query} = Volt.JS.Specifier.split_query(specifier)

if external?(path_specifier, ctx.external) do
:skip
if external?(path_specifier, ctx.external),
do: :skip,
else: resolve_for_browser(path_specifier, importer, ctx, query)
end

@doc "Return whether a resolved path is the empty module."
def empty_module?(path), do: path == @empty_module

# A package's `browser` field says what a specifier means in browsers: `false`
# for nothing, as for Node built-ins a package only uses when it has them, or a
# path to a replacement. esbuild, webpack and Rollup honour it the same way.
defp resolve_for_browser(specifier, importer, ctx, query) do
case browser_field(specifier, importer) do
false -> {:ok, @empty_module}
{:replace, path} -> append_query(resolve_relative(path, importer, ctx), query)
nil -> do_resolve(specifier, importer, ctx, query)
end
end

defp browser_field(_specifier, nil), do: nil

defp browser_field(specifier, importer) do
with {:ok, package_dir, %{"browser" => browser}} when is_map(browser) <-
NPM.Resolution.PackageResolver.nearest_package(Path.dirname(importer_path(importer))),
{:ok, value} <- fetch_browser_mapping(browser, specifier) do
case value do
false -> false
path when is_binary(path) -> {:replace, Path.expand(path, package_dir)}
_other -> nil
end
else
do_resolve(path_specifier, importer, ctx, query)
_none -> nil
end
end

defp fetch_browser_mapping(browser, specifier) do
Enum.find_value([specifier, "node:" <> specifier], :error, fn key ->
case Map.fetch(browser, key) do
{:ok, value} -> {:ok, value}
:error -> nil
end
end)
end

defp importer_path(importer) do
{path, _query} = Volt.URL.split_query(importer)
path
end

def absolute?(specifier), do: Path.type(specifier) == :absolute

defp do_resolve(specifier, importer, ctx, query) do
Expand Down Expand Up @@ -46,7 +92,7 @@ defmodule Volt.Builder.Resolver do

defp resolve_by_type(specifier, importer, ctx) do
cond do
NPM.Resolution.PackageResolver.node_builtin?(specifier) ->
Volt.JS.Specifier.node_builtin?(specifier) ->
:skip

# OXC's transform imports helpers from `@oxc-project/runtime`, and `OXC.bundle/2`
Expand Down
43 changes: 29 additions & 14 deletions lib/volt/dev_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ defmodule Volt.DevServer do
if Volt.Config.Tailwind.enabled?(tailwind_config),
do: Volt.Config.Tailwind.new(tailwind_config)

session =
if server_config.watch do
Volt.Dev.session_identity(
root: expanded_root,
id: profile || :default,
session: Keyword.get(opts, :session, :default)
)
else
Keyword.get(opts, :session, :default)
end

# A watcher says when sources change, so the scan can wait for that.
watched? = server_config.watch or not is_nil(Keyword.get(opts, :session_supervisor))

Expand All @@ -77,17 +88,6 @@ defmodule Volt.DevServer do
watched?
)

session =
if server_config.watch do
Volt.Dev.session_identity(
root: expanded_root,
id: profile || :default,
session: Keyword.get(opts, :session, :default)
)
else
Keyword.get(opts, :session, :default)
end

watcher_opts =
if server_config.watch and is_nil(Keyword.get(opts, :session_supervisor)) do
watch_dirs =
Expand Down Expand Up @@ -493,7 +493,9 @@ defmodule Volt.DevServer do
content_type = content_type_for(module_id, css_import?)
cache_key = cache_key_for(module_id, css_import?)

case Volt.Cache.get(cache_key, mtime, config.tables || config.session) do
# A compiled module imports vendor modules by their version, which changes
# when a package is installed or bundled again.
case current_cached(cache_key, mtime, config) do
%{code: code, sourcemap: sourcemap} ->
send_compiled(conn, code, sourcemap, content_type)

Expand All @@ -511,6 +513,16 @@ defmodule Volt.DevServer do
end
end

defp current_cached(cache_key, mtime, config) do
case Volt.Cache.get(cache_key, mtime, config.tables || config.session) do
%{vendor_hash: vendor_hash} = entry when is_binary(vendor_hash) ->
if vendor_hash == vendor_hash(config), do: entry

entry ->
entry
end
end

defp compile_and_serve(
conn,
module_id,
Expand Down Expand Up @@ -563,7 +575,8 @@ defmodule Volt.DevServer do
sourcemap: result.sourcemap,
css: result.css,
hashes: result.hashes,
content_type: content_type
content_type: content_type,
vendor_hash: vendor_hash(config)
}

if cacheable?,
Expand Down Expand Up @@ -850,7 +863,7 @@ defmodule Volt.DevServer do
specifier = URL.append_query(specifier, query)

cond do
NPM.Resolution.PackageResolver.node_builtin?(specifier) ->
Volt.JS.Specifier.node_builtin?(specifier) ->
:keep

String.starts_with?(specifier, "#") ->
Expand Down Expand Up @@ -969,6 +982,8 @@ defmodule Volt.DevServer do

defp prebundle_vendor(opts, false), do: Volt.JS.Vendor.prebundle(opts)

defp vendor_hash(config), do: Volt.JS.Vendor.browser_hash(vendor_opts(config))

defp serve_vendor(specifier, config, browser_hash) do
vendor_opts = vendor_opts(config)

Expand Down
6 changes: 4 additions & 2 deletions lib/volt/dev_server/cache_entry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ defmodule Volt.DevServer.CacheEntry do
sourcemap: nil,
css: nil,
hashes: nil,
content_type: Volt.MIME.javascript()
content_type: Volt.MIME.javascript(),
vendor_hash: nil

@type t :: %__MODULE__{
code: String.t(),
sourcemap: String.t() | nil,
css: String.t() | nil,
hashes: Volt.Pipeline.Result.Hashes.t() | nil,
content_type: String.t()
content_type: String.t(),
vendor_hash: String.t() | nil
}
end
65 changes: 45 additions & 20 deletions lib/volt/js/check.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,24 @@ defmodule Volt.JS.Check do
end

defp type_aware_lint(files, config, lint_config, opts) do
{files, source_overrides, source_files} = type_aware_inputs(files)
inputs = type_aware_inputs(files)

# Declarations are resolved from imports, so they are not checked files
# and need no place in a project; the overlay only lists the modules.
common_opts =
[
type_aware: true,
type_check: opts[:type_check] == true,
source_overrides:
source_overrides
|> Map.merge(Volt.JS.Lint.TSConfigOverlay.overrides(Map.keys(source_overrides)))
inputs.overrides
|> Map.merge(Volt.JS.Lint.TSConfigOverlay.overrides(Map.keys(inputs.overrides)))
|> Map.merge(inputs.declarations)
|> Map.merge(Keyword.get(config, :source_overrides, %{}))
] ++ type_aware_options(config)

files
source_files = inputs.source_files

inputs.files
|> Enum.group_by(fn file ->
original = Map.get(source_files, Path.expand(file), file)
lint_config |> Volt.JS.Lint.Config.options(original) |> OXC.Lint.type_aware_rules()
Expand All @@ -105,42 +110,62 @@ defmodule Volt.JS.Check do
end)
end

# The files tsgolint checks, with the virtual modules of plugin-owned files
# such as `.vue` scripts among them, the sources of those modules, the files
# they came from, and the declarations imports of plugin-owned files resolve
# to.
defp type_aware_inputs(files) do
plugins = Volt.Config.build().plugins
inputs = %{files: [], overrides: %{}, source_files: %{}, declarations: %{}}

Enum.reduce(files, {[], %{}, %{}}, fn file, {files, overrides, source_files} ->
files
|> Enum.reduce(inputs, fn file, inputs ->
if type_aware_file?(file) do
{[file | files], overrides, source_files}
%{inputs | files: [file | inputs.files]}
else
source = File.read!(file)

file
|> embedded_modules(plugins)
|> Enum.reduce({files, overrides, source_files}, fn module, acc ->
add_embedded_module(acc, file, module)
end)
|> embedded_modules(plugins, source)
|> Enum.reduce(inputs, &add_embedded_module(&2, file, &1))
|> add_declaration(file, Volt.PluginRunner.declaration(plugins, file, source, []))
end
end)
|> then(fn {files, overrides, source_files} ->
{Enum.reverse(files), overrides, source_files}
end)
|> Map.update!(:files, &Enum.reverse/1)
end

defp type_aware_file?(file), do: Path.extname(file) in Volt.JS.Extensions.bundleable()

defp embedded_modules(file, plugins) do
Volt.PluginRunner.embedded_modules(plugins, file, File.read!(file), [])
defp embedded_modules(file, plugins, source) do
Volt.PluginRunner.embedded_modules(plugins, file, source, [])
end

defp add_embedded_module({files, overrides, source_files}, file, module) do
defp add_embedded_module(inputs, file, module) do
virtual_file = "#{file}.#{module.type}#{module.index}#{module.extension}"
expanded = Path.expand(virtual_file)

{
[virtual_file | files],
Map.put(overrides, expanded, module.source),
Map.put(source_files, expanded, file)
%{
inputs
| files: [virtual_file | inputs.files],
overrides: Map.put(inputs.overrides, expanded, module.source),
source_files: Map.put(inputs.source_files, expanded, file)
}
end

# TypeScript resolves an import of `Button.vue` to `Button.vue.d.ts`, so a
# generated declaration under that name types the import without a
# `declare module "*.vue"` shim. A declaration the project wrote itself
# under that name is kept.
defp add_declaration(inputs, file, {:ok, declaration}) do
path = Path.expand("#{file}.d.ts")

if File.regular?(path),
do: inputs,
else: %{inputs | declarations: Map.put(inputs.declarations, path, declaration)}
end

defp add_declaration(inputs, _file, _none), do: inputs

defp restore_sfc_file(diagnostic, source_files) do
case Map.fetch(source_files, diagnostic.file) do
{:ok, file} -> %{diagnostic | file: file}
Expand Down
Loading
Loading