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
2 changes: 2 additions & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ logic = [
"Volt.JS.Check",
"Volt.JS.CommonJS",
"Volt.JS.Lint.Config",
"Volt.JS.Lint.TSConfigOverlay",
"Volt.JS.Extensions",
"Volt.JS.Format",
"Volt.JS.Discovery",
Expand Down Expand Up @@ -141,6 +142,7 @@ infrastructure = [
"Volt.Cache",
"Volt.Dev",
"Volt.Dev.Cleanup",
"Volt.Dev.Prebundled",
"Volt.Dev.Error",
"Volt.Priv.Vendor",
"Volt.DevServer.ClientTag",
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Fixed

- Leave Vue `<script>` blocks in other languages, such as `<script lang="elixir">`, out of the script modules that `mix volt.js.check` lints and type-checks. Every block was treated as JavaScript, so such a block was reported as TypeScript syntax errors. `lang="jsx"` blocks are now checked as JSX ([#57](https://github.com/elixir-volt/volt/issues/57)).
- Serve the `@oxc-project/runtime` helpers that the dev server's own output imports. With a `:target` below ES2022, a class with fields compiles to an import of such a helper, which `/@vendor/` answered with 404 unless the npm package was installed, so the importing module and everything importing it failed to load with no error shown. The helpers now come from the bundler, as in production builds ([#58](https://github.com/elixir-volt/volt/issues/58)).
- Type-check the scripts of `.vue` and `.svelte` files with the project's `tsconfig.json`. `mix volt.js.check --type-aware --type-check` checked them in a project of their own with default options, so imports through `paths` aliases and of other components failed with TS2307 even with a `declare module "*.vue"` declaration ([#60](https://github.com/elixir-volt/volt/issues/60)).
- Stop the dev server scanning sources and pre-bundling packages on every request. Phoenix initializes plugs per request in development, and one import that resolves to no package, such as one the page provides, made every request bundle all packages again, with concurrent requests replacing cache files others were reading. Pre-bundling now runs once and again after a source file changes, requests take turns, and unresolvable imports no longer count as stale ([#59](https://github.com/elixir-volt/volt/issues/59)).
- Resolve the dev server's paths from the directory Volt started in, not the current working directory. `Phoenix.CodeReloader` changes the VM's working directory while it compiles a reloadable path dependency, so a vendor request served at the same time wrote its cache into the dependency's `_build`, or failed with `could not write to file`. The vendor cache, asset root, watched directories and Tailwind input are affected ([#56](https://github.com/elixir-volt/volt/issues/56)).

## 0.20.0 - 2026-10-03

### Breaking changes
Expand Down
2 changes: 2 additions & 0 deletions lib/volt/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ defmodule Volt.Application do

@impl true
def start(_type, _args) do
Volt.Paths.capture_root()
Volt.Cache.create_table()
Volt.Dev.Assets.create_table()
Volt.HMR.ImportGraph.create_table()
Volt.HMR.GlobGraph.create_table()
Volt.HMR.StyleGraph.create_table()
Volt.HMR.ModuleGraph.create_table()
Volt.HMR.Errors.create_table()
Volt.Dev.Prebundled.create_table()

# Watchers release their Tailwind contexts when they terminate, so the
# Tailwind processes start before them and stop after them.
Expand Down
7 changes: 1 addition & 6 deletions lib/volt/builder/resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ defmodule Volt.Builder.Resolver do

# OXC's transform imports helpers from `@oxc-project/runtime`, and `OXC.bundle/2`
# provides them itself, so they never come from `node_modules`.
oxc_runtime_helper?(specifier) ->
Volt.JS.Specifier.oxc_runtime_helper?(specifier) ->
:skip

absolute?(specifier) ->
Expand All @@ -68,11 +68,6 @@ defmodule Volt.Builder.Resolver do
end
end

defp oxc_runtime_helper?(specifier),
do:
specifier == "@oxc-project/runtime" or
String.starts_with?(specifier, "@oxc-project/runtime/")

defp resolve_absolute(specifier) do
if File.exists?(specifier) do
{:ok, specifier}
Expand Down
2 changes: 1 addition & 1 deletion lib/volt/config/tailwind.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ defmodule Volt.Config.Tailwind do
name = overrides[:name] || config[:name] || entry_name(css)

%__MODULE__{
css: if(css, do: Path.expand(css)),
css: if(css, do: Volt.Paths.expand(css)),
name: name,
sources: overrides[:sources] || config[:sources] || [],
dev_url: overrides[:dev_url] || config[:dev_url] || default_dev_url(name)
Expand Down
9 changes: 5 additions & 4 deletions lib/volt/dev.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ defmodule Volt.Dev do
@doc "Start or reuse a managed watcher, rejecting conflicting configuration."
def start(opts) do
session = session_identity(opts)
root = opts |> Keyword.fetch!(:root) |> Path.expand()
root = opts |> Keyword.fetch!(:root) |> Volt.Paths.expand()
key = {:session, session}
opts = normalize_options(Keyword.put(opts, :session, session), root)

Expand Down Expand Up @@ -78,7 +78,8 @@ defmodule Volt.Dev do
def session_identity(opts) do
case Keyword.get(opts, :session, :default) do
:default ->
{:managed, Keyword.get(opts, :id, :default), Path.expand(Keyword.fetch!(opts, :root))}
{:managed, Keyword.get(opts, :id, :default),
Volt.Paths.expand(Keyword.fetch!(opts, :root))}

session ->
session
Expand Down Expand Up @@ -139,8 +140,8 @@ defmodule Volt.Dev do
|> Keyword.delete(:id)
|> Keyword.put(:root, root)
|> Keyword.put_new(:session, :default)
|> Keyword.update(:watch_dirs, [], &Enum.map(&1, fn path -> Path.expand(path) end))
|> Keyword.update(:reload_dirs, [], &Enum.map(&1, fn path -> Path.expand(path) end))
|> Keyword.update(:watch_dirs, [], &Enum.map(&1, fn path -> Volt.Paths.expand(path) end))
|> Keyword.update(:reload_dirs, [], &Enum.map(&1, fn path -> Volt.Paths.expand(path) end))
end

defp verify_configuration(supervisor, pid, opts) do
Expand Down
38 changes: 38 additions & 0 deletions lib/volt/dev/prebundled.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule Volt.Dev.Prebundled do
@moduledoc false
# Which sets of vendor options have been pre-bundled since the sources last
# changed.
#
# Phoenix initializes plugs on every request in development, so the dev
# server asks for a pre-bundle on every request. Scanning the sources each
# time is wasted work, and concurrent requests bundling at once would replace
# cache files that others are reading.

@table :volt_vendor_prebundled

def create_table, do: Volt.ETS.create_named_set(@table)

@doc """
Run `prebundle` unless it already ran for `opts` since `forget/0`. Callers
with the same options take turns, so one bundles and the rest find it done.
"""
@spec ensure(keyword(), (-> term())) :: :ok
def ensure(opts, prebundle) when is_function(prebundle, 0) do
:global.trans(
{{__MODULE__, opts}, self()},
fn ->
if :ets.lookup(@table, opts) == [] do
prebundle.()
Volt.ETS.put(@table, {opts})
end
end,
[node()]
)

:ok
end

@doc "Make `ensure/2` run again. A changed source may import a package that is not bundled yet."
@spec forget() :: :ok
def forget, do: Volt.ETS.clear(@table)
end
47 changes: 31 additions & 16 deletions lib/volt/dev_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ defmodule Volt.DevServer do
require Logger

alias Plug.Conn
alias Volt.{Config, URL}
alias Volt.{Config, Paths, URL}

@support_modules {:volt, "ts"}
@runtime_rewrites %{"../hmr" => "/@volt/client.js"}
Expand All @@ -47,7 +47,7 @@ defmodule Volt.DevServer do
server_config = Config.server(profile, build_opts)

root = Keyword.get(opts, :root) || to_string(config.root)
expanded_root = Path.expand(root)
expanded_root = Paths.expand(root)

node_modules = NPM.Resolution.PackageResolver.find_node_modules(expanded_root)
plugins = config.plugins
Expand All @@ -58,7 +58,19 @@ defmodule Volt.DevServer do
if Volt.Config.Tailwind.enabled?(tailwind_config),
do: Volt.Config.Tailwind.new(tailwind_config)

prebundle_vendor(expanded_root, node_modules, plugins, config.resolve_dirs, module_types)
# 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))

prebundle_vendor(
[
root: expanded_root,
node_modules: node_modules,
plugins: plugins,
resolve_dirs: config.resolve_dirs,
module_types: module_types
],
watched?
)

session =
if server_config.watch do
Expand All @@ -75,7 +87,7 @@ defmodule Volt.DevServer do
if server_config.watch and is_nil(Keyword.get(opts, :session_supervisor)) do
watch_dirs =
if tailwind_root && server_config.watch_dirs == [] do
[Volt.Paths.lib()]
[Paths.lib()]
else
server_config.watch_dirs
end
Expand Down Expand Up @@ -103,7 +115,11 @@ defmodule Volt.DevServer do
resolve_dirs: config.resolve_dirs,
module_types: module_types,
define:
Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix)
Volt.Env.define(
mode: "development",
root: Paths.root(),
env_prefix: config.env_prefix
)
]
end

Expand All @@ -121,7 +137,11 @@ defmodule Volt.DevServer do
resolve_dirs: config.resolve_dirs,
module_types: module_types,
define:
Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix),
Volt.Env.define(
mode: "development",
root: Paths.root(),
env_prefix: config.env_prefix
),
hmr_timeout: server_config.hmr_timeout,
morph: server_config.morph,
stylesheet_url: tailwind_root && tailwind_root.dev_url,
Expand Down Expand Up @@ -927,15 +947,10 @@ defmodule Volt.DevServer do

# ── Vendor pre-bundling ───────────────────────────────────────────

defp prebundle_vendor(root, node_modules, plugins, resolve_dirs, module_types) do
Volt.JS.Vendor.prebundle(
root: root,
node_modules: node_modules,
plugins: plugins,
resolve_dirs: resolve_dirs,
module_types: module_types
)
end
defp prebundle_vendor(opts, true),
do: Volt.Dev.Prebundled.ensure(opts, fn -> Volt.JS.Vendor.prebundle(opts) end)

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

defp serve_vendor(specifier, config, browser_hash) do
vendor_opts = vendor_opts(config)
Expand Down Expand Up @@ -992,7 +1007,7 @@ defmodule Volt.DevServer do
# any script in this response could run.
defp send_compile_error(conn, path, errors, config) do
Volt.HMR.error(path, errors, session: config.session)
message = "[Volt] Could not compile #{Path.relative_to_cwd(path)}"
message = "[Volt] Could not compile #{Path.relative_to(path, Paths.root())}"

conn
|> Conn.put_resp_content_type(Volt.MIME.javascript())
Expand Down
5 changes: 4 additions & 1 deletion lib/volt/js/check.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ defmodule Volt.JS.Check do
[
type_aware: true,
type_check: opts[:type_check] == true,
source_overrides: Map.merge(source_overrides, Keyword.get(config, :source_overrides, %{}))
source_overrides:
source_overrides
|> Map.merge(Volt.JS.Lint.TSConfigOverlay.overrides(Map.keys(source_overrides)))
|> Map.merge(Keyword.get(config, :source_overrides, %{}))
] ++ type_aware_options(config)

files
Expand Down
99 changes: 99 additions & 0 deletions lib/volt/js/lint/tsconfig_overlay.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
defmodule Volt.JS.Lint.TSConfigOverlay do
@moduledoc """
Puts virtual modules, such as the scripts of a `.vue` file, into the TypeScript
project that covers them.

tsgolint assigns a file to a project by the nearest `tsconfig.json` that
includes it, and expands `include` by listing directories on disk. A virtual
module is not on disk, so it lands in an inferred project with default
options: the project's `paths` do not apply to it, and the declaration files
the project includes are not in its program.

tsgolint does read file contents, and a `tsconfig.json` among them, through
the same overrides that carry the virtual modules. So the nearest
`tsconfig.json` is overridden with one that extends the original and names
the virtual modules in `files`. The original is carried unchanged under
another name beside it, so relative paths in it keep their meaning.
"""

@base "tsconfig.volt-base.json"

@doc """
Return the source overrides that add `virtual_files` to their projects.

A virtual module without a `tsconfig.json` above it, or whose `tsconfig.json`
cannot be read as an object, is left to the inferred project.
"""
@spec overrides([String.t()]) :: %{String.t() => String.t()}
def overrides(virtual_files) do
virtual_files
|> Enum.group_by(&nearest_tsconfig(Path.dirname(&1)))
|> Enum.flat_map(fn
{nil, _files} -> []
{tsconfig, files} -> project(tsconfig, Enum.sort(files))
end)
|> Map.new()
end

defp nearest_tsconfig(dir) do
tsconfig = Path.join(dir, "tsconfig.json")
parent = Path.dirname(dir)

cond do
File.regular?(tsconfig) -> tsconfig
parent == dir -> nil
true -> nearest_tsconfig(parent)
end
end

defp project(tsconfig, virtual_files) do
with {:ok, source} <- File.read(tsconfig),
{:ok, properties} <- properties(source) do
[
{Path.join(Path.dirname(tsconfig), @base), source},
{tsconfig, Jason.encode!(extending(properties, virtual_files))}
]
else
_unreadable -> []
end
end

# `files` in a config replaces the `files` of the config it extends, and a
# config with `files` and no `include` includes nothing else. So the original's
# own `files` are repeated, and the default `include` is spelled out when the
# original relied on it.
defp extending(properties, virtual_files) do
config = %{
"extends" => "./" <> @base,
"files" => string_elements(properties["files"]) ++ virtual_files
}

if Enum.any?(["files", "include", "extends"], &Map.has_key?(properties, &1)),
do: config,
else: Map.put(config, "include", ["**/*"])
end

# A tsconfig may have comments and trailing commas, which makes it a
# JavaScript object literal rather than JSON.
defp properties(source) do
case OXC.parse("(" <> source <> "\n)", "tsconfig.js") do
{:ok, %{body: [%{expression: %{expression: %{type: :object_expression} = object}}]}} ->
properties =
for %{key: key, value: value} <- object.properties,
{:ok, name} <- [Volt.JS.AST.property_key(key)],
into: %{},
do: {name, value}

{:ok, properties}

_other ->
:error
end
end

defp string_elements(%{type: :array_expression, elements: elements}) do
for %{type: :literal, value: value} <- elements, is_binary(value), do: value
end

defp string_elements(_absent), do: []
end
13 changes: 13 additions & 0 deletions lib/volt/js/specifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ defmodule Volt.JS.Specifier do

def split_query(specifier), do: Volt.URL.split_query(specifier)

@oxc_runtime "@oxc-project/runtime"

@doc """
Return whether a specifier names a helper from `@oxc-project/runtime`.

OXC's transform imports these when it lowers syntax for a target, such as
class fields below ES2022. `OXC.bundle/2` provides them itself, so they are
never resolved from `node_modules`.
"""
@spec oxc_runtime_helper?(String.t()) :: boolean()
def oxc_runtime_helper?(specifier),
do: specifier == @oxc_runtime or String.starts_with?(specifier, @oxc_runtime <> "/")

defp split_on_query(specifier) do
case String.split(specifier, "?", parts: 2) do
[path, query] -> {path, query}
Expand Down
Loading
Loading