From 8f8d6d76774b45bfec35406174fd597a21e4e826 Mon Sep 17 00:00:00 2001 From: Flower Ornament <31774+flowerornament@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:28:01 -0700 Subject: [PATCH 1/2] Fix entry chunks with dynamic imports --- lib/volt/builder.ex | 42 +++++++++++++++++++++- lib/volt/builder/output.ex | 23 ++++++------ lib/volt/builder/rewriter.ex | 46 ++++++++++++++++++++++++ test/builder_test.exs | 70 ++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 11 deletions(-) diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index b2c6a11..3364a65 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -338,6 +338,8 @@ defmodule Volt.Builder do end defp rewrite_imports_to_labels(code, label_map, from_label, global_map) do + code = rewrite_dynamic_css_imports(code) + case OXC.rewrite_specifiers(code, "module.js", fn specifier -> rewrite_specifier(specifier, label_map, from_label, global_map) end) do @@ -347,9 +349,47 @@ defmodule Volt.Builder do end @css_exts Volt.JS.Extensions.css() + @css_import_noop "data:text/javascript,export{}" + @dynamic_css_import_noop "Promise.resolve({ default: undefined })" + + defp rewrite_dynamic_css_imports(code) do + case OXC.parse(code, "module.js") do + {:ok, ast} -> + patches = collect_dynamic_css_import_patches(ast) + if patches == [], do: code, else: OXC.patch_string(code, patches) + + {:error, _} -> + code + end + end + + defp collect_dynamic_css_import_patches(ast) do + {_ast, patches} = + OXC.postwalk(ast, [], fn + %{ + type: :import_expression, + source: %{type: :literal, value: spec}, + start: start, + end: finish + } = node, + patches + when is_binary(spec) and is_integer(start) and is_integer(finish) -> + if Path.extname(spec) in @css_exts do + {node, [%{start: start, end: finish, change: @dynamic_css_import_noop} | patches]} + else + {node, patches} + end + + node, patches -> + {node, patches} + end) + + patches + end + defp rewrite_specifier(specifier, label_map, from_label, global_map) do if Path.extname(specifier) in @css_exts do - {:rewrite, "data:text/css,"} + {:rewrite, @css_import_noop} else case Map.fetch(label_map, specifier) do {:ok, new_label} -> diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index faaa35b..d84ec7b 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -63,9 +63,10 @@ defmodule Volt.Builder.Output do graph = Volt.ChunkGraph.build(entry, modules, dep_map, manual_chunks: manual_chunks) js_map = Map.new(js_files) + module_labels = Map.new(modules, fn {path, label, _source} -> {path, label} end) with {:ok, chunk_bundles} <- - build_chunk_bundles(graph.chunks, js_map, bundle_opts, ctx, graph) do + build_chunk_bundles(graph.chunks, js_map, module_labels, bundle_opts, ctx, graph) do chunk_url_map = Map.new(chunk_bundles, fn {chunk_id, {_code, _sourcemap}} -> chunk = graph.chunks[chunk_id] @@ -78,7 +79,7 @@ defmodule Volt.Builder.Output do js_results = Enum.map(chunk_bundles, fn {chunk_id, {code, sourcemap}} -> chunk = graph.chunks[chunk_id] - chunk_js = select_chunk_files(chunk.modules, js_map) + chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) code = Rewriter.inject_external_preamble(code, chunk_js, ctx) code = Rewriter.rewrite_chunk_imports(code, graph.module_to_chunk, chunk_url_map) @@ -131,14 +132,15 @@ defmodule Volt.Builder.Output do end end - defp build_chunk_bundles(chunks, js_map, bundle_opts, ctx, graph) do + defp build_chunk_bundles(chunks, js_map, module_labels, bundle_opts, ctx, graph) do Enum.reduce_while(chunks, {:ok, %{}}, fn {chunk_id, chunk}, {:ok, acc} -> - chunk_js = select_chunk_files(chunk.modules, js_map) + chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) if chunk_js == [] do {:cont, {:ok, acc}} else chunk_js = Rewriter.rewrite_external_imports(chunk_js, ctx) + chunk_js = Rewriter.protect_dynamic_imports(chunk_js) external = Rewriter.external_chunk_imports(chunk_js, graph.module_to_chunk, chunk_id) @@ -150,6 +152,7 @@ defmodule Volt.Builder.Output do case bundle_js_files(chunk_js, bundle_opts) do {:ok, result} -> {code, sourcemap} = BundleResult.extract(result) + code = Rewriter.restore_dynamic_imports(code) {:cont, {:ok, Map.put(acc, chunk_id, {code, sourcemap})}} {:error, errors} -> @@ -183,14 +186,14 @@ defmodule Volt.Builder.Output do defp chunk_entry_label([{label, _code} | _]), do: label - defp select_chunk_files(module_paths, js_map) do + defp select_chunk_files(module_paths, js_map, module_labels) do module_paths |> Enum.flat_map(fn mod_path -> - case Enum.find(js_map, fn {label, _} -> - String.ends_with?(mod_path, "/" <> label) or mod_path == label - end) do - {label, code} -> [{label, code}] - nil -> [] + with label when is_binary(label) <- module_labels[mod_path], + code when is_binary(code) <- js_map[label] do + [{label, code}] + else + _ -> [] end end) end diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index a73f773..026bc14 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -3,6 +3,9 @@ defmodule Volt.Builder.Rewriter do alias Volt.Builder.Externals + @dynamic_import_keyword "import" + @dynamic_import_placeholder "__volt_dynamic_import__" + def rewrite_external_imports(js_files, ctx) do if MapSet.size(ctx.external_set) == 0 do js_files @@ -19,6 +22,16 @@ defmodule Volt.Builder.Rewriter do |> Enum.uniq() end + def protect_dynamic_imports(js_files) do + Enum.map(js_files, fn {label, code} -> + {label, protect_dynamic_imports_in_code(code)} + end) + end + + def restore_dynamic_imports(code) do + String.replace(code, @dynamic_import_placeholder <> "(", @dynamic_import_keyword <> "(") + end + def inject_external_preamble(code, js_files, ctx) do if MapSet.size(ctx.external_set) == 0 do code @@ -131,6 +144,38 @@ defmodule Volt.Builder.Rewriter do end end + defp protect_dynamic_imports_in_code(code) do + case OXC.parse(code, "chunk.js") do + {:ok, ast} -> + patches = collect_dynamic_import_protection_patches(ast) + if patches == [], do: code, else: OXC.patch_string(code, patches) + + {:error, _} -> + code + end + end + + defp collect_dynamic_import_protection_patches(ast) do + {_ast, patches} = + OXC.postwalk(ast, [], fn + %{type: :import_expression, start: start} = node, patches when is_integer(start) -> + {node, [dynamic_import_protection_patch(start) | patches]} + + node, patches -> + {node, patches} + end) + + patches + end + + defp dynamic_import_protection_patch(start) do + %{ + start: start, + end: start + byte_size(@dynamic_import_keyword), + change: @dynamic_import_placeholder + } + end + defp collect_import_patches(ast, module_to_chunk, chunk_url_map) do {_ast, patches} = OXC.postwalk(ast, [], fn @@ -200,6 +245,7 @@ defmodule Volt.Builder.Rewriter do spec |> String.trim_leading("./") |> String.trim_leading("../") + |> String.trim_leading("_external/") |> Path.rootname() Enum.find_value(module_to_chunk, fn {mod_path, chunk_id} -> diff --git a/test/builder_test.exs b/test/builder_test.exs index 29138a5..63d1516 100644 --- a/test/builder_test.exs +++ b/test/builder_test.exs @@ -456,6 +456,76 @@ defmodule Volt.BuilderTest do assert manifest["dynamic-entry.js"]["file"] == "dynamic-entry.js" end + test "code splitting includes alias modules outside the entry root" do + File.mkdir_p!(Path.join(@fixture_dir, "shared")) + + File.write!(Path.join(@fixture_dir, "shared/rendered.ts"), """ + export const rendered = 'rendered-from-shared-root' + """) + + File.write!(Path.join(@fixture_dir, "src/lazy.ts"), """ + export const lazyValue = 'lazy-loaded' + """) + + File.write!(Path.join(@fixture_dir, "src/external_alias_entry.ts"), """ + import { rendered } from '@shared/rendered' + + document.body.dataset.rendered = rendered + + import('./lazy').then((mod) => { + document.body.dataset.lazy = mod.lazyValue + }) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/external_alias_entry.ts"), + outdir: @outdir, + name: "external-alias-entry", + format: :esm, + hash: false, + minify: false, + sourcemap: false, + aliases: %{"@shared" => Path.join(@fixture_dir, "shared")} + ) + + assert File.regular?(result.js.path) + + entry_js = File.read!(Path.join(@outdir, "external-alias-entry.js")) + assert entry_js =~ "rendered-from-shared-root" + assert entry_js =~ ~r/import\(["']\.\/external-alias-entry-lazy\.js["']\)/ + + lazy_js = File.read!(Path.join(@outdir, "external-alias-entry-lazy.js")) + assert lazy_js =~ "lazy-loaded" + end + + test "dynamic CSS imports become inert browser-loadable modules" do + File.write!(Path.join(@fixture_dir, "src/theme.css"), "body { color: red }") + + File.write!(Path.join(@fixture_dir, "src/dynamic_css_entry.ts"), """ + import('./theme.css').then(() => { + document.body.dataset.css = 'loaded' + }) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/dynamic_css_entry.ts"), + outdir: @outdir, + name: "dynamic-css-entry", + format: :esm, + hash: false, + minify: false, + sourcemap: false + ) + + js = File.read!(result.js.path) + assert js =~ "Promise.resolve({ default: undefined })" + refute js =~ "import(" + refute js =~ "data:text/css" + refute js =~ "color: red" + end + test "eager import.meta.glob dependencies resolve from original source directory" do File.mkdir_p!(Path.join(@fixture_dir, "src/components")) From cff4e66375065b4737f1b5a0db0e59c89abeac31 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 1 May 2026 17:29:14 +0300 Subject: [PATCH 2/2] Fix minified dynamic chunk imports --- lib/volt/builder/output.ex | 4 +-- lib/volt/builder/rewriter.ex | 53 +++++++++++++++++++++------- test/builder_test.exs | 67 ++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index d84ec7b..c214f0e 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -140,7 +140,7 @@ defmodule Volt.Builder.Output do {:cont, {:ok, acc}} else chunk_js = Rewriter.rewrite_external_imports(chunk_js, ctx) - chunk_js = Rewriter.protect_dynamic_imports(chunk_js) + {chunk_js, dynamic_import_placeholder} = Rewriter.protect_dynamic_imports(chunk_js) external = Rewriter.external_chunk_imports(chunk_js, graph.module_to_chunk, chunk_id) @@ -152,7 +152,7 @@ defmodule Volt.Builder.Output do case bundle_js_files(chunk_js, bundle_opts) do {:ok, result} -> {code, sourcemap} = BundleResult.extract(result) - code = Rewriter.restore_dynamic_imports(code) + code = Rewriter.restore_dynamic_imports(code, dynamic_import_placeholder) {:cont, {:ok, Map.put(acc, chunk_id, {code, sourcemap})}} {:error, errors} -> diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index 026bc14..cd049bd 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -4,7 +4,7 @@ defmodule Volt.Builder.Rewriter do alias Volt.Builder.Externals @dynamic_import_keyword "import" - @dynamic_import_placeholder "__volt_dynamic_import__" + @dynamic_import_placeholder_prefix "__volt_dynamic_import__" def rewrite_external_imports(js_files, ctx) do if MapSet.size(ctx.external_set) == 0 do @@ -23,13 +23,18 @@ defmodule Volt.Builder.Rewriter do end def protect_dynamic_imports(js_files) do - Enum.map(js_files, fn {label, code} -> - {label, protect_dynamic_imports_in_code(code)} - end) + placeholder = dynamic_import_placeholder(js_files) + + protected = + Enum.map(js_files, fn {label, code} -> + {label, protect_dynamic_imports_in_code(code, placeholder)} + end) + + {protected, placeholder} end - def restore_dynamic_imports(code) do - String.replace(code, @dynamic_import_placeholder <> "(", @dynamic_import_keyword <> "(") + def restore_dynamic_imports(code, placeholder) do + String.replace(code, placeholder <> "(", @dynamic_import_keyword <> "(") end def inject_external_preamble(code, js_files, ctx) do @@ -144,10 +149,20 @@ defmodule Volt.Builder.Rewriter do end end - defp protect_dynamic_imports_in_code(code) do + defp dynamic_import_placeholder(js_files) do + code = Enum.map_join(js_files, "\n", fn {_label, code} -> code end) + + Stream.iterate(0, &(&1 + 1)) + |> Enum.find_value(fn suffix -> + candidate = @dynamic_import_placeholder_prefix <> Integer.to_string(suffix) <> "__" + if String.contains?(code, candidate), do: nil, else: candidate + end) + end + + defp protect_dynamic_imports_in_code(code, placeholder) do case OXC.parse(code, "chunk.js") do {:ok, ast} -> - patches = collect_dynamic_import_protection_patches(ast) + patches = collect_dynamic_import_protection_patches(ast, placeholder) if patches == [], do: code, else: OXC.patch_string(code, patches) {:error, _} -> @@ -155,11 +170,11 @@ defmodule Volt.Builder.Rewriter do end end - defp collect_dynamic_import_protection_patches(ast) do + defp collect_dynamic_import_protection_patches(ast, placeholder) do {_ast, patches} = OXC.postwalk(ast, [], fn %{type: :import_expression, start: start} = node, patches when is_integer(start) -> - {node, [dynamic_import_protection_patch(start) | patches]} + {node, [dynamic_import_protection_patch(start, placeholder) | patches]} node, patches -> {node, patches} @@ -168,11 +183,11 @@ defmodule Volt.Builder.Rewriter do patches end - defp dynamic_import_protection_patch(start) do + defp dynamic_import_protection_patch(start, placeholder) do %{ start: start, end: start + byte_size(@dynamic_import_keyword), - change: @dynamic_import_placeholder + change: placeholder } end @@ -194,6 +209,20 @@ defmodule Volt.Builder.Rewriter do when is_binary(spec) -> maybe_patch_specifier(node, patches, spec, s, e, module_to_chunk, chunk_url_map) + %{ + type: :import_expression, + source: %{ + type: :template_literal, + expressions: [], + quasis: [%{value: %{cooked: spec}}], + start: s, + end: e + } + } = node, + patches + when is_binary(spec) -> + maybe_patch_specifier(node, patches, spec, s, e, module_to_chunk, chunk_url_map) + node, patches -> {node, patches} end) diff --git a/test/builder_test.exs b/test/builder_test.exs index 63d1516..a03b6a2 100644 --- a/test/builder_test.exs +++ b/test/builder_test.exs @@ -456,6 +456,73 @@ defmodule Volt.BuilderTest do assert manifest["dynamic-entry.js"]["file"] == "dynamic-entry.js" end + test "code splitting rewrites minified dynamic import chunk URLs" do + File.write!( + Path.join(@fixture_dir, "src/lazy.ts"), + "export const lazyValue = 'lazy-loaded'" + ) + + File.write!(Path.join(@fixture_dir, "src/minified_dynamic_entry.ts"), """ + import('./lazy').then((mod) => { + document.body.dataset.lazy = mod.lazyValue + }) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/minified_dynamic_entry.ts"), + outdir: @outdir, + name: "minified-dynamic-entry", + format: :esm, + hash: false, + sourcemap: false + ) + + assert File.regular?(result.js.path) + + entry_js = File.read!(Path.join(@outdir, "minified-dynamic-entry.js")) + assert entry_js =~ "minified-dynamic-entry-lazy.js" + refute entry_js =~ "lazy.ts" + refute entry_js =~ ~r/import\([`'"]\.\/lazy[`'"]\)/ + end + + test "dynamic import protection avoids user identifier collisions" do + File.write!( + Path.join(@fixture_dir, "src/lazy.ts"), + "export const lazyValue = 'lazy-loaded'" + ) + + File.write!(Path.join(@fixture_dir, "src/placeholder_collision_entry.ts"), """ + function __volt_dynamic_import__0__(value: string) { + return value + } + + document.body.dataset.placeholder = __volt_dynamic_import__0__('kept') + + import('./lazy').then((mod) => { + document.body.dataset.lazy = mod.lazyValue + }) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/placeholder_collision_entry.ts"), + outdir: @outdir, + name: "placeholder-collision-entry", + format: :esm, + hash: false, + minify: false, + sourcemap: false + ) + + assert File.regular?(result.js.path) + + entry_js = File.read!(Path.join(@outdir, "placeholder-collision-entry.js")) + assert entry_js =~ "function __volt_dynamic_import__0__" + refute entry_js =~ "function import" + assert entry_js =~ ~r/import\([`'"]\.\/placeholder-collision-entry-lazy\.js[`'"]\)/ + end + test "code splitting includes alias modules outside the entry root" do File.mkdir_p!(Path.join(@fixture_dir, "shared"))