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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion lib/volt/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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} ->
Expand Down
23 changes: 13 additions & 10 deletions lib/volt/builder/output.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)

Expand Down Expand Up @@ -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, dynamic_import_placeholder} = Rewriter.protect_dynamic_imports(chunk_js)

external = Rewriter.external_chunk_imports(chunk_js, graph.module_to_chunk, chunk_id)

Expand All @@ -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, dynamic_import_placeholder)
{:cont, {:ok, Map.put(acc, chunk_id, {code, sourcemap})}}

{:error, errors} ->
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions lib/volt/builder/rewriter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ defmodule Volt.Builder.Rewriter do

alias Volt.Builder.Externals

@dynamic_import_keyword "import"
@dynamic_import_placeholder_prefix "__volt_dynamic_import__"

def rewrite_external_imports(js_files, ctx) do
if MapSet.size(ctx.external_set) == 0 do
js_files
Expand All @@ -19,6 +22,21 @@ defmodule Volt.Builder.Rewriter do
|> Enum.uniq()
end

def protect_dynamic_imports(js_files) do
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, placeholder) do
String.replace(code, placeholder <> "(", @dynamic_import_keyword <> "(")
end

def inject_external_preamble(code, js_files, ctx) do
if MapSet.size(ctx.external_set) == 0 do
code
Expand Down Expand Up @@ -131,6 +149,48 @@ defmodule Volt.Builder.Rewriter do
end
end

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, placeholder)
if patches == [], do: code, else: OXC.patch_string(code, patches)

{:error, _} ->
code
end
end

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, placeholder) | patches]}

node, patches ->
{node, patches}
end)

patches
end

defp dynamic_import_protection_patch(start, placeholder) do
%{
start: start,
end: start + byte_size(@dynamic_import_keyword),
change: placeholder
}
end

defp collect_import_patches(ast, module_to_chunk, chunk_url_map) do
{_ast, patches} =
OXC.postwalk(ast, [], fn
Expand All @@ -149,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)
Expand Down Expand Up @@ -200,6 +274,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} ->
Expand Down
137 changes: 137 additions & 0 deletions test/builder_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,143 @@ 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"))

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"))

Expand Down