From 74e2e6c4f3a8cb6cd997e636bf0bd1d0b7475503 Mon Sep 17 00:00:00 2001 From: Franciszek Madej Date: Thu, 15 Oct 2026 18:15:15 +0200 Subject: [PATCH] Honour package sideEffects when tree-shaking production bundles Production bundles are built from a virtual project without the packages' package.json files, so Rolldown could not see sideEffects and kept every module that runs code at the top level, such as icons wrapped in forwardRef. Write a minimal package.json carrying sideEffects at each bundled package's root. --- lib/volt/builder.ex | 5 ++- lib/volt/builder/compiled.ex | 2 +- lib/volt/builder/output.ex | 36 +++++++++++++----- lib/volt/builder/package_manifests.ex | 53 ++++++++++++++++++++++++++ test/volt/builder/resolution_test.exs | 54 +++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 12 deletions(-) create mode 100644 lib/volt/builder/package_manifests.ex diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 3eea110..53887ed 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -615,8 +615,9 @@ defmodule Volt.Builder do # ── Module compilation ────────────────────────────────────────────── defp compile_all(modules, _target, ctx) do - with {:ok, compiled} <- compile_modules(modules, ctx) do - merge_compiled(compiled) + with {:ok, compiled} <- compile_modules(modules, ctx), + {:ok, merged} <- merge_compiled(compiled) do + {:ok, %{merged | package_manifests: Volt.Builder.PackageManifests.files(modules)}} end end diff --git a/lib/volt/builder/compiled.ex b/lib/volt/builder/compiled.ex index 35561d7..6293ceb 100644 --- a/lib/volt/builder/compiled.ex +++ b/lib/volt/builder/compiled.ex @@ -1,5 +1,5 @@ defmodule Volt.Builder.Compiled do @moduledoc "Compiled modules and prepared binary assets before bundling." - defstruct scripts: [], styles: [], assets: [], artifacts: [] + defstruct scripts: [], styles: [], assets: [], artifacts: [], package_manifests: [] end diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index ef3d8c1..9c08590 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -6,7 +6,12 @@ defmodule Volt.Builder.Output do @doc "Bundle modules into a single in-memory JS bundle." def bundle_single( entry, - %Volt.Builder.Compiled{scripts: js_files, styles: css_parts, assets: assets}, + %Volt.Builder.Compiled{ + scripts: js_files, + styles: css_parts, + assets: assets, + package_manifests: package_manifests + }, files, build_ctx ) do @@ -26,7 +31,7 @@ defmodule Volt.Builder.Output do |> Keyword.put(:entry, entry_label) |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) - case bundle_js_files(js_files, bundle_opts) do + case bundle_js_files(js_files ++ package_manifests, bundle_opts) do {:ok, bundle_result} -> {js_code, js_sourcemap} = extract_bundle_result(bundle_result) js_code = Rewriter.inject_external_preamble(js_code, js_files, ctx) @@ -79,7 +84,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, build_ctx ) do @@ -100,7 +106,7 @@ defmodule Volt.Builder.Output do |> Keyword.put(:entry, entry_label) |> put_external_imports(Rewriter.preserved_external_imports(js_files, ctx)) - case bundle_js_files(js_files, bundle_opts) do + case bundle_js_files(js_files ++ package_manifests, bundle_opts) do {:ok, bundle_result} -> {js_code, js_sourcemap} = extract_bundle_result(bundle_result) js_code = Rewriter.inject_external_preamble(js_code, js_files, ctx) @@ -159,7 +165,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, modules, path_labels, @@ -178,7 +185,7 @@ defmodule Volt.Builder.Output do bundle = OXC.Bundle.new( entries: shared_bundle_entries(entries, path_labels), - files: js_files, + files: js_files ++ package_manifests, format: Keyword.get(bundle_opts, :format, :esm), minify: Keyword.get(bundle_opts, :minify, false), sourcemap: Keyword.get(bundle_opts, :sourcemap, false), @@ -397,7 +404,8 @@ defmodule Volt.Builder.Output do scripts: js_files, styles: css_parts, assets: assets, - artifacts: asset_artifacts + artifacts: asset_artifacts, + package_manifests: package_manifests }, {modules, dep_map}, build_ctx @@ -421,6 +429,7 @@ defmodule Volt.Builder.Output do graph.chunks, js_map, module_labels, + package_manifests, bundle_opts, ctx, graph, @@ -781,7 +790,16 @@ defmodule Volt.Builder.Output do Path.relative_to(to_label, from_dir) end - defp build_chunk_bundles(chunks, js_map, module_labels, bundle_opts, ctx, graph, dep_map) do + defp build_chunk_bundles( + chunks, + js_map, + module_labels, + package_manifests, + bundle_opts, + ctx, + graph, + dep_map + ) do Enum.reduce_while(chunks, {:ok, %{}}, fn {chunk_id, chunk}, {:ok, acc} -> chunk_js = select_chunk_files(chunk.modules, js_map, module_labels) @@ -803,7 +821,7 @@ defmodule Volt.Builder.Output do |> put_external_imports(external) |> put_external_imports(Rewriter.preserved_external_imports(chunk_js, ctx)) - case bundle_js_files(chunk_js, bundle_opts) do + case bundle_js_files(chunk_js ++ package_manifests, bundle_opts) do {:ok, result} -> {code, sourcemap} = extract_bundle_result(result) code = Rewriter.restore_dynamic_imports(code, dynamic_import_placeholder) diff --git a/lib/volt/builder/package_manifests.ex b/lib/volt/builder/package_manifests.ex new file mode 100644 index 0000000..6cfcf6e --- /dev/null +++ b/lib/volt/builder/package_manifests.ex @@ -0,0 +1,53 @@ +defmodule Volt.Builder.PackageManifests do + @moduledoc """ + Carries the `sideEffects` field of bundled packages into the bundle input. + + Modules are bundled from a virtual project in which a package's files sit + under the package name (see `Volt.Builder.Naming.module_label/2`), without + the package's `package.json`. Rolldown reads `sideEffects` from the nearest + `package.json`, so without one it keeps every module that runs code at the + top level, such as each icon of an icon library wrapped in `forwardRef`. A + minimal manifest at the package root restores what the package declared. + """ + + @doc """ + Returns a `{label, json}` virtual file for each package of `modules` whose + `package.json` declares `sideEffects`. + """ + @spec files([{String.t(), String.t(), term()}]) :: [{String.t(), String.t()}] + def files(modules) do + modules + |> Enum.flat_map(fn {path, _label, _source} -> package_root(path) end) + |> Enum.uniq() + |> Enum.flat_map(&manifest_file/1) + end + + defp package_root(module_id) do + {path, _query} = Volt.URL.split_query(module_id) + + case path |> :binary.matches("/node_modules/") |> List.last() do + nil -> + [] + + {position, length} -> + root_end = position + length + name = package_name(binary_part(path, root_end, byte_size(path) - root_end)) + [{name, binary_part(path, 0, root_end) <> name}] + end + end + + defp package_name("@" <> _ = relative_path), + do: relative_path |> String.split("/") |> Enum.take(2) |> Enum.join("/") + + defp package_name(relative_path), do: relative_path |> String.split("/") |> hd() + + defp manifest_file({name, dir}) do + with {:ok, json} <- File.read(Path.join(dir, "package.json")), + {:ok, %{"sideEffects" => side_effects}} <- Jason.decode(json) do + manifest = Jason.encode!(%{"sideEffects" => side_effects}) + [{Volt.Builder.Naming.file_path(name) <> "/package.json", manifest}] + else + _ -> [] + end + end +end diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index 95ccaf0..9ba67c1 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -84,6 +84,60 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "b-" end + test "drops unused modules of packages declaring no side effects" do + for {package, side_effects} <- [{"pure-icons", "false"}, {"@scope/effectful-icons", "true"}] do + package_dir = Path.join(@fixture_dir, "node_modules/#{package}") + File.mkdir_p!(package_dir) + + File.write!( + Path.join(package_dir, "package.json"), + ~s({"name":"#{package}","type":"module","main":"index.js","sideEffects":#{side_effects}}) + ) + + File.write!(Path.join(package_dir, "wrap.js"), """ + export function wrap(render) { + globalThis.registered = (globalThis.registered || 0) + 1 + return { render } + } + """) + + File.write!(Path.join(package_dir, "index.js"), """ + export { default as Used } from './used.js' + export { default as Unused } from './unused.js' + """) + + File.write!(Path.join(package_dir, "used.js"), """ + import { wrap } from './wrap.js' + export default wrap(() => '#{package}-used-icon') + """) + + File.write!(Path.join(package_dir, "unused.js"), """ + import { wrap } from './wrap.js' + export default wrap(() => '#{package}-unused-icon') + """) + end + + File.write!(Path.join(@fixture_dir, "src/icons_app.ts"), """ + import { Used } from 'pure-icons' + import { Used as EffectfulUsed } from '@scope/effectful-icons' + console.log(Used, EffectfulUsed) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/icons_app.ts"), + outdir: @outdir, + minify: false, + sourcemap: false, + node_modules: Path.join(@fixture_dir, "node_modules") + ) + + js = File.read!(result.js.path) + assert js =~ "pure-icons-used-icon" + refute js =~ "pure-icons-unused-icon" + assert js =~ "@scope/effectful-icons-unused-icon" + end + test "resolves bare imports from the importer's scoped package directory" do app_packages = Path.join(@fixture_dir, "node_modules") framework_root = Path.join(@fixture_dir, "framework")