From 62efc082f9390b99c4cac6c32cce741a318700b7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 12:57:04 +0400 Subject: [PATCH 01/13] Fix dev reload loop, CommonJS and vendor interop; move formatter options Dev server and watcher: - Compare file contents before rebuilding, so a file rewritten with identical content (LiveView colocated hooks on every code reload) no longer reloads the page in a loop. - Resolve relative watch_ignored patterns from the project directory as well as each watch root. - Convert local CommonJS/UMD files such as vendor/topbar.js to ES modules in development. - Append the browser hash to relative imports inside served pre-bundles, so a package reached from application code and from another pre-bundle is a single module instance. Vue was loaded twice before. Build: - Skip tsconfig path mappings that only point at declaration files. - Keep external modules as imports in :esm and :cjs output, as Rollup does. Only IIFE output reads them from globals. (Breaking.) Formatter: - Read formatter options from the :volt key of .formatter.exs instead of config :volt, :format, which now only holds the build output format. A keyword list there raises with the options to move. The installer, guides, examples and Volt's own config follow. (Breaking.) --- .formatter.exs | 10 +- CHANGELOG.md | 27 ++++ README.md | 6 +- config/config.exs | 8 -- examples/react/.formatter.exs | 3 +- examples/react/config/config.exs | 4 - examples/solid/.formatter.exs | 3 +- examples/solid/config/config.exs | 4 - examples/svelte/.formatter.exs | 3 +- examples/svelte/config/config.exs | 4 - examples/vanilla/.formatter.exs | 3 +- examples/vanilla/config/config.exs | 4 - examples/vue/.formatter.exs | 3 +- examples/vue/config/config.exs | 4 - guides/cheatsheets/configuration.cheatmd | 14 +- guides/deployment/production-builds.md | 14 ++ guides/features/features.md | 2 + guides/features/formatting-and-linting.md | 33 +++-- guides/introduction/getting-started.md | 7 +- lib/mix/tasks/volt/build.ex | 3 +- lib/mix/tasks/volt/install.ex | 41 +++--- lib/mix/tasks/volt/js/check.ex | 2 +- lib/mix/tasks/volt/js/format.ex | 5 +- lib/volt/builder.ex | 46 +++--- lib/volt/builder/externals.ex | 2 +- lib/volt/builder/output.ex | 19 ++- lib/volt/builder/output_context.ex | 1 + lib/volt/builder/rewriter.ex | 22 +++ lib/volt/config.ex | 12 +- lib/volt/dev_server.ex | 132 ++++++++++++------ lib/volt/formatter.ex | 13 +- lib/volt/js/common_js.ex | 92 ++++++++++++ lib/volt/js/discovery.ex | 8 +- lib/volt/js/format.ex | 65 ++++++++- lib/volt/js/tsconfig.ex | 35 +++-- lib/volt/watcher.ex | 55 +++++++- lib/volt/watcher/ignore.ex | 8 +- test/mix/tasks/volt/install_test.exs | 10 ++ test/volt/builder/resolution_test.exs | 64 +++++++++ test/volt/dev_server/basic_test.exs | 11 +- .../volt/dev_server/import_rewriting_test.exs | 44 ++++++ test/volt/js/discovery_test.exs | 24 ++-- test/volt/js/format_test.exs | 55 ++++++-- test/volt/js/tsconfig_test.exs | 17 +++ test/volt/watcher_test.exs | 54 +++++++ 45 files changed, 791 insertions(+), 205 deletions(-) create mode 100644 lib/volt/js/common_js.ex diff --git a/.formatter.exs b/.formatter.exs index d2cda26..55a8b68 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,12 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + volt: [ + trailing_comma: :none, + tab_width: 2, + semi: false, + single_quote: true, + print_width: 100, + arrow_parens: :always + ] ] diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ec343..da0d118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## Unreleased + +### Breaking changes + +- Formatter options moved from `config :volt, :format` to the `:volt` key of `.formatter.exs`, where `mix format` plugins keep their options. `config :volt, :format` now only holds the build output format (`:iife`, `:esm`, or `:cjs`), so both can be set. A keyword list under `config :volt, :format` raises an `ArgumentError` with the options to move: + + # .formatter.exs + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true] + ] + + The formatting-only `:root`, `:sources`, and `:ignore` overrides for `mix volt.js.format` and `mix volt.js.check` move to the same key. `.oxfmtrc.json` and `.prettierrc.json` are still read when there is no `:volt` key. `mix igniter.install volt` writes the options to `.formatter.exs`. +- External modules stay as imports in `:esm` and `:cjs` output, as in Rollup, Rolldown, esbuild and Bun. With `format: :esm, external: ["phoenix"]`, `import { Socket } from "phoenix"` used to become `const { Socket } = Phoenix;`; it is now left for the browser or host to resolve, for example through an import map. `:cjs` output uses `require("phoenix")`. IIFE output, the default, still reads externals from globals. The global names in `external: %{"phoenix" => "Phoenix"}` apply to IIFE output only. ESM builds that relied on page globals need an import map or `format: :iife`. +- `Volt.JS.Format.load_config/1` takes the `.formatter.exs` options instead of reading the application environment. + +### Added + +- The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. +- Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`. + +### Fixed + +- Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding. +- Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. +- Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. + ## 0.19.4 - 2026-10-03 ### Compatibility diff --git a/README.md b/README.md index b7ab98e..c1b5733 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,11 @@ JS/TS formatting, linting, and testing run inside the BEAM. `mix format` handles ```elixir # .formatter.exs -[plugins: [Volt.Formatter], inputs: ["assets/**/*.{js,ts,jsx,tsx}"]] +[ + plugins: [Volt.Formatter], + inputs: ["assets/**/*.{js,ts,jsx,tsx}"], + volt: [semi: false, single_quote: true] +] ``` ```bash diff --git a/config/config.exs b/config/config.exs index 98e14d3..f97b032 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,13 +1,5 @@ import Config -config :volt, :format, - trailing_comma: :none, - tab_width: 2, - semi: false, - single_quote: true, - print_width: 100, - arrow_parens: :always - config :volt, :lint, plugins: ["typescript", "import", "unicorn"], rules: %{ diff --git a/examples/react/.formatter.exs b/examples/react/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/react/.formatter.exs +++ b/examples/react/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/react/config/config.exs b/examples/react/config/config.exs index 8c8f13d..e8fd363 100644 --- a/examples/react/config/config.exs +++ b/examples/react/config/config.exs @@ -33,10 +33,6 @@ config :volt, ], import_source: "react" -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript", "react"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/solid/.formatter.exs b/examples/solid/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/solid/.formatter.exs +++ b/examples/solid/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/solid/config/config.exs b/examples/solid/config/config.exs index 595d9f0..af08475 100644 --- a/examples/solid/config/config.exs +++ b/examples/solid/config/config.exs @@ -33,10 +33,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/svelte/.formatter.exs b/examples/svelte/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/svelte/.formatter.exs +++ b/examples/svelte/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/svelte/config/config.exs b/examples/svelte/config/config.exs index e367836..5cea6b3 100644 --- a/examples/svelte/config/config.exs +++ b/examples/svelte/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/vanilla/.formatter.exs b/examples/vanilla/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/vanilla/.formatter.exs +++ b/examples/vanilla/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index aba95e3..82f1b8d 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), diff --git a/examples/vue/.formatter.exs b/examples/vue/.formatter.exs index a0877aa..daa575d 100644 --- a/examples/vue/.formatter.exs +++ b/examples/vue/.formatter.exs @@ -5,5 +5,6 @@ "*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] diff --git a/examples/vue/config/config.exs b/examples/vue/config/config.exs index e27ff43..85409ab 100644 --- a/examples/vue/config/config.exs +++ b/examples/vue/config/config.exs @@ -32,10 +32,6 @@ config :volt, ] ] -config :volt, :format, - semi: false, - single_quote: true - config :volt, :lint, plugins: ["typescript", "vue"], tsgolint: System.find_executable("tsgolint"), diff --git a/guides/cheatsheets/configuration.cheatmd b/guides/cheatsheets/configuration.cheatmd index e25567b..ae2a457 100644 --- a/guides/cheatsheets/configuration.cheatmd +++ b/guides/cheatsheets/configuration.cheatmd @@ -121,7 +121,7 @@ Prefix or list of prefixes exposed through `import.meta.env`. config :volt, external: ~w(phoenix phoenix_html) ``` -Exclude packages from the bundle. +Exclude packages from the bundle. IIFE output reads them from globals; `:esm` and `:cjs` keep the imports. ### Aliases @@ -259,34 +259,36 @@ The dev-server Plug starts a supervised watcher on the first request. Set this t ## Formatting Options {: .col-2} +Set under the `:volt` key of `.formatter.exs`. + ### Semi ```elixir -config :volt, :format, semi: false +[volt: [semi: false]] ``` ### Single Quote ```elixir -config :volt, :format, single_quote: true +[volt: [single_quote: true]] ``` ### Print Width ```elixir -config :volt, :format, print_width: 100 +[volt: [print_width: 100]] ``` ### Trailing Comma ```elixir -config :volt, :format, trailing_comma: :none +[volt: [trailing_comma: :none]] ``` ### Arrow Parens ```elixir -config :volt, :format, arrow_parens: :always +[volt: [arrow_parens: :always]] ``` ## Lint Options diff --git a/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index a9325da..0561274 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -136,6 +136,20 @@ config :volt, external: ~w(phoenix phoenix_html phoenix_live_view) Or per-build: `mix volt.build --external phoenix --external phoenix_html` +How an external is referenced depends on the output format, as in Rollup: + +| Format | Output for `import { Socket } from "phoenix"` | +| --- | --- | +| `:iife` (default) | `const { Socket } = Phoenix;` — read from a global the page provides | +| `:esm` | `import { Socket } from "phoenix";` — resolved by the browser, for example through an import map | +| `:cjs` | `require("phoenix")` | + +For IIFE output the global name is derived from the specifier (`phoenix_html` becomes `PhoenixHtml`). Pass a map to name the globals yourself; the names are ignored for `:esm` and `:cjs`: + +```elixir +config :volt, external: %{"phoenix" => "Phoenix", "vue" => "Vue"} +``` + ## Module Preloading For code-split builds, the production manifest records static imports, dynamic imports, chunk-local CSS, and emitted assets. Use `Volt.Preload.tags/2` in your layout to preload the entry and its static chunk dependencies: diff --git a/guides/features/features.md b/guides/features/features.md index 1867953..cbf00f4 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -176,6 +176,8 @@ Exclude packages the host page already provides: config :volt, external: ~w(phoenix phoenix_html phoenix_live_view) ``` +IIFE output reads externals from globals; `:esm` and `:cjs` output keeps them as imports. See [Production Builds](../deployment/production-builds.md#external-modules). + ## Source Maps Production builds write `.map` files by default. Use `sourcemap: :hidden` to write maps without the URL comment (for Sentry, Datadog, etc.), or `sourcemap: false` to skip. diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index bd63492..3935bed 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -25,18 +25,35 @@ mix volt.js.format ### Configuration +Formatter options live under the `:volt` key of `.formatter.exs`, next to the plugin: + ```elixir -config :volt, :format, - print_width: 100, - semi: false, - single_quote: true, - trailing_comma: :none, - arrow_parens: :always +[ + plugins: [Volt.Formatter], + inputs: ["{config,lib,test}/**/*.{ex,exs}", "assets/**/*.{js,ts,jsx,tsx}"], + volt: [ + print_width: 100, + semi: false, + single_quote: true, + trailing_comma: :none, + arrow_parens: :always + ] +] ``` -All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Falls back to `.oxfmtrc.json` if no Elixir config is set. +All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Without a `:volt` key, options come from `.oxfmtrc.json` or `.prettierrc.json`. `mix format`, `mix volt.js.format`, and `mix volt.js.check` all read the same options. + +`mix format` formats the files matched by `:inputs`. `mix volt.js.format` and `mix volt.js.check` use the build source set; `:root`, `:sources`, and `:ignore` under the `:volt` key override it for formatting only: + +```elixir +[ + volt: [semi: false, sources: ["priv/ts/**/*.ts"], ignore: ["vendor/**"]] +] +``` -`:root`, `:sources`, and `:ignore` may also be set under `config :volt, :format` to override the build source set for formatting only. +> #### Upgrading {: .info} +> +> Formatter options used to live in `config :volt, :format`. That key now only holds the build output format (`:iife`, `:esm`, or `:cjs`), and a keyword list there raises with instructions to move it. ## Linting diff --git a/guides/introduction/getting-started.md b/guides/introduction/getting-started.md index a9695ac..e588964 100644 --- a/guides/introduction/getting-started.md +++ b/guides/introduction/getting-started.md @@ -9,8 +9,8 @@ mix igniter.install volt The installer: - Adds `{:volt, "~> 0.19"}` to `mix.exs` - Configures build settings in `config/config.exs` -- Adds format and lint config to `config/config.exs` -- Adds `Volt.Formatter` plugin to `.formatter.exs` +- Adds lint config to `config/config.exs` +- Adds the `Volt.Formatter` plugin and formatter options to `.formatter.exs` - Adds the `Volt.DevServer` plug to your endpoint - Configures Volt's automatic development watcher - Updates `assets.build` and `assets.deploy` aliases @@ -107,7 +107,8 @@ Add `Volt.Formatter` to `.formatter.exs`: "{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "assets/**/*.{js,ts,jsx,tsx}" - ] + ], + volt: [semi: false, single_quote: true] ] ``` diff --git a/lib/mix/tasks/volt/build.ex b/lib/mix/tasks/volt/build.ex index be1f616..099df16 100644 --- a/lib/mix/tasks/volt/build.ex +++ b/lib/mix/tasks/volt/build.ex @@ -23,7 +23,8 @@ defmodule Mix.Tasks.Volt.Build do * `--sourcemap false` — skip source map generation * `--sourcemap hidden` — write `.map` files but omit `sourceMappingURL` comment * `--resolve-dir` — additional directory for bare specifier resolution (repeatable) - * `--external` — specifier to exclude from bundle (repeatable) + * `--external` — specifier to exclude from bundle (repeatable); read from a global + in `iife` output and kept as an import in `esm` and `cjs` output * `--name` — output base name (default: derived from entry filename) * `--no-hash` — stable filenames (no content hash) * `--no-code-splitting` — disable chunk splitting diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index 64bf414..b5bce35 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -17,8 +17,8 @@ if Code.ensure_loaded?(Igniter) do 2. Remove `config :esbuild` and `config :tailwind` blocks 3. Update `assets.setup`, `assets.build`, and `assets.deploy` aliases 4. Add Volt build config to `config/config.exs` - 5. Add format and lint config to `config/config.exs` - 6. Add `Volt.Formatter` plugin to `.formatter.exs` + 5. Add lint config to `config/config.exs` + 6. Add `Volt.Formatter` plugin and formatter options to `.formatter.exs` 7. Add `Volt.DevServer` plug to your endpoint 8. Configure the automatic Volt watcher in `config/dev.exs` 9. Wire Volt client types into TypeScript configuration @@ -44,6 +44,7 @@ if Code.ensure_loaded?(Igniter) do alias Igniter.Project.Config, as: ProjectConfig alias Igniter.Project.Deps, as: ProjectDeps alias Igniter.Project.Formatter, as: ProjectFormatter + alias Sourceror.Zipper alias Volt.Paths alias Igniter.Project.TaskAliases @@ -66,9 +67,9 @@ if Code.ensure_loaded?(Igniter) do |> remove_old_watchers(app_name, endpoint) |> update_aliases() |> add_volt_config() - |> add_format_config() |> add_lint_config() |> add_formatter_plugin() + |> add_format_config() |> add_typescript_config() |> add_dev_config(app_name, endpoint) |> add_dev_server_plug() @@ -235,19 +236,27 @@ if Code.ensure_loaded?(Igniter) do opts ) - code = - format_kw - |> Enum.map(fn {key, value} -> [to_string(key), ": ", inspect(value)] end) - |> Enum.intersperse(",\n ") - |> IO.iodata_to_binary() - - ProjectConfig.configure( - igniter, - "config.exs", - :volt, - [:format], - {:code, Sourceror.parse_string!("[\n #{code}\n]")} - ) + # Runs after `add_formatter_plugin/1`, which creates `.formatter.exs`. + Igniter.update_elixir_file(igniter, ".formatter.exs", fn zipper -> + zipper + |> Zipper.down() + |> Zipper.rightmost() + |> CodeKeyword.put_in_keyword([:volt], format_kw, &{:ok, &1}) + |> case do + {:ok, zipper} -> + {:ok, zipper} + + _ -> + {:warning, + """ + Could not add Volt formatter options to `.formatter.exs`. + + Please add them manually, i.e + + volt: #{inspect(format_kw)} + """} + end + end) end defp add_lint_config(igniter) do diff --git a/lib/mix/tasks/volt/js/check.ex b/lib/mix/tasks/volt/js/check.ex index 04efd22..8b0bbd5 100644 --- a/lib/mix/tasks/volt/js/check.ex +++ b/lib/mix/tasks/volt/js/check.ex @@ -9,7 +9,7 @@ defmodule Mix.Tasks.Volt.Js.Check do mix volt.js.check mix volt.js.check --type-aware --type-check - Reads format options from `config :volt, :format` (falls back to `.oxfmtrc.json`). + Reads format options from the `:volt` key of `.formatter.exs` (falls back to `.oxfmtrc.json`). Lint settings come from `config :volt, :lint`. File discovery uses `config :volt, sources:` and `ignore:`. diff --git a/lib/mix/tasks/volt/js/format.ex b/lib/mix/tasks/volt/js/format.ex index d6889bf..d08e711 100644 --- a/lib/mix/tasks/volt/js/format.ex +++ b/lib/mix/tasks/volt/js/format.ex @@ -8,8 +8,9 @@ defmodule Mix.Tasks.Volt.Js.Format do mix volt.js.format - Reads options from `config :volt, :format`. Falls back to `.oxfmtrc.json`. - File discovery uses `config :volt, sources:` and `ignore:`. + Reads options from the `:volt` key of `.formatter.exs`. Falls back to `.oxfmtrc.json`. + File discovery uses `config :volt, sources:` and `ignore:`, which `:root`, + `:sources`, and `:ignore` under the same `:volt` key override. No Node.js required. """ diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 65e3ffc..d3dfb84 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -46,8 +46,10 @@ defmodule Volt.Builder do chunks: %{"vendor" => ["vue", "vue-router"], "ui" => ["assets/src/components"]} * `:write_manifest` — write `manifest.json` after building (default: `true`) - * `:external` — specifiers to exclude from the bundle and access as globals. - Accepts a list (global name auto-derived) or a map of `specifier => global_name`: + * `:external` — specifiers to exclude from the bundle. With `format: :iife` + they are read from global variables; with `:esm` and `:cjs` the imports + stay in the output. Accepts a list (global name auto-derived) or a map of + `specifier => global_name`, whose names apply to `:iife` only: external: ["vue", "phoenix"] external: %{"vue" => "Vue", "phoenix" => "Phoenix"} @@ -121,11 +123,10 @@ defmodule Volt.Builder do rewrite_nonlocal_labels(compiled, collected.specifier_labels, collected.path_labels) output_ctx = %Volt.Builder.OutputContext{ - plugins: ctx.plugins, - external_set: ctx.external, - external_globals: ctx.external_globals, - workers: collected.workers, - worker_results: worker_results + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: collected.workers, + worker_results: worker_results } out = %Volt.Builder.BuildContext{ @@ -246,11 +247,10 @@ defmodule Volt.Builder do compiled = rewrite_nonlocal_labels(compiled, specifier_labels, path_labels) output_ctx = %Volt.Builder.OutputContext{ - plugins: ctx.plugins, - external_set: ctx.external, - external_globals: ctx.external_globals, - workers: workers, - worker_results: %{} + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: workers, + worker_results: %{} } out = %Volt.Builder.BuildContext{ @@ -309,11 +309,10 @@ defmodule Volt.Builder do compiled = %{compiled | artifacts: compiled.artifacts ++ worker_artifacts} output_ctx = %Volt.Builder.OutputContext{ - plugins: ctx.plugins, - external_set: ctx.external, - external_globals: ctx.external_globals, - workers: workers, - worker_results: worker_results + output_externals(ctx, build_ctx) + | plugins: ctx.plugins, + workers: workers, + worker_results: worker_results } out = %Volt.Builder.BuildContext{ @@ -915,6 +914,19 @@ defmodule Volt.Builder do |> Enum.sort_by(fn {source_root, _package_dir} -> -byte_size(source_root) end) end + # IIFE bundles read externals from global variables. ES module and CommonJS + # output keeps them as imports for the host to resolve, as Rollup does. + defp output_externals(ctx, build_ctx) do + if Keyword.get(build_ctx.bundle_opts, :format, :iife) == :iife do + %Volt.Builder.OutputContext{ + external_set: ctx.external, + external_globals: ctx.external_globals + } + else + %Volt.Builder.OutputContext{external_imports: ctx.external} + end + end + defp normalize_external(externals) when is_map(externals) do set = externals |> Map.keys() |> MapSet.new() {set, externals} diff --git a/lib/volt/builder/externals.ex b/lib/volt/builder/externals.ex index e8f0a94..419055d 100644 --- a/lib/volt/builder/externals.ex +++ b/lib/volt/builder/externals.ex @@ -1,5 +1,5 @@ defmodule Volt.Builder.Externals do - @moduledoc "Rewrite external imports into global variable access for production builds." + @moduledoc "Rewrite external imports into global variable access for IIFE production builds." @doc """ Rewrite external import declarations into direct global access. diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 5ce655e..f2423ba 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -20,7 +20,11 @@ defmodule Volt.Builder.Output do js_files = Rewriter.rewrite_external_imports(js_files, ctx) entry_label = entry |> Path.basename() |> Naming.file_path() entry_name = Path.rootname(entry_label) - bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) + + bundle_opts = + bundle_opts + |> Keyword.put(:entry, entry_label) + |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> @@ -90,7 +94,11 @@ defmodule Volt.Builder.Output do js_files = Rewriter.rewrite_external_imports(js_files, ctx) entry_label = entry |> Path.basename() |> Naming.file_path() - bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) + + bundle_opts = + bundle_opts + |> Keyword.put(:entry, entry_label) + |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> @@ -176,7 +184,11 @@ defmodule Volt.Builder.Output do sourcemap: Keyword.get(bundle_opts, :sourcemap, false), treeshake: Keyword.get(bundle_opts, :treeshake, false), define: Keyword.get(bundle_opts, :define, %{}), - external: Keyword.get(bundle_opts, :external, []), + external: + Enum.uniq( + Keyword.get(bundle_opts, :external, []) ++ + Rewriter.preserved_external_imports(js_files, ctx) + ), module_types: Keyword.get(bundle_opts, :module_types, %{}), output: [ entry_file_names: shared_entry_file_names(build_ctx.hash), @@ -793,6 +805,7 @@ defmodule Volt.Builder.Output do bundle_opts |> Keyword.put(:entry, chunk_entry_label(chunk_js)) |> put_external_imports(external) + |> put_external_imports(Rewriter.preserved_external_imports(chunk_js, ctx)) case bundle_js_files(chunk_js, bundle_opts) do {:ok, result} -> diff --git a/lib/volt/builder/output_context.ex b/lib/volt/builder/output_context.ex index 1cc6d7e..db285e4 100644 --- a/lib/volt/builder/output_context.ex +++ b/lib/volt/builder/output_context.ex @@ -4,6 +4,7 @@ defmodule Volt.Builder.OutputContext do defstruct plugins: [], external_set: MapSet.new(), external_globals: %{}, + external_imports: MapSet.new(), workers: %{}, worker_results: %{} end diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index b856944..8379021 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -14,6 +14,28 @@ defmodule Volt.Builder.Rewriter do end end + @doc "Specifiers imported by `js_files` that stay as imports in the bundle output." + def preserved_external_imports(js_files, ctx) do + if MapSet.size(ctx.external_imports) == 0 do + [] + else + js_files + |> Enum.flat_map(fn {_label, code} -> + case OXC.select(code, "module.js", :import_specifiers) do + {:ok, specifiers} -> specifiers + {:error, _} -> [] + end + end) + |> Enum.uniq() + |> Enum.filter(&preserved_external?(&1, ctx.external_imports)) + end + end + + defp preserved_external?(specifier, externals) do + MapSet.member?(externals, specifier) or + Enum.any?(externals, &String.starts_with?(specifier, &1 <> "/")) + end + def external_chunk_imports(js_files, chunk_import_map) do js_files |> Enum.flat_map(fn {_label, code} -> diff --git a/lib/volt/config.ex b/lib/volt/config.ex index 97a70d3..ab15e8d 100644 --- a/lib/volt/config.ex +++ b/lib/volt/config.ex @@ -130,20 +130,18 @@ defmodule Volt.Config do @spec build(atom() | nil, keyword()) :: map() def build(profile, overrides) do - flat_env = - Application.get_all_env(:volt) - |> Keyword.take(@build_keys) - |> Keyword.reject(fn {k, v} -> k == :format and not is_atom(v) end) + flat_env = Application.get_all_env(:volt) |> Keyword.take(@build_keys) profile_env = if profile do - Application.get_env(:volt, profile, []) - |> Keyword.take(@build_keys) - |> Keyword.reject(fn {k, v} -> k == :format and not is_atom(v) end) + Application.get_env(:volt, profile, []) |> Keyword.take(@build_keys) else [] end + Volt.JS.Format.reject_application_config!(flat_env[:format]) + Volt.JS.Format.reject_application_config!(profile_env[:format]) + config = @defaults |> Map.from_struct() diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index d327d91..624307a 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -472,52 +472,80 @@ defmodule Volt.DevServer do file_path = Volt.Plugin.EmbeddedModule.parent_path(module_id) source = File.read!(file_path) - case Volt.Pipeline.compile(module_id, source, pipeline_opts(config, module_id)) do - {:ok, result} -> - Volt.HMR.clear_error(file_path, session: config.session) - Volt.HMR.GlobGraph.update_from_source(file_path, source, config.tables || config.session) - - Volt.HMR.ImportGraph.update_from_compiled( - file_path, - result.code, - config.tables || config.session - ) - - Volt.HMR.StyleDependencies.update_from_compile( - file_path, - source, - result, - config.tables || config.session - ) - - result = rewrite_dev_css_urls(result, file_path, config) - mod_url = Volt.URL.join(config.prefix, relative) - code = code_for_request(result, mod_url, content_type, css_import?) - graph_url = if css_import?, do: URL.append_query(mod_url, "import"), else: mod_url - - update_module_graph( - graph_url, - graph_url, - file_path, - code, - source, - content_type, - config.tables || config.session - ) + with {:ok, module_source, cacheable?} <- module_source(file_path, source, config), + {:ok, result} <- + Volt.Pipeline.compile(module_id, module_source, pipeline_opts(config, module_id)) do + Volt.HMR.clear_error(file_path, session: config.session) + Volt.HMR.GlobGraph.update_from_source(file_path, source, config.tables || config.session) + + Volt.HMR.ImportGraph.update_from_compiled( + file_path, + result.code, + config.tables || config.session + ) + + Volt.HMR.StyleDependencies.update_from_compile( + file_path, + source, + result, + config.tables || config.session + ) + + result = rewrite_dev_css_urls(result, file_path, config) + mod_url = Volt.URL.join(config.prefix, relative) + code = code_for_request(result, mod_url, content_type, css_import?) + graph_url = if css_import?, do: URL.append_query(mod_url, "import"), else: mod_url + + update_module_graph( + graph_url, + graph_url, + file_path, + code, + source, + content_type, + config.tables || config.session + ) + + entry = %Volt.DevServer.CacheEntry{ + code: code, + sourcemap: result.sourcemap, + css: result.css, + hashes: result.hashes, + content_type: content_type + } + + if cacheable?, + do: Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) + + send_compiled(conn, code, result.sourcemap, content_type) + else + {:error, errors} -> + send_compile_error(conn, file_path, errors, config) + end + end - entry = %Volt.DevServer.CacheEntry{ - code: code, - sourcemap: result.sourcemap, - css: result.css, - hashes: result.hashes, - content_type: content_type - } + # CommonJS/UMD files are converted to ES modules, with the modules they + # require bundled in. Those are not tracked for invalidation, so a module + # that requires others is rebuilt on every request instead of cached. + defp module_source(file_path, source, config) do + if Volt.JS.CommonJS.commonjs?(source, file_path) do + opts = [ + modules: Enum.reject([config.node_modules | config.resolve_dirs], &is_nil/1), + module_types: config.module_types + ] - Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) - send_compiled(conn, code, result.sourcemap, content_type) + with {:ok, code} <- Volt.JS.CommonJS.to_esm(file_path, opts) do + {:ok, code, not requires_modules?(source, file_path)} + end + else + {:ok, source, true} + end + end - {:error, errors} -> - send_compile_error(conn, file_path, errors, config) + defp requires_modules?(source, file_path) do + case OXC.select(source, Path.basename(file_path), :require_calls) do + {:ok, []} -> false + _ -> true end end @@ -908,12 +936,28 @@ defmodule Volt.DevServer do vendor_opts = vendor_opts(config) if Volt.JS.Vendor.current_browser_hash?(browser_hash, vendor_opts) do - read_or_bundle_vendor(specifier, config, vendor_opts) + with {:ok, code} <- read_or_bundle_vendor(specifier, config, vendor_opts) do + {:ok, version_vendor_imports(code, Volt.JS.Vendor.browser_hash(vendor_opts))} + end else {:error, :outdated} end end + # Pre-bundles import their siblings and shared chunks relatively. Application + # modules import the same files with the browser hash, and a browser treats + # URLs that differ only in their query as separate module instances. + defp version_vendor_imports(code, browser_hash) do + case OXC.rewrite_specifiers(code, "vendor.js", fn specifier -> + if NPM.Resolution.PackageResolver.relative?(specifier), + do: {:rewrite, URL.append_query(specifier, "v=#{browser_hash}")}, + else: :keep + end) do + {:ok, rewritten} -> rewritten + {:error, _} -> code + end + end + defp read_or_bundle_vendor(specifier, config, vendor_opts) do case Volt.JS.Vendor.read(specifier, vendor_opts) do {:ok, _} = ok -> diff --git a/lib/volt/formatter.ex b/lib/volt/formatter.ex index c57acb9..bd09fb3 100644 --- a/lib/volt/formatter.ex +++ b/lib/volt/formatter.ex @@ -17,8 +17,15 @@ defmodule Volt.Formatter do ## Configuration - Reads options from `config :volt, :format` or falls back to - `.oxfmtrc.json` / `.prettierrc.json`. See the "Formatter & linter configuration" section in the README for details. + Set oxfmt options under the `:volt` key of the same file: + + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true, print_width: 100] + ] + + Without that key, options come from `.oxfmtrc.json` / `.prettierrc.json`. + See the "Formatting and Linting" guide for details. """ @behaviour Mix.Tasks.Format @@ -31,7 +38,7 @@ defmodule Volt.Formatter do @impl true def format(contents, opts) do filename = opts[:file] || extension_to_filename(opts[:extension]) || "input.ts" - format_opts = Volt.JS.Format.load_config() + format_opts = Volt.JS.Format.load_config(opts) OXC.Format.run!(contents, filename, format_opts) end diff --git a/lib/volt/js/common_js.ex b/lib/volt/js/common_js.ex new file mode 100644 index 0000000..57c782d --- /dev/null +++ b/lib/volt/js/common_js.ex @@ -0,0 +1,92 @@ +defmodule Volt.JS.CommonJS do + @moduledoc """ + Detects local CommonJS/UMD modules and converts them to ES modules for the dev server. + + Production builds hand CommonJS files to the bundler, which wraps them itself. + The dev server serves each file as a native ES module, so a CommonJS file such + as Phoenix's vendored `topbar.js` needs the same interop before a browser can + `import topbar from "../vendor/topbar"`. + """ + + @extensions ~w(.js .cjs) + @esm_declarations [ + :import_declaration, + :export_named_declaration, + :export_default_declaration, + :export_all_declaration + ] + + @doc "Return whether `source` is a CommonJS/UMD module rather than an ES module." + @spec commonjs?(String.t(), String.t()) :: boolean() + def commonjs?(source, path) do + if Path.extname(path) in @extensions do + case OXC.parse(source, Path.basename(path)) do + {:ok, ast} -> not esm?(ast) and uses_commonjs?(ast) + {:error, _} -> false + end + else + false + end + end + + @doc """ + Bundle the CommonJS file at `path` into an ES module whose default export is + `module.exports`. Modules it requires are bundled along with it. + + ## Options + + * `:modules` — directories used to resolve bare `require` specifiers + * `:module_types` — bundler module type overrides + """ + @spec to_esm(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def to_esm(path, opts \\ []) do + module_types = Keyword.get(opts, :module_types, %{}) + + bundle_opts = + [ + cwd: Path.dirname(path), + format: :esm, + conditions: Volt.JS.Resolution.browser_conditions(), + modules: Keyword.get(opts, :modules, []), + define: %{"process.env.NODE_ENV" => ~s("development")} + ] ++ if(module_types != %{}, do: [module_types: module_types], else: []) + + case OXC.bundle(path, bundle_opts) do + {:ok, %{code: code}} -> {:ok, code} + {:ok, code} when is_binary(code) -> {:ok, code} + {:error, _} = error -> error + end + end + + defp esm?(%{body: body}) when is_list(body) do + Enum.any?(body, &(&1[:type] in @esm_declarations)) + end + + defp esm?(_ast), do: false + + defp uses_commonjs?(ast) do + {_ast, found?} = + OXC.postwalk(ast, false, fn + node, false -> {node, commonjs_node?(node)} + node, true -> {node, true} + end) + + found? + end + + defp commonjs_node?(node) when is_map(node) do + Volt.JS.AST.member_expression?(node, "module", "exports") or + exports_member?(node) or + match?({:ok, _}, Volt.JS.AST.call_arguments(node, "require")) + end + + defp commonjs_node?(_node), do: false + + defp exports_member?(%{ + type: :member_expression, + object: %{type: :identifier, name: "exports"} + }), + do: true + + defp exports_member?(_node), do: false +end diff --git a/lib/volt/js/discovery.ex b/lib/volt/js/discovery.ex index 9a4305d..aeff5b7 100644 --- a/lib/volt/js/discovery.ex +++ b/lib/volt/js/discovery.ex @@ -39,10 +39,6 @@ defmodule Volt.JS.Discovery do defp discovery_config(nil), do: [] - defp discovery_config(tool) do - config = Application.get_env(:volt, tool, []) - - # The bundler reads :format as an atom, so it holds no discovery keys. - if Keyword.keyword?(config), do: config, else: [] - end + defp discovery_config(:format), do: Volt.JS.Format.discovery_config() + defp discovery_config(tool), do: Application.get_env(:volt, tool, []) end diff --git a/lib/volt/js/format.ex b/lib/volt/js/format.ex index 0df0bb9..2facbf7 100644 --- a/lib/volt/js/format.ex +++ b/lib/volt/js/format.ex @@ -26,18 +26,69 @@ defmodule Volt.JS.Format do @atom_values ~w(trailing_comma arrow_parens end_of_line quote_props object_wrap experimental_operator_position embedded_language_formatting)a @discovery_keys ~w(root sources ignore)a - def load_config do - opts = Application.get_env(:volt, :format) + @dot_formatter ".formatter.exs" - # The bundler reads the same key, where the value is an atom such as :esm. - # Only a keyword list carries formatter options. - if Keyword.keyword?(opts) do - Keyword.drop(opts, @discovery_keys) + @doc """ + Load oxfmt options. + + Options come from the `:volt` key of `.formatter.exs`: + + [ + plugins: [Volt.Formatter], + volt: [semi: false, single_quote: true] + ] + + Without that key, they come from `.oxfmtrc.json` or `.prettierrc.json`. + `formatter_opts` is the keyword list `.formatter.exs` evaluates to; `mix format` + passes it to `Volt.Formatter`, and the Mix tasks read the file themselves. + """ + @spec load_config(keyword()) :: keyword() + def load_config(formatter_opts \\ formatter_opts()) do + reject_application_config!(Application.get_env(:volt, :format)) + + case Keyword.fetch(formatter_opts, :volt) do + {:ok, opts} -> Keyword.drop(opts, @discovery_keys) + :error -> load_json_config() + end + end + + @doc "Read the options in the project's `.formatter.exs`, or `[]` without one." + @spec formatter_opts() :: keyword() + def formatter_opts do + if File.regular?(@dot_formatter) do + {opts, _binding} = Code.eval_file(@dot_formatter) + opts else - load_json_config() + [] end end + @doc """ + File-discovery options (`:root`, `:sources`, `:ignore`) for `mix volt.js.format` + and `mix volt.js.check`, from the `:volt` key of `.formatter.exs`. + """ + @spec discovery_config(keyword()) :: keyword() + def discovery_config(formatter_opts \\ formatter_opts()) do + formatter_opts |> Keyword.get(:volt, []) |> Keyword.take(@discovery_keys) + end + + @doc false + def reject_application_config!(format) when is_list(format) do + raise ArgumentError, """ + formatter options are no longer read from `config :volt, :format`, which now \ + only holds the build output format (`:iife`, `:esm`, or `:cjs`). + + Move them to the `:volt` key of .formatter.exs: + + [ + plugins: [Volt.Formatter], + volt: #{inspect(format)} + ] + """ + end + + def reject_application_config!(_format), do: :ok + def load_json_config do case find_json_config() do nil -> [] diff --git a/lib/volt/js/tsconfig.ex b/lib/volt/js/tsconfig.ex index 363ef31..7e75258 100644 --- a/lib/volt/js/tsconfig.ex +++ b/lib/volt/js/tsconfig.ex @@ -15,7 +15,9 @@ defmodule Volt.JS.TSConfig do %{"@" => "/absolute/path/to/src"} Glob suffixes (`/*`) are stripped from both keys and values. - Only the first path in each mapping array is used. + The first path in each mapping array that is not a declaration file is used; + mappings that only point at declaration files (`.d.ts`) describe types and + are left to normal module resolution. """ @spec read_paths(String.t()) :: %{String.t() => String.t()} def read_paths(tsconfig_path) do @@ -26,24 +28,29 @@ defmodule Volt.JS.TSConfig do tsconfig_dir = Path.dirname(tsconfig_path) base = Path.expand(base_url, tsconfig_dir) - Map.new(paths, fn {key, targets} -> - alias_key = key |> String.trim_trailing("/*") |> String.trim_trailing("*") - alias_key = String.trim_trailing(alias_key, "/") - - target = - targets - |> List.first("") - |> String.trim_trailing("/*") - |> String.trim_trailing("*") - |> String.trim_trailing("/") - - {alias_key, Path.expand(target, base)} - end) + for {key, targets} <- paths, + target = Enum.find(List.wrap(targets), &runtime_target?/1), + into: %{} do + {trim_glob(key), Path.expand(trim_glob(target), base)} + end else _ -> %{} end end + defp runtime_target?(target) when is_binary(target) do + not String.ends_with?(target, [".d.ts", ".d.mts", ".d.cts"]) + end + + defp runtime_target?(_target), do: false + + defp trim_glob(pattern) do + pattern + |> String.trim_trailing("/*") + |> String.trim_trailing("*") + |> String.trim_trailing("/") + end + @doc """ Find and read tsconfig.json paths from the current working directory. """ diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index b732eef..38756e3 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -63,7 +63,8 @@ defmodule Volt.Watcher do tailwind_dirs: [], reload_dirs: [], explicit_ignored: [], - watch_ignored: [] + watch_ignored: [], + source_digests: %{} ] def start_link(opts) do @@ -164,6 +165,8 @@ defmodule Volt.Watcher do watch_ignored: watch_ignored } + state = %{state | source_digests: initial_source_digests(all_dirs -- [root], state)} + if config[:tailwind], do: initial_tailwind_build(state) {:ok, refresh_tailwind_inputs(state)} @@ -321,8 +324,17 @@ defmodule Volt.Watcher do def handle_info({:rebuild, path}, state) do state = %{state | pending: Map.delete(state.pending, path)} - handle_js_change(path, state) - {:noreply, state} + digest = source_digest(path) + + # Generators such as Phoenix colocated hooks rewrite files with identical + # content on every code reload; rebuilding for those would reload the page, + # which triggers the next rewrite. + if Map.fetch(state.source_digests, path) == {:ok, digest} do + {:noreply, state} + else + handle_js_change(path, state) + {:noreply, %{state | source_digests: Map.put(state.source_digests, path, digest)}} + end end def handle_info(:tailwind_rebuild, state) do @@ -372,6 +384,43 @@ defmodule Volt.Watcher do Enum.any?(patterns, &GlobEx.match?(&1, path)) end + defp source_digest(path) do + case File.read(path) do + {:ok, source} -> :crypto.hash(:sha256, source) + {:error, _reason} -> :missing + end + end + + # Directories outside the asset root hold generated sources whose first + # rewrite would otherwise have no baseline to compare against. + defp initial_source_digests(dirs, state) do + extensions = Extensions.watchable_js(state.config[:plugins] || []) + + for dir <- dirs, + path <- source_files(dir, extensions, state), + into: %{}, + do: {path, source_digest(path)} + end + + defp source_files(dir, extensions, state) do + case File.ls(dir) do + {:ok, names} -> + Enum.flat_map(names, fn name -> + path = Path.join(dir, name) + + cond do + ignored_path?(path, state) -> [] + File.dir?(path) -> source_files(path, extensions, state) + Path.extname(path) in extensions -> [path] + true -> [] + end + end) + + {:error, _reason} -> + [] + end + end + defp schedule_rebuild(state, path) do case Map.get(state.pending, path) do nil -> :ok diff --git a/lib/volt/watcher/ignore.ex b/lib/volt/watcher/ignore.ex index 59a3ad1..9f0c7b9 100644 --- a/lib/volt/watcher/ignore.ex +++ b/lib/volt/watcher/ignore.ex @@ -10,9 +10,13 @@ defmodule Volt.Watcher.Ignore do ] @spec compile([String.t()], [String.t()]) :: [GlobEx.t()] - def compile(patterns, roots), do: compile_patterns(@default_patterns ++ patterns, roots) + def compile(patterns, roots), + do: compile_patterns(@default_patterns, roots) ++ compile_explicit(patterns, roots) - def compile_explicit(patterns, roots), do: compile_patterns(patterns, roots) + # Configured patterns also resolve from the project directory, so that + # `_build/**` matches a watch root that itself lives inside `_build`. + def compile_explicit(patterns, roots), + do: compile_patterns(patterns, Enum.uniq(roots ++ [File.cwd!()])) defp compile_patterns(patterns, roots) do patterns diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index 744d377..551e91d 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -110,6 +110,16 @@ defmodule Mix.Tasks.Volt.InstallTest do refute config_content =~ "config :esbuild" refute config_content =~ "config :tailwind" + refute config_content =~ "semi:" + + {formatter_opts, _binding} = + igniter.rewrite.sources[".formatter.exs"] + |> Rewrite.Source.get(:content) + |> Code.eval_string() + + assert Volt.Formatter in formatter_opts[:plugins] + assert formatter_opts[:volt][:semi] == false + assert formatter_opts[:volt][:trailing_comma] == :none dev_content = igniter.rewrite.sources["config/dev.exs"] diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index 3063683..345b55e 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -442,6 +442,70 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "ref(" end + test "keeps external imports in ES module output" do + File.write!(Path.join(@fixture_dir, "src/external_app.js"), """ + import { Socket } from 'phoenix' + import 'phoenix/side-effect' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_app.js"), + outdir: @outdir, + format: :esm, + minify: false, + sourcemap: false, + external: %{"phoenix" => "Phoenix"} + ) + + js = File.read!(result.js.path) + assert js =~ ~r/import \{ Socket \} from "phoenix"/ + assert js =~ ~s(import "phoenix/side-effect") + refute js =~ "= Phoenix" + end + + test "requires external imports in CommonJS output" do + File.write!(Path.join(@fixture_dir, "src/external_cjs_app.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_cjs_app.js"), + outdir: @outdir, + format: :cjs, + minify: false, + sourcemap: false, + external: ["phoenix"] + ) + + js = File.read!(result.js.path) + assert js =~ ~s[require("phoenix")] + refute js =~ "= Phoenix" + end + + test "reads external imports from globals in IIFE output" do + File.write!(Path.join(@fixture_dir, "src/external_iife_app.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_iife_app.js"), + outdir: @outdir, + minify: false, + sourcemap: false, + external: %{"phoenix" => "PhoenixGlobal"} + ) + + js = File.read!(result.js.path) + assert js =~ "const { Socket } = PhoenixGlobal;" + refute js =~ ~s(from "phoenix") + end + test "resolves .js imports to .ts files when .js does not exist" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), """ export const helper = 'ts-resolved' diff --git a/test/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index c16a38a..9831619 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -169,8 +169,15 @@ defmodule Volt.DevServer.BasicTest do assert vendor_conn.status == 200 - assert [[_, chunk_path]] = - Regex.scan(~r{from "\./(chunks/[^"']+\.js)"}, vendor_conn.resp_body) + # Shared chunks carry the importer's browser hash, so a module reached from + # both application code and another pre-bundle stays a single instance. + [_, browser_hash] = String.split(vendor_url, "?v=") + + assert [[_, chunk_path, ^browser_hash]] = + Regex.scan( + ~r{from "\./(chunks/[^"'?]+\.js)\?v=([a-f0-9]+)"}, + vendor_conn.resp_body + ) chunk_conn = call_dev_server("/@vendor/#{chunk_path}", diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index fd46978..084a9d1 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -26,6 +26,50 @@ defmodule Volt.DevServer.ImportRewritingTest do refute conn.resp_body =~ Path.join(root, "value.ts") end + describe "CommonJS interop" do + test "serves a local UMD module as an ES module with a default export" do + File.mkdir_p!(Path.join(@fixture_dir, "src/vendor")) + + File.write!(Path.join(@fixture_dir, "src/vendor/topbar.js"), """ + (function (window, document) { + var topbar = { show: function () {} }; + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else { + this.topbar = topbar; + } + }.call(this, window, document)); + """) + + conn = call_dev_server("/assets/vendor/topbar.js") + assert conn.status == 200 + assert conn.resp_body =~ "export default" + end + + test "bundles modules required by a local CommonJS module" do + File.write!(Path.join(@fixture_dir, "src/cjs-dep.js"), "module.exports = { answer: 42 }") + + File.write!( + Path.join(@fixture_dir, "src/cjs-entry.js"), + "exports.answer = require('./cjs-dep').answer" + ) + + conn = call_dev_server("/assets/cjs-entry.js") + assert conn.status == 200 + assert conn.resp_body =~ "export default" + assert conn.resp_body =~ "answer: 42" + refute conn.resp_body =~ ~r/require\(['"]\.\/cjs-dep/ + end + + test "leaves ES modules untouched" do + File.write!(Path.join(@fixture_dir, "src/esm.js"), "export const module = { exports: 1 }") + + conn = call_dev_server("/assets/esm.js") + assert conn.status == 200 + refute conn.resp_body =~ "__commonJS" + end + end + describe "import rewriting" do test "rewrites relative imports to absolute paths" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), "export const y = 1") diff --git a/test/volt/js/discovery_test.exs b/test/volt/js/discovery_test.exs index 3f0c0ba..7e8d293 100644 --- a/test/volt/js/discovery_test.exs +++ b/test/volt/js/discovery_test.exs @@ -29,11 +29,7 @@ defmodule Volt.JS.DiscoveryTest do end test "discovers tool-specific source sets", %{tmp_dir: tmp_dir} do - Application.put_env(:volt, :format, - root: tmp_dir, - sources: ["format/**/*.ts"], - ignore: [] - ) + write_formatter!(tmp_dir, volt: [root: tmp_dir, sources: ["format/**/*.ts"], ignore: []]) Application.put_env(:volt, :lint, root: tmp_dir, @@ -41,11 +37,13 @@ defmodule Volt.JS.DiscoveryTest do ignore: [] ) - assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] - assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + File.cd!(tmp_dir, fn -> + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end) end - test "a bundle format on the same key holds no discovery options", %{tmp_dir: tmp_dir} do + test "formatting uses the build source set without .formatter.exs options", %{tmp_dir: tmp_dir} do Application.put_env(:volt, :format, :esm) Application.put_env(:volt, :root, tmp_dir) Application.put_env(:volt, :sources, ["format/**/*.ts"]) @@ -57,8 +55,14 @@ defmodule Volt.JS.DiscoveryTest do ignore: [] ) - assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] - assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + File.cd!(tmp_dir, fn -> + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end) + end + + defp write_formatter!(dir, opts) do + File.write!(Path.join(dir, ".formatter.exs"), inspect(opts)) end defp restore_env(key, :error), do: Application.delete_env(:volt, key) diff --git a/test/volt/js/format_test.exs b/test/volt/js/format_test.exs index bc68de7..a0cd616 100644 --- a/test/volt/js/format_test.exs +++ b/test/volt/js/format_test.exs @@ -4,31 +4,58 @@ defmodule Volt.JS.FormatTest do alias Volt.JS.Format setup do - original_config = Application.get_env(:volt, :format) + original_config = Application.fetch_env(:volt, :format) on_exit(fn -> case original_config do - nil -> Application.delete_env(:volt, :format) - config -> Application.put_env(:volt, :format, config) + :error -> Application.delete_env(:volt, :format) + {:ok, config} -> Application.put_env(:volt, :format, config) end end) end - test "load_config/0 excludes file-discovery options from OXC formatter options" do - Application.put_env(:volt, :format, - root: ".", - sources: ["priv/ts/**/*.ts"], - ignore: ["vendor/**"], - semi: false, - print_width: 100 - ) + test "load_config/1 reads formatter options from the :volt key of .formatter.exs" do + formatter_opts = [ + plugins: [Volt.Formatter], + volt: [ + root: ".", + sources: ["priv/ts/**/*.ts"], + ignore: ["vendor/**"], + semi: false, + print_width: 100 + ] + ] - assert Format.load_config() == [semi: false, print_width: 100] + assert Format.load_config(formatter_opts) == [semi: false, print_width: 100] + + assert Format.discovery_config(formatter_opts) == [ + root: ".", + sources: ["priv/ts/**/*.ts"], + ignore: ["vendor/**"] + ] + end + + test "load_config/1 falls back to JSON config files without a :volt key" do + assert Format.load_config(plugins: [Volt.Formatter]) == Format.load_json_config() + end + + test "load_config/0 reads the project's .formatter.exs" do + assert Format.load_config()[:semi] == false end - test "load_config/0 ignores a bundle format set on the same key" do + test "load_config/1 is unaffected by the build output format" do Application.put_env(:volt, :format, :esm) - assert Format.load_config() == Format.load_json_config() + assert Format.load_config(volt: [semi: false]) == [semi: false] + end + + test "formatter options under config :volt, :format are rejected with guidance" do + Application.put_env(:volt, :format, semi: false) + + assert_raise ArgumentError, ~r/Move them to the `:volt` key of \.formatter\.exs/, fn -> + Format.load_config(volt: []) + end + + assert_raise ArgumentError, ~r/\.formatter\.exs/, fn -> Volt.Config.build() end end end diff --git a/test/volt/js/tsconfig_test.exs b/test/volt/js/tsconfig_test.exs index 9576d15..9ad3e84 100644 --- a/test/volt/js/tsconfig_test.exs +++ b/test/volt/js/tsconfig_test.exs @@ -69,6 +69,23 @@ defmodule Volt.JS.TSConfigTest do paths = Volt.JS.TSConfig.read_paths(tsconfig_path()) assert paths["~utils"] =~ "src/utils" end + + test "skips mappings that only point at declaration files" do + write_tsconfig!(%{ + "compilerOptions" => %{ + "paths" => %{ + "topbar" => ["./types/topbar.d.ts"], + "phoenix" => ["./types/phoenix.d.ts", "./vendor/phoenix.js"], + "@/*" => ["./src/*"] + } + } + }) + + paths = Volt.JS.TSConfig.read_paths(tsconfig_path()) + refute Map.has_key?(paths, "topbar") + assert paths["phoenix"] =~ "vendor/phoenix.js" + assert paths["@"] =~ "src" + end end defp tsconfig_path, do: Path.join(@fixture_dir, "tsconfig.json") diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index ed8dbbf..82d9152 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -411,6 +411,60 @@ defmodule Volt.WatcherTest do GenServer.stop(pid) end + test "resolves relative watcher ignore globs from the project directory", %{ + watch_dir: watch_dir + } do + generated_dir = Path.join(File.cwd!(), "tmp/volt-watcher-generated") + entry = Path.join(generated_dir, "index.js") + File.mkdir_p!(generated_dir) + on_exit(fn -> File.rm_rf!(generated_dir) end) + + {:ok, pid} = + Volt.Watcher.start_link( + root: watch_dir, + watch_dirs: [generated_dir], + watch_ignored: ["tmp/volt-watcher-generated/**"], + name: :test_watcher_project_relative_ignore + ) + + state = send_file_event(pid, entry) + + refute Map.has_key?(state.pending, entry) + GenServer.stop(pid) + end + + test "does not reload when a generated file is rewritten with identical content", %{ + watch_dir: watch_dir + } do + Registry.register(Volt.HMR.Registry, :clients, nil) + + generated_dir = Path.join(watch_dir, "generated") + root = Path.join(watch_dir, "assets") + index = Path.join(generated_dir, "index.js") + File.mkdir_p!(generated_dir) + File.mkdir_p!(root) + File.write!(index, "export const hooks = {};") + + {:ok, pid} = + Volt.Watcher.start_link( + root: root, + watch_dirs: [generated_dir], + name: :test_watcher_identical_rewrite + ) + + send(pid, {:file_event, self(), {index, [:modified]}}) + refute_receive {:volt_hmr, :update, _}, 300 + + File.write!(index, "export const hooks = {changed: true};") + send(pid, {:file_event, self(), {index, [:modified]}}) + assert_receive {:volt_hmr, :update, %{changes: [:full]}}, 2000 + + send(pid, {:file_event, self(), {index, [:modified]}}) + refute_receive {:volt_hmr, :update, _}, 300 + + GenServer.stop(pid) + end + test "ignores dependency directories by default", %{watch_dir: watch_dir} do entry = Path.join([watch_dir, "node_modules", "example", "index.ts"]) From c88fa8b66fcd1a87871214c0bffe94349dcc59a0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:02:49 +0400 Subject: [PATCH 02/13] Explain why a development session is unavailable A second watcher with different options, such as a Mix.Tasks.Volt.Dev entry in the endpoint's :watchers next to plug Volt.DevServer, made every request answer a bare 503. The response and an error log now name the conflict and how to resolve it. --- CHANGELOG.md | 1 + lib/volt/dev_server.ex | 22 ++++++++++++++++++---- test/volt/dev_server/basic_test.exs | 22 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da0d118..503ecc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding. - Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. +- Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. - Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. ## 0.19.4 - 2026-10-03 diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 624307a..525bd74 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -162,8 +162,8 @@ defmodule Volt.DevServer do %Volt.Dev.Session.Tables{} = tables <- Volt.Dev.tables(session) do call_generation(conn, config, tables) else - {:error, _reason} -> - conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + {:error, reason} -> + session_unavailable(conn, reason) end end @@ -172,8 +172,8 @@ defmodule Volt.DevServer do %Volt.Dev.Session.Tables{} = tables -> call_generation(conn, config, tables) - {:error, _} -> - conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + {:error, reason} -> + session_unavailable(conn, reason) end end @@ -186,6 +186,20 @@ defmodule Volt.DevServer do do_call(conn, config) end + defp session_unavailable(conn, reason) do + message = "Development session is unavailable: #{session_error(reason)}" + Logger.error("[Volt] " <> message) + conn |> Conn.send_resp(503, message) |> Conn.halt() + end + + defp session_error(:session_configuration_conflict) do + "a Volt watcher is already running for this asset root with different options. " <> + "`plug Volt.DevServer` starts its own watcher, so remove any " <> + "`Mix.Tasks.Volt.Dev` entry from the endpoint's `:watchers` in config/dev.exs." + end + + defp session_error(reason), do: inspect(reason) + defp call_generation(conn, config, tables) do do_call(conn, %{config | tables: tables}) rescue diff --git a/test/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index 9831619..b86c214 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -75,6 +75,28 @@ defmodule Volt.DevServer.BasicTest do assert opts.watcher_opts[:tailwind_key] == {:profile, :default, Path.expand(css)} end + @tag :tmp_dir + test "explains a conflicting watcher instead of answering a bare 503", %{tmp_dir: root} do + assets = Path.join(root, "assets") + File.mkdir_p!(assets) + File.write!(Path.join(assets, "app.ts"), "export const x = 1") + + config = Volt.DevServer.init(root: assets, watch: true) + on_exit(fn -> Volt.Dev.stop(config.session) end) + + conflicting = Keyword.put(config.watcher_opts, :watch_dirs, [root]) + assert {:ok, _watcher} = Volt.Dev.start(conflicting) + + log = + ExUnit.CaptureLog.capture_log(fn -> + conn = Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) + assert conn.status == 503 + assert conn.resp_body =~ "Mix.Tasks.Volt.Dev" + end) + + assert log =~ "a Volt watcher is already running" + end + test "passes through non-asset requests for downstream HTML plugs" do opts = Volt.DevServer.init(root: Path.join(@fixture_dir, "src"), prefix: "/assets") From 68db4b674c4afd8f2fd6000f93fef5b0b7bbd77d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:02:49 +0400 Subject: [PATCH 03/13] Log vendor pre-bundling only when packages are bundled Phoenix initializes plugs on every request in development, so the dev server logged "Pre-bundled N vendor package(s)" per request even when the cache was fresh. The log moves to where bundling happens. --- CHANGELOG.md | 1 + lib/volt/dev_server.ex | 21 +++++++-------------- lib/volt/js/vendor.ex | 6 +++++- test/volt/js/vendor_test.exs | 12 ++++++++++++ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503ecc0..9bfb8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding. - Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. - Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. +- Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request. - Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. ## 0.19.4 - 2026-10-03 diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 525bd74..f200c60 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -930,20 +930,13 @@ defmodule Volt.DevServer do # ── Vendor pre-bundling ─────────────────────────────────────────── defp prebundle_vendor(root, node_modules, plugins, resolve_dirs, module_types) do - case Volt.JS.Vendor.prebundle( - root: root, - node_modules: node_modules, - plugins: plugins, - resolve_dirs: resolve_dirs, - module_types: module_types - ) do - {:ok, vendor_map} when map_size(vendor_map) > 0 -> - count = map_size(vendor_map) - Logger.debug("[Volt] Pre-bundled #{count} vendor package(s)") - - _ -> - :ok - end + Volt.JS.Vendor.prebundle( + root: root, + node_modules: node_modules, + plugins: plugins, + resolve_dirs: resolve_dirs, + module_types: module_types + ) end defp serve_vendor(specifier, config, browser_hash) do diff --git a/lib/volt/js/vendor.ex b/lib/volt/js/vendor.ex index 09de73e..388af78 100644 --- a/lib/volt/js/vendor.ex +++ b/lib/volt/js/vendor.ex @@ -196,7 +196,11 @@ defmodule Volt.JS.Vendor do if not force and Enum.all?(specifiers, &cache_fresh?(&1, module_dirs, plugins, module_types)) do {:ok, vendor_map} else - safe_bundle_vendors(specifiers, module_dirs, plugins, module_types, vendor_map) + with {:ok, bundled} = ok <- + safe_bundle_vendors(specifiers, module_dirs, plugins, module_types, vendor_map) do + Logger.debug("[Volt] Pre-bundled #{map_size(bundled)} vendor package(s)") + ok + end end end diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index 7d1ba7b..7235edc 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -61,6 +61,18 @@ defmodule Volt.JS.VendorTest do assert Map.has_key?(vendor_map, "fake-lib") end + test "logs only when packages are bundled, not when the cache is fresh" do + opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules] + + assert ExUnit.CaptureLog.capture_log(fn -> + Volt.JS.Vendor.prebundle(opts ++ [force: true]) + end) =~ + "Pre-bundled" + + refute ExUnit.CaptureLog.capture_log(fn -> Volt.JS.Vendor.prebundle(opts) end) =~ + "Pre-bundled" + end + test "caches bundled files on disk" do Volt.JS.Vendor.prebundle( root: Path.join(@fixture_dir, "src"), From ed4f034ec55f94bb27d342fd0ddca986c4f49fd4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:02:49 +0400 Subject: [PATCH 04/13] Read external globals once in code-split IIFE output Chunks already read their external globals inside the IIFE. A second copy was prepended at the top level of each script, where two chunks importing the same name would clash. --- CHANGELOG.md | 1 + lib/volt/builder/output.ex | 4 ---- test/volt/builder/code_splitting_test.exs | 29 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfb8e0..7d450ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. - Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. - Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request. +- Read external globals once in code-split IIFE output. Each chunk also declared them at the top level of the script, where two chunks importing the same name would clash. - Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. ## 0.19.4 - 2026-10-03 diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index f2423ba..ef3d8c1 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -556,7 +556,6 @@ defmodule Volt.Builder.Output do process_chunks( chunk_bundles, graph, - js_map, module_labels, css_results, ctx, @@ -588,7 +587,6 @@ defmodule Volt.Builder.Output do defp process_chunks( chunk_bundles, graph, - js_map, module_labels, css_results, ctx, @@ -599,9 +597,7 @@ defmodule Volt.Builder.Output do Map.new(chunk_bundles, fn {chunk_id, {code, sourcemap}} -> chunk = graph.chunks[chunk_id] - chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) chunk_import_map = chunk_import_map(chunk, graph, module_labels, dep_map) - code = Rewriter.inject_external_preamble(code, chunk_js, ctx) code = Rewriter.rewrite_chunk_imports(code, chunk_import_map, chunk_url_map) code = Rewriter.rewrite_dynamic_preloads(code, preload_map) diff --git a/test/volt/builder/code_splitting_test.exs b/test/volt/builder/code_splitting_test.exs index 7a73045..dde1043 100644 --- a/test/volt/builder/code_splitting_test.exs +++ b/test/volt/builder/code_splitting_test.exs @@ -2,6 +2,35 @@ defmodule Volt.Builder.CodeSplittingTest do use Volt.TestSupport.BuilderCase describe "build/1 code splitting" do + test "IIFE chunks read external globals once, inside the function scope" do + File.write!(Path.join(@fixture_dir, "src/global_lazy.js"), """ + import { Presence } from 'phoenix' + export const run = () => console.log(Presence) + """) + + File.write!(Path.join(@fixture_dir, "src/global_entry.js"), """ + import { Socket } from 'phoenix' + console.log(new Socket('/socket')) + import('./global_lazy.js').then((m) => m.run()) + """) + + {:ok, _result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/global_entry.js"), + outdir: @outdir, + hash: false, + minify: false, + sourcemap: false, + external: ["phoenix"] + ) + + for file <- Path.wildcard(Path.join(@outdir, "global_entry*.js")) do + js = File.read!(file) + assert [_once] = Regex.scan(~r/= Phoenix;/, js) + refute String.starts_with?(js, "const ") + end + end + test "multi-entry ESM builds share common chunks" do File.write!( Path.join(@fixture_dir, "src/shared.ts"), From 1ef3596b7f1bbf9aeb8b6e2ac6e802787bdd15bd Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:02:49 +0400 Subject: [PATCH 05/13] Don't crash a watcher terminating after the Tailwind registry stops During application shutdown a watcher can terminate after Volt.Tailwind.Registry has stopped, and releasing its context raised "unknown registry". There is nothing left to release then. --- CHANGELOG.md | 1 + lib/volt/tailwind/supervisor.ex | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d450ea..da92025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. - Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request. - Read external globals once in code-split IIFE output. Each chunk also declared them at the top level of the script, where two chunks importing the same name would clash. +- Don't crash a watcher that terminates after Volt's Tailwind registry has stopped during application shutdown. - Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. ## 0.19.4 - 2026-10-03 diff --git a/lib/volt/tailwind/supervisor.ex b/lib/volt/tailwind/supervisor.ex index f205b81..20cff31 100644 --- a/lib/volt/tailwind/supervisor.ex +++ b/lib/volt/tailwind/supervisor.ex @@ -6,9 +6,15 @@ defmodule Volt.Tailwind.Supervisor do @doc "Release a compiler context without affecting other registered roots." def release(key) do - case Registry.lookup(@registry, key) do - [] -> :ok - [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) + # A watcher can terminate after these have stopped during application + # shutdown, when there is nothing left to release. + if Process.whereis(@registry) && Process.whereis(@supervisor) do + case Registry.lookup(@registry, key) do + [] -> :ok + [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) + end + else + :ok end end From 18691895721ac7a7451eee50d141c6b11f9fbbeb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:13:40 +0400 Subject: [PATCH 06/13] Collect every test generated by test.each and describe.each Source lines were matched to collected tests by position with Enum.zip, which truncates to the shorter list. The line scan only recognized plain test() and it() calls, so a five-case table followed by two tests was collected as two tests. Each case now maps to the line of its table, and describe.each repeats its inner tests per case. When the source shows a different number of tests than the runtime collected, such as tests registered in a loop, all tests are kept without a line. --- CHANGELOG.md | 2 + lib/volt/test/lines.ex | 88 ++++++++++++++++++++++++++-------- lib/volt/test/shared.ex | 17 ++++--- test/volt/test/lines_test.exs | 31 ++++++++++++ test/volt/test/runner_test.exs | 44 +++++++++++++++++ 5 files changed, 155 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da92025..6215cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ - The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. - Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`. + ### Fixed +- Collect every test generated by `test.each` and `describe.each`. Source lines were matched to tests by position, and tests without a detected line were dropped: a five-case table followed by two tests collected as two tests. Each case now maps to the line of its table, and tests registered in ways the source does not show, such as in a loop, are kept without a line. - Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding. - Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render. - Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. diff --git a/lib/volt/test/lines.ex b/lib/volt/test/lines.ex index 97ededa..5a4691b 100644 --- a/lib/volt/test/lines.ex +++ b/lib/volt/test/lines.ex @@ -7,33 +7,52 @@ defmodule Volt.Test.Lines do at useful file lines without hand-parsing JavaScript. """ + @doc """ + Return the source line of every test in `source`, in the order the runtime + registers them. + + `test.each` and `describe.each` register one test per case, so their lines + repeat once per case. Returns `{:error, :dynamic_cases}` when the cases are + not an array literal and the number of tests cannot be read from the source. + """ @spec test_lines(String.t(), String.t()) :: {:ok, [pos_integer()]} | {:error, term()} def test_lines(source, filename) do - case OXC.parse(source, filename) do - {:ok, ast} -> - {:ok, ast |> collect_test_starts() |> Enum.sort() |> Enum.map(&line(source, &1))} + with {:ok, ast} <- OXC.parse(source, filename) do + {:ok, ast |> test_starts() |> Enum.map(&line(source, &1))} + end + catch + :dynamic_cases -> {:error, :dynamic_cases} + end + + defp test_starts(%{type: :call_expression, callee: callee, arguments: args} = node) + when is_list(args) do + cond do + test_callee?(callee) and test_call?(args) -> + [node.start] - {:error, _} = error -> - error + each_callee?(callee, &test_callee?/1) and test_call?(args) -> + List.duplicate(node.start, case_count(callee)) + + each_callee?(callee, &describe_callee?/1) -> + args |> test_starts() |> List.duplicate(case_count(callee)) |> Enum.concat() + + true -> + child_starts(node) end end - defp collect_test_starts(ast) do - {_ast, starts} = - OXC.postwalk(ast, [], fn - %{type: :call_expression, callee: callee, arguments: args} = node, starts - when is_list(args) -> - if test_callee?(callee) and test_call?(args) do - {node, [node.start | starts]} - else - {node, starts} - end - - node, starts -> - {node, starts} - end) - - starts + defp test_starts(node) when is_map(node), do: child_starts(node) + defp test_starts(nodes) when is_list(nodes), do: Enum.flat_map(nodes, &test_starts/1) + defp test_starts(_other), do: [] + + # Children in source order, which is the order the runtime runs them in. + defp child_starts(node) do + node + |> Map.values() + |> Enum.flat_map(&List.wrap/1) + |> Enum.filter(&match?(%{start: start} when is_integer(start), &1)) + |> Enum.sort_by(& &1.start) + |> Enum.flat_map(&test_starts/1) end defp test_callee?(%{type: :identifier, name: name}) when name in ["test", "it"], do: true @@ -45,6 +64,33 @@ defmodule Volt.Test.Lines do defp test_callee?(_), do: false + defp describe_callee?(%{type: :identifier, name: "describe"}), do: true + + defp describe_callee?(%{type: :member_expression, object: object, property: %{name: property}}) + when property in ["skip", "todo"] do + describe_callee?(object) + end + + defp describe_callee?(_), do: false + + # `test.each(cases)` and `describe.each(cases)`, whose result is then called + # with the name and body. + defp each_callee?( + %{ + type: :call_expression, + callee: %{type: :member_expression, object: object, property: %{name: "each"}} + }, + base? + ), + do: base?.(object) + + defp each_callee?(_callee, _base?), do: false + + defp case_count(%{arguments: [%{type: :array_expression, elements: elements}]}), + do: length(elements) + + defp case_count(_each_call), do: throw(:dynamic_cases) + defp test_call?([%{value: name} | _]) when is_binary(name), do: true defp test_call?([%{type: :template_literal, expressions: [], quasis: [_]} | _]), do: true defp test_call?(_), do: false diff --git a/lib/volt/test/shared.ex b/lib/volt/test/shared.ex index d8eb238..42312d9 100644 --- a/lib/volt/test/shared.ex +++ b/lib/volt/test/shared.ex @@ -3,15 +3,20 @@ defmodule Volt.Test.Shared do alias Volt.Test.Config + @doc """ + Add source lines to collected tests. + + Lines are read from the source and matched to tests by position. Tests keep + no line when the source declares a different number of tests than the runtime + collected, for example when they are registered in a loop. + """ def add_source_lines(path, tests) do with {:ok, source} <- File.read(path), - {:ok, lines} <- Volt.Test.Lines.test_lines(source, Path.basename(path)) do - {:ok, - tests - |> Enum.zip(lines) - |> Enum.map(fn {test, line} -> %{test | line: line} end)} + {:ok, lines} <- Volt.Test.Lines.test_lines(source, Path.basename(path)), + true <- length(lines) == length(tests) do + {:ok, Enum.zip_with(tests, lines, fn test, line -> %{test | line: line} end)} else - {:error, _} -> {:ok, tests} + _ -> {:ok, tests} end end diff --git a/test/volt/test/lines_test.exs b/test/volt/test/lines_test.exs index b09fe70..2247d57 100644 --- a/test/volt/test/lines_test.exs +++ b/test/volt/test/lines_test.exs @@ -18,6 +18,37 @@ defmodule Volt.Test.LinesTest do assert Volt.Test.Lines.test_lines(source, "math.test.ts") == {:ok, [4, 6, 7]} end + test "repeats lines for each-table cases in registration order" do + source = ~TS""" + import { describe, test } from 'volt:test' + + test.each([ + [1, 2], + [2, 3], + [3, 4] + ])('adds %d', () => {}) + + describe.each(['a', 'b'])('suite %s', () => { + test('first', () => {}) + test('second', () => {}) + }) + + test('after', () => {}) + """ + + assert Volt.Test.Lines.test_lines(source, "each.test.ts") == + {:ok, [3, 3, 3, 10, 11, 10, 11, 14]} + end + + test "reports each-tables whose cases are not an array literal" do + source = ~TS""" + const cases = [1, 2] + test.each(cases)('case %d', () => {}) + """ + + assert Volt.Test.Lines.test_lines(source, "dynamic.test.ts") == {:error, :dynamic_cases} + end + test "ignores non-test calls" do source = ~TS""" const helper = { test() {} } diff --git a/test/volt/test/runner_test.exs b/test/volt/test/runner_test.exs index a6e3d5b..2e7b5ec 100644 --- a/test/volt/test/runner_test.exs +++ b/test/volt/test/runner_test.exs @@ -36,6 +36,50 @@ defmodule Volt.Test.RunnerTest do ]} = Runner.collect_file(path) end + test "collects every each-table case and the tests after it", %{tmp_dir: tmp_dir} do + path = + write!(tmp_dir, "each.test.ts", ~TS""" + import { test } from 'volt:test' + + test.each([ + [1], + [2], + [3] + ])('case %d', () => {}) + + test('after', () => {}) + """) + + assert {:ok, tests} = Runner.collect_file(path) + + assert Enum.map(tests, &{&1.full_name, &1.line}) == [ + {"case 1", 3}, + {"case 2", 3}, + {"case 3", 3}, + {"after", 9} + ] + end + + test "keeps tests registered in ways the source does not show", %{tmp_dir: tmp_dir} do + path = + write!(tmp_dir, "dynamic.test.ts", ~TS""" + import { test } from 'volt:test' + + const cases = [1, 2] + test.each(cases)('case %d', () => {}) + + for (const name of ['x', 'y']) { + test('loop ' + name, () => {}) + } + + test('after', () => {}) + """) + + assert {:ok, tests} = Runner.collect_file(path) + + assert Enum.map(tests, & &1.full_name) == ["case 1", "case 2", "loop x", "loop y", "after"] + end + test "collects skip todo and tag metadata", %{tmp_dir: tmp_dir} do path = write!(tmp_dir, "modifiers.test.ts", ~TS""" From 4840c3bbd7d9d49b944c1f61d44b6fe6f6bb8015 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:13:40 +0400 Subject: [PATCH 07/13] Declare import.meta.env in the client types The client types only declared import.meta.hot, so TypeScript projects had to declare ImportMeta.env themselves. The installer adds env.d.ts to configurations that list declaration files explicitly. --- CHANGELOG.md | 1 + guides/features/environment-variables.md | 13 +++++++++++++ lib/mix/tasks/volt/install.ex | 1 + priv/types/client/env.d.ts | 10 ++++++++++ test/mix/tasks/volt/install_test.exs | 1 + 5 files changed, 26 insertions(+) create mode 100644 priv/types/client/env.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6215cc1..130015e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. - Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`. +- Volt's client types declare `import.meta.env`, so TypeScript projects no longer need their own `ImportMeta` declaration for `MODE`, `DEV`, `PROD` and exposed variables. `mix igniter.install volt` adds `env.d.ts` to configurations that list declaration files explicitly. ### Fixed diff --git a/guides/features/environment-variables.md b/guides/features/environment-variables.md index 8cef0b3..ab1c690 100644 --- a/guides/features/environment-variables.md +++ b/guides/features/environment-variables.md @@ -34,6 +34,19 @@ Multiple prefixes are also supported: config :volt, env_prefix: ["VOLT_", "PUBLIC_"] ``` +## TypeScript + +Volt's client types declare `import.meta.env` with `MODE`, `DEV`, `PROD`, and any other key as `string | boolean | undefined`. `mix igniter.install volt` adds them to `tsconfig.json`; to add them by hand, include `deps/volt/priv/types/client/**/*.d.ts`. + +Declare your own variables to get exact types and completion: + +```ts +// assets/env.d.ts +interface ImportMetaEnv { + readonly VOLT_API_URL: string +} +``` + ## File Loading Order Files are loaded in order, with later files overriding earlier ones: diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index b5bce35..5d3e252 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -102,6 +102,7 @@ if Code.ensure_loaded?(Igniter) do files when is_list(files) <- Map.get(config, "files", []), true <- Enum.all?(files, &is_binary/1) do types = [ + "#{deps}/volt/priv/types/client/env.d.ts", "#{deps}/volt/priv/types/client/hmr.d.ts", "#{deps}/volt/priv/types/client/preload.d.ts", "#{deps}/volt/priv/types/client/styles.d.ts" diff --git a/priv/types/client/env.d.ts b/priv/types/client/env.d.ts new file mode 100644 index 0000000..5ef2d2d --- /dev/null +++ b/priv/types/client/env.d.ts @@ -0,0 +1,10 @@ +interface ImportMetaEnv { + readonly MODE: string + readonly DEV: boolean + readonly PROD: boolean + readonly [key: string]: string | boolean | undefined +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index 551e91d..74b913c 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -170,6 +170,7 @@ defmodule Mix.Tasks.Volt.InstallTest do assert config["files"] == [ "assets/app.ts", + "deps/volt/priv/types/client/env.d.ts", "deps/volt/priv/types/client/hmr.d.ts", "deps/volt/priv/types/client/preload.d.ts", "deps/volt/priv/types/client/styles.d.ts" From aea7f3ce58fb93a74b290309d0543eb76b50a4cf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:27:07 +0400 Subject: [PATCH 08/13] Serve .cjs and .cts files in development They were not in the dev server's compilable extensions, so the private extension list in Volt.JS.CommonJS could never match .cjs. Both are CommonJS by extension and go through the same conversion as CommonJS .js files; only .js is inspected. The list comes from Volt.JS.Extensions. --- CHANGELOG.md | 2 +- lib/volt/js/common_js.ex | 29 +++++++++++++------ lib/volt/js/extensions.ex | 6 +++- lib/volt/pipeline.ex | 2 +- .../volt/dev_server/import_rewriting_test.exs | 11 +++++++ 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130015e..19624bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Added -- The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. +- The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. `.cjs` and `.cts` files, which the dev server did not serve, are converted the same way. - Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`. - Volt's client types declare `import.meta.env`, so TypeScript projects no longer need their own `ImportMeta` declaration for `MODE`, `DEV`, `PROD` and exposed variables. `mix igniter.install volt` adds `env.d.ts` to configurations that list declaration files explicitly. diff --git a/lib/volt/js/common_js.ex b/lib/volt/js/common_js.ex index 57c782d..6b83d95 100644 --- a/lib/volt/js/common_js.ex +++ b/lib/volt/js/common_js.ex @@ -8,7 +8,6 @@ defmodule Volt.JS.CommonJS do `import topbar from "../vendor/topbar"`. """ - @extensions ~w(.js .cjs) @esm_declarations [ :import_declaration, :export_named_declaration, @@ -16,16 +15,28 @@ defmodule Volt.JS.CommonJS do :export_all_declaration ] - @doc "Return whether `source` is a CommonJS/UMD module rather than an ES module." + @doc """ + Return whether the module at `path` is CommonJS/UMD rather than an ES module. + + `.cjs` and `.cts` files are CommonJS by extension. A `.js` file is CommonJS + when it has no `import` or `export` declarations and uses `module.exports`, + `exports`, or `require`. + """ @spec commonjs?(String.t(), String.t()) :: boolean() def commonjs?(source, path) do - if Path.extname(path) in @extensions do - case OXC.parse(source, Path.basename(path)) do - {:ok, ast} -> not esm?(ast) and uses_commonjs?(ast) - {:error, _} -> false - end - else - false + ext = Path.extname(path) + + cond do + ext in Volt.JS.Extensions.cjs() -> true + ext == ".js" -> commonjs_source?(source, Path.basename(path)) + true -> false + end + end + + defp commonjs_source?(source, filename) do + case OXC.parse(source, filename) do + {:ok, ast} -> not esm?(ast) and uses_commonjs?(ast) + {:error, _} -> false end end diff --git a/lib/volt/js/extensions.ex b/lib/volt/js/extensions.ex index 6717b7f..86b8673 100644 --- a/lib/volt/js/extensions.ex +++ b/lib/volt/js/extensions.ex @@ -9,12 +9,16 @@ defmodule Volt.JS.Extensions do def js, do: @js def cjs, do: @cjs + def json, do: @json def node_resolvable, do: @js ++ @cjs ++ @json def node_resolvable_with_exact, do: ["" | node_resolvable()] def bundleable, do: @js ++ @cjs def formattable, do: @js - def compilable(plugins \\ []), do: plugin_exts(plugins, :compile) ++ @js ++ @css ++ @json + + def compilable(plugins \\ []), + do: plugin_exts(plugins, :compile) ++ @js ++ @cjs ++ @css ++ @json + def scannable(plugins \\ []), do: plugin_exts(plugins, :scan) ++ @js def resolvable(plugins \\ []), do: ["" | @js ++ @cjs ++ plugin_exts(plugins, :resolve) ++ @json] def resolvable_index, do: Enum.map(@js ++ @cjs, &("/index" <> &1)) diff --git a/lib/volt/pipeline.ex b/lib/volt/pipeline.ex index 5637125..7460b8d 100644 --- a/lib/volt/pipeline.ex +++ b/lib/volt/pipeline.ex @@ -50,7 +50,7 @@ defmodule Volt.Pipeline do Volt.MIME.css?(content_type) -> compile_css(compile_path, source, opts) - ext in Volt.JS.Extensions.js() -> + ext in Volt.JS.Extensions.bundleable() -> compile_js(compile_path, source, opts) Volt.CSS.Modules.css_module?(base_path) -> diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index 084a9d1..7f9452e 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -61,6 +61,17 @@ defmodule Volt.DevServer.ImportRewritingTest do refute conn.resp_body =~ ~r/require\(['"]\.\/cjs-dep/ end + test "serves .cjs and .cts files as ES modules" do + File.write!(Path.join(@fixture_dir, "src/dep.cjs"), "module.exports = { answer: 42 }") + File.write!(Path.join(@fixture_dir, "src/typed.cts"), "const n: number = 7; export = { n }") + + for path <- ["/assets/dep.cjs", "/assets/typed.cts"] do + conn = call_dev_server(path) + assert conn.status == 200 + assert conn.resp_body =~ "export default" + end + end + test "leaves ES modules untouched" do File.write!(Path.join(@fixture_dir, "src/esm.js"), "export const module = { exports: 1 }") From e76f502b1f18de39bae287ea0bbaae85259318c0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:27:07 +0400 Subject: [PATCH 09/13] Stop watchers before the Tailwind processes they depend on The watcher supervisor started before the Tailwind registry and worker supervisor, so on shutdown the Tailwind processes stopped first and every watcher with Tailwind enabled crashed in terminate/2 with "unknown registry: Volt.Tailwind.Registry". Start the Tailwind processes first. This replaces the Process.whereis guard in Volt.Tailwind.Supervisor.release/1, which hid the ordering problem. --- CHANGELOG.md | 2 +- lib/volt/application.ex | 8 +++++--- lib/volt/tailwind/supervisor.ex | 12 +++--------- test/volt/application_test.exs | 22 ++++++++++++++++++++++ 4 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 test/volt/application_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19624bd..a44d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ - Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it. - Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request. - Read external globals once in code-split IIFE output. Each chunk also declared them at the top level of the script, where two chunks importing the same name would clash. -- Don't crash a watcher that terminates after Volt's Tailwind registry has stopped during application shutdown. +- Stop watchers before the Tailwind processes they release their contexts to. Volt's supervisor stopped the Tailwind registry first, so every watcher with Tailwind enabled crashed with `unknown registry: Volt.Tailwind.Registry` on shutdown. - Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package. ## 0.19.4 - 2026-10-03 diff --git a/lib/volt/application.ex b/lib/volt/application.ex index c613cc5..b1c20d9 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -12,13 +12,15 @@ defmodule Volt.Application do Volt.HMR.ModuleGraph.create_table() Volt.HMR.Errors.create_table() + # Watchers release their Tailwind contexts when they terminate, so the + # Tailwind processes start before them and stop after them. children = [ {Registry, keys: :duplicate, name: Volt.HMR.Registry}, - {Registry, keys: :unique, name: Volt.Dev.WatcherRegistry}, - {DynamicSupervisor, strategy: :one_for_one, name: Volt.Dev.WatcherSupervisor}, {Registry, keys: :unique, name: Volt.Tailwind.Registry}, {DynamicSupervisor, strategy: :one_for_one, name: Volt.Tailwind.WorkerSupervisor}, - Volt.Tailwind.Runtime + Volt.Tailwind.Runtime, + {Registry, keys: :unique, name: Volt.Dev.WatcherRegistry}, + {DynamicSupervisor, strategy: :one_for_one, name: Volt.Dev.WatcherSupervisor} ] opts = [strategy: :one_for_one, name: Volt.Supervisor] diff --git a/lib/volt/tailwind/supervisor.ex b/lib/volt/tailwind/supervisor.ex index 20cff31..f205b81 100644 --- a/lib/volt/tailwind/supervisor.ex +++ b/lib/volt/tailwind/supervisor.ex @@ -6,15 +6,9 @@ defmodule Volt.Tailwind.Supervisor do @doc "Release a compiler context without affecting other registered roots." def release(key) do - # A watcher can terminate after these have stopped during application - # shutdown, when there is nothing left to release. - if Process.whereis(@registry) && Process.whereis(@supervisor) do - case Registry.lookup(@registry, key) do - [] -> :ok - [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) - end - else - :ok + case Registry.lookup(@registry, key) do + [] -> :ok + [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) end end diff --git a/test/volt/application_test.exs b/test/volt/application_test.exs new file mode 100644 index 0000000..e99e7cf --- /dev/null +++ b/test/volt/application_test.exs @@ -0,0 +1,22 @@ +defmodule Volt.ApplicationTest do + use ExUnit.Case, async: true + + test "Tailwind processes start before watchers, so they stop after them" do + # `Supervisor.which_children/1` lists children in reverse start order. + started = + Volt.Supervisor + |> Supervisor.which_children() + |> Enum.map(fn {id, _pid, _type, _modules} -> id end) + |> Enum.reverse() + + position = fn id -> Enum.find_index(started, &(&1 == id)) end + + for tailwind <- [ + Volt.Tailwind.Registry, + Volt.Tailwind.WorkerSupervisor, + Volt.Tailwind.Runtime + ] do + assert position.(tailwind) < position.(Volt.Dev.WatcherSupervisor) + end + end +end From b9cf18661a921d0b03da1671bdfdf9cd5d32b7da Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:27:07 +0400 Subject: [PATCH 10/13] Walk test declarations with OXC.postwalk and count newlines once Volt.Test.Lines goes back to OXC.postwalk/3 instead of a hand-rolled traversal. Post-order reaches the tests in a describe.each body before the describe.each call, which folds them into one repeated group. Line numbers were found by counting newlines from the start of the source for every test. On a 110 KB file with 1,200 tests that took about 140 ms of 214 ms; a single pass over the newline offsets brings the whole function to about 81 ms, most of it OXC.parse. --- lib/volt/test/lines.ex | 88 +++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/lib/volt/test/lines.ex b/lib/volt/test/lines.ex index 5a4691b..c95ebb7 100644 --- a/lib/volt/test/lines.ex +++ b/lib/volt/test/lines.ex @@ -17,42 +17,58 @@ defmodule Volt.Test.Lines do """ @spec test_lines(String.t(), String.t()) :: {:ok, [pos_integer()]} | {:error, term()} def test_lines(source, filename) do - with {:ok, ast} <- OXC.parse(source, filename) do - {:ok, ast |> test_starts() |> Enum.map(&line(source, &1))} + with {:ok, ast} <- OXC.parse(source, filename), + {:ok, groups} <- collect_test_groups(ast) do + {:ok, lines(source, flatten_groups(groups))} end - catch - :dynamic_cases -> {:error, :dynamic_cases} end - defp test_starts(%{type: :call_expression, callee: callee, arguments: args} = node) - when is_list(args) do + # A group is `{anchor, starts}`: the offset of a test declaration and the + # offsets of the tests it registers, in registration order. Post-order + # traversal reaches the tests inside a `describe.each` body before the + # `describe.each` call, which then folds their groups into one repeated group. + defp collect_test_groups(ast) do + {_ast, acc} = + OXC.postwalk(ast, {:ok, []}, fn + %{type: :call_expression, callee: callee, arguments: args} = node, {:ok, groups} + when is_list(args) -> + {node, add_test_group(node, callee, args, groups)} + + node, acc -> + {node, acc} + end) + + acc + end + + defp add_test_group(node, callee, args, groups) do cond do test_callee?(callee) and test_call?(args) -> - [node.start] + {:ok, [{node.start, [node.start]} | groups]} each_callee?(callee, &test_callee?/1) and test_call?(args) -> - List.duplicate(node.start, case_count(callee)) + with {:ok, count} <- case_count(callee) do + {:ok, [{node.start, List.duplicate(node.start, count)} | groups]} + end each_callee?(callee, &describe_callee?/1) -> - args |> test_starts() |> List.duplicate(case_count(callee)) |> Enum.concat() + with {:ok, count} <- case_count(callee) do + {inner, outer} = + Enum.split_with(groups, fn {anchor, _starts} -> + anchor > node.start and anchor < node.end + end) + + starts = inner |> flatten_groups() |> List.duplicate(count) |> Enum.concat() + {:ok, [{node.start, starts} | outer]} + end true -> - child_starts(node) + {:ok, groups} end end - defp test_starts(node) when is_map(node), do: child_starts(node) - defp test_starts(nodes) when is_list(nodes), do: Enum.flat_map(nodes, &test_starts/1) - defp test_starts(_other), do: [] - - # Children in source order, which is the order the runtime runs them in. - defp child_starts(node) do - node - |> Map.values() - |> Enum.flat_map(&List.wrap/1) - |> Enum.filter(&match?(%{start: start} when is_integer(start), &1)) - |> Enum.sort_by(& &1.start) - |> Enum.flat_map(&test_starts/1) + defp flatten_groups(groups) do + groups |> Enum.sort() |> Enum.flat_map(fn {_anchor, starts} -> starts end) end defp test_callee?(%{type: :identifier, name: name}) when name in ["test", "it"], do: true @@ -87,21 +103,29 @@ defmodule Volt.Test.Lines do defp each_callee?(_callee, _base?), do: false defp case_count(%{arguments: [%{type: :array_expression, elements: elements}]}), - do: length(elements) + do: {:ok, length(elements)} - defp case_count(_each_call), do: throw(:dynamic_cases) + defp case_count(_each_call), do: {:error, :dynamic_cases} defp test_call?([%{value: name} | _]) when is_binary(name), do: true defp test_call?([%{type: :template_literal, expressions: [], quasis: [_]} | _]), do: true defp test_call?(_), do: false - defp line(source, start) do - source - |> binary_part(0, start) - |> count_newlines(1) + # Counts each newline once for all offsets. Counting from the start of the + # source per test is quadratic and dominated the cost on large files. + defp lines(source, starts) do + newlines = for {offset, _length} <- :binary.matches(source, "\n"), do: offset + + {line_by_start, _rest, _line} = + starts + |> Enum.uniq() + |> Enum.sort() + |> Enum.reduce({%{}, newlines, 1}, fn start, {lines, newlines, line} -> + {before, rest} = Enum.split_while(newlines, &(&1 < start)) + line = line + length(before) + {Map.put(lines, start, line), rest, line} + end) + + Enum.map(starts, &Map.fetch!(line_by_start, &1)) end - - defp count_newlines(<<>>, line), do: line - defp count_newlines(<>, line), do: count_newlines(rest, line + 1) - defp count_newlines(<<_byte, rest::binary>>, line), do: count_newlines(rest, line) end From 337d87bf0335b326cd7127250e5c1bf41ffa1282 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:37:08 +0400 Subject: [PATCH 11/13] Fix lint findings from CI mix lint failed on Elixir 1.20 / OTP 29, the job that runs it: - Credo: two single-item list appends. - Architecture policy: Volt.JS.CommonJS had no layer, and Volt.Config (model) called Volt.JS.Format (logic), closing a layer cycle. The check for formatter options left under config :volt, :format moves to Volt.Config, which Volt.JS.Format calls. - Smells: a case returning booleans in the dev server, and a member expression shape repeated across the callee matchers in Volt.Test.Lines, now one function. --- .reach.exs | 1 + lib/volt/config.ex | 21 +++++++++++++++++++-- lib/volt/dev_server.ex | 5 +---- lib/volt/js/format.ex | 19 +------------------ lib/volt/test/lines.ex | 31 +++++++++++++------------------ lib/volt/watcher/ignore.ex | 2 +- test/volt/js/vendor_test.exs | 2 +- 7 files changed, 37 insertions(+), 44 deletions(-) diff --git a/.reach.exs b/.reach.exs index 3556620..9d67e6e 100644 --- a/.reach.exs +++ b/.reach.exs @@ -99,6 +99,7 @@ logic = [ "Volt.HTMLEntry", "Volt.JS.AST", "Volt.JS.Check", + "Volt.JS.CommonJS", "Volt.JS.Lint.Config", "Volt.JS.Extensions", "Volt.JS.Format", diff --git a/lib/volt/config.ex b/lib/volt/config.ex index ab15e8d..c63efa3 100644 --- a/lib/volt/config.ex +++ b/lib/volt/config.ex @@ -139,8 +139,8 @@ defmodule Volt.Config do [] end - Volt.JS.Format.reject_application_config!(flat_env[:format]) - Volt.JS.Format.reject_application_config!(profile_env[:format]) + reject_formatter_options!(flat_env[:format]) + reject_formatter_options!(profile_env[:format]) config = @defaults @@ -154,6 +154,23 @@ defmodule Volt.Config do %{config | aliases: Map.merge(tsconfig_paths, config.aliases)} end + @doc false + def reject_formatter_options!(format) when is_list(format) do + raise ArgumentError, """ + formatter options are no longer read from `config :volt, :format`, which now \ + only holds the build output format (`:iife`, `:esm`, or `:cjs`). + + Move them to the `:volt` key of .formatter.exs: + + [ + plugins: [Volt.Formatter], + volt: #{inspect(format)} + ] + """ + end + + def reject_formatter_options!(_format), do: :ok + @doc """ Read dev server config, merged with defaults. diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index f200c60..c6c45aa 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -557,10 +557,7 @@ defmodule Volt.DevServer do end defp requires_modules?(source, file_path) do - case OXC.select(source, Path.basename(file_path), :require_calls) do - {:ok, []} -> false - _ -> true - end + not match?({:ok, []}, OXC.select(source, Path.basename(file_path), :require_calls)) end defp pipeline_opts(config, importer) do diff --git a/lib/volt/js/format.ex b/lib/volt/js/format.ex index 2facbf7..51058c7 100644 --- a/lib/volt/js/format.ex +++ b/lib/volt/js/format.ex @@ -44,7 +44,7 @@ defmodule Volt.JS.Format do """ @spec load_config(keyword()) :: keyword() def load_config(formatter_opts \\ formatter_opts()) do - reject_application_config!(Application.get_env(:volt, :format)) + Volt.Config.reject_formatter_options!(Application.get_env(:volt, :format)) case Keyword.fetch(formatter_opts, :volt) do {:ok, opts} -> Keyword.drop(opts, @discovery_keys) @@ -72,23 +72,6 @@ defmodule Volt.JS.Format do formatter_opts |> Keyword.get(:volt, []) |> Keyword.take(@discovery_keys) end - @doc false - def reject_application_config!(format) when is_list(format) do - raise ArgumentError, """ - formatter options are no longer read from `config :volt, :format`, which now \ - only holds the build output format (`:iife`, `:esm`, or `:cjs`). - - Move them to the `:volt` key of .formatter.exs: - - [ - plugins: [Volt.Formatter], - volt: #{inspect(format)} - ] - """ - end - - def reject_application_config!(_format), do: :ok - def load_json_config do case find_json_config() do nil -> [] diff --git a/lib/volt/test/lines.ex b/lib/volt/test/lines.ex index c95ebb7..390242f 100644 --- a/lib/volt/test/lines.ex +++ b/lib/volt/test/lines.ex @@ -71,34 +71,29 @@ defmodule Volt.Test.Lines do groups |> Enum.sort() |> Enum.flat_map(fn {_anchor, starts} -> starts end) end - defp test_callee?(%{type: :identifier, name: name}) when name in ["test", "it"], do: true + @test_names ["test", "it"] + @describe_names ["describe"] - defp test_callee?(%{type: :member_expression, object: object, property: %{name: property}}) - when property in ["skip", "todo"] do - test_callee?(object) - end - - defp test_callee?(_), do: false + defp test_callee?(callee), do: callee_named?(callee, @test_names) + defp describe_callee?(callee), do: callee_named?(callee, @describe_names) - defp describe_callee?(%{type: :identifier, name: "describe"}), do: true + # `test`, `test.skip`, `test.todo`, and the same for `it` and `describe`. + defp callee_named?(%{type: :identifier, name: name}, names), do: name in names - defp describe_callee?(%{type: :member_expression, object: object, property: %{name: property}}) - when property in ["skip", "todo"] do - describe_callee?(object) + defp callee_named?(%{type: :member_expression, object: object, property: property}, names) do + property[:name] in ["skip", "todo"] and callee_named?(object, names) end - defp describe_callee?(_), do: false + defp callee_named?(_callee, _names), do: false # `test.each(cases)` and `describe.each(cases)`, whose result is then called # with the name and body. defp each_callee?( - %{ - type: :call_expression, - callee: %{type: :member_expression, object: object, property: %{name: "each"}} - }, + %{type: :call_expression, callee: %{type: :member_expression} = callee}, base? - ), - do: base?.(object) + ) do + callee.property[:name] == "each" and base?.(callee.object) + end defp each_callee?(_callee, _base?), do: false diff --git a/lib/volt/watcher/ignore.ex b/lib/volt/watcher/ignore.ex index 9f0c7b9..906f78f 100644 --- a/lib/volt/watcher/ignore.ex +++ b/lib/volt/watcher/ignore.ex @@ -16,7 +16,7 @@ defmodule Volt.Watcher.Ignore do # Configured patterns also resolve from the project directory, so that # `_build/**` matches a watch root that itself lives inside `_build`. def compile_explicit(patterns, roots), - do: compile_patterns(patterns, Enum.uniq(roots ++ [File.cwd!()])) + do: compile_patterns(patterns, Enum.uniq([File.cwd!() | roots])) defp compile_patterns(patterns, roots) do patterns diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index 7235edc..45f8ff3 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -65,7 +65,7 @@ defmodule Volt.JS.VendorTest do opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules] assert ExUnit.CaptureLog.capture_log(fn -> - Volt.JS.Vendor.prebundle(opts ++ [force: true]) + Volt.JS.Vendor.prebundle(Keyword.put(opts, :force, true)) end) =~ "Pre-bundled" From ccef434341f02a3f0f839e49f77383794c25f466 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:41:22 +0400 Subject: [PATCH 12/13] Format Volt's own TypeScript through mix format Volt's .formatter.exs held the formatter options but did not list Volt.Formatter, so mix format never exercised the plugin on Volt's own sources. Add the plugin and the priv/ts sources that mix volt.js.format already covers. --- .formatter.exs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.formatter.exs b/.formatter.exs index 55a8b68..5e3c114 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,6 +1,11 @@ # Used by "mix format" [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + plugins: [Volt.Formatter], + inputs: [ + "{mix,.formatter}.exs", + "{config,lib,test}/**/*.{ex,exs}", + "priv/ts/{client,compilers,test}/**/*.ts" + ], volt: [ trailing_comma: :none, tab_width: 2, From 895ca2bad1f91b2c5840fe6c980ba3ae74237d0f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 13:47:43 +0400 Subject: [PATCH 13/13] Compile before checking formatting in mix lint mix format loads a project's own formatter plugin from the existing build and compiles only when the module cannot be loaded. CI restores a cached _build, so it formatted Volt's TypeScript with the previous Volt.Formatter, which did not read options from .formatter.exs, and reported every file as unformatted. --- mix.exs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mix.exs b/mix.exs index aee828d..58427bf 100644 --- a/mix.exs +++ b/mix.exs @@ -68,6 +68,10 @@ defmodule Volt.MixProject do defp aliases do [ lint: [ + # `mix format` loads Volt.Formatter from the existing build and only + # compiles when the module is missing, so a cached build would format + # with stale plugin code. + "compile", "format --check-formatted", "volt.js.check --type-aware --type-check", "credo --strict",