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
1 change: 1 addition & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ infrastructure = [
"Volt.Dev.ConsoleForwarder.Payload",
"Volt.ETS",
"Volt.HMR.Boundary",
"Volt.HMR.Documents",
"Volt.HMR.Errors",
"Volt.HMR.GlobGraph",
"Volt.HMR.ImportGraph",
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

- Require vize 0.17.1, which fixes three Vapor compiler bugs. Two can affect components compiled with `vapor: true`: static attribute values were decoded twice in Vapor templates, and default slot content beside a named `<template #name>` was dropped. Volt's use of vize is unchanged.

### Added

- `plug Volt.DevServer, document: {module, function, args}` lets the dev server render pages itself. With `:morph`, each open page's websocket process keeps the HTML the server last rendered for it, renders the page again when a template or content file changes, and compares the two renders. A page is then sent only what applies to it: nothing, a reload, or the new HTML to patch. Pages no longer request themselves to find out.
- With `:document`, the attributes the server changed on `<html>` and `<body>` and the metadata and links it changed in `<head>` are applied too. Attributes that scripts set on those elements, such as a theme, are left alone, because the comparison only sees what the server rendered.
- When the server changed the attributes of an element matched by `morph: [preserve: selector]`, such as the props of a mounted component, the client sets them and dispatches a cancelable `volt:element-update` event on the element instead of reloading. The page reloads only if nothing handles the event.

### Fixed

- Leave Vue `<script>` blocks in other languages, such as `<script lang="elixir">`, out of the script modules that `mix volt.js.check` lints and type-checks. Every block was treated as JavaScript, so such a block was reported as TypeScript syntax errors. `lang="jsx"` blocks are now checked as JSX ([#57](https://github.com/elixir-volt/volt/issues/57)).
Expand All @@ -14,7 +20,6 @@
- Stop the dev server scanning sources and pre-bundling packages on every request. Phoenix initializes plugs per request in development, and one import that resolves to no package, such as one the page provides, made every request bundle all packages again, with concurrent requests replacing cache files others were reading. Pre-bundling now runs once and again after a source file changes, requests take turns, and unresolvable imports no longer count as stale ([#59](https://github.com/elixir-volt/volt/issues/59)).
- Resolve the dev server's paths from the directory Volt started in, not the current working directory. `Phoenix.CodeReloader` changes the VM's working directory while it compiles a reloadable path dependency, so a vendor request served at the same time wrote its cache into the dependency's `_build`, or failed with `could not write to file`. The vendor cache, asset root, watched directories and Tailwind input are affected ([#56](https://github.com/elixir-volt/volt/issues/56)).


## 0.20.0 - 2026-10-03

### Breaking changes
Expand Down
29 changes: 28 additions & 1 deletion guides/features/hmr.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,34 @@ After a patch the client dispatches `volt:document-updated` on `document`, for c
document.addEventListener("volt:document-updated", () => enhance(document))
```

Only the contents of `<body>` and the title are patched. Attributes on `<html>` and `<body>`, and the rest of `<head>`, stay as they were until the next reload.
### Letting the server compare renders

By default a page finds out what changed by requesting itself again. A server that can render a page outside a request can hand that to Volt instead:

```elixir
plug Volt.DevServer, document: {MySite, :render_document, []}
```

The function is called with the page's path, followed by the listed arguments, and returns `{:ok, html}`, the HTML a request for that path is answered with, or `:error`.

Each open page has its own websocket process, which lives as long as the page does. With `:document` and `:morph` set, that process keeps the HTML the server last rendered for its page. When a template or content file changes it renders the page again and compares the two renders, so each page is sent only what applies to it:

- nothing, when its HTML is the same;
- a reload, when scripts or stylesheets differ or preserved elements were added or removed;
- otherwise the new HTML to patch, with what the server changed outside the body's contents: attributes of `<html>` and `<body>`, metadata and links in `<head>`, and the attributes of preserved elements.

Because both sides of the comparison are server renders, it sees what the server changed and nothing that scripts did to the page since. An attribute a script set on `<html>`, such as a theme, is in neither render and stays. The page starts from the HTML it was served, which the dev server keeps until the page connects, so opening a page costs no extra render.

For each preserved element it names, the client sets the new attributes and dispatches a cancelable `volt:element-update` event on it. The element's owner re-renders it and calls `preventDefault()`; if nothing handles the event, the page reloads.

```javascript
island.addEventListener("volt:element-update", (event) => {
event.preventDefault()
rerender(JSON.parse(island.dataset.props))
})
```

Without `:document`, only the contents of `<body>` and the title are patched. Attributes on `<html>` and `<body>`, and the rest of `<head>`, stay as they were until the next reload.

## Ignoring watcher paths

Expand Down
1 change: 1 addition & 0 deletions lib/volt/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule Volt.Application do
Volt.HMR.StyleGraph.create_table()
Volt.HMR.ModuleGraph.create_table()
Volt.HMR.Errors.create_table()
Volt.HMR.Documents.create_table()
Volt.Dev.Prebundled.create_table()

# Watchers release their Tailwind contexts when they terminate, so the
Expand Down
1 change: 1 addition & 0 deletions lib/volt/dev/cleanup.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Volt.Dev.Cleanup do
Volt.HMR.StyleGraph.clear_session(session)
Volt.HMR.ModuleGraph.clear_session(session)
Volt.HMR.Errors.clear_session(session)
Volt.HMR.Documents.clear_session(session)
:ok
end
end
21 changes: 19 additions & 2 deletions lib/volt/dev_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ defmodule Volt.DevServer do
* `:target` — JS downlevel target (e.g. `:es2020`)
* `:import_source` — JSX import source (e.g. `"vue"`)
* `:vapor` — use Vue Vapor mode (default: `false`)
* `:document` — a function, or `{module, function, args}`, called with a
page's path, followed by `args`, to render the HTML a request for it is
answered with. With
it and the server's `:morph` setting, Volt compares renders for each open
page itself; see the HMR guide.
* `:watch` — start the supervised file watcher on the first request (default: `true`)

## Example
Expand Down Expand Up @@ -144,6 +149,7 @@ defmodule Volt.DevServer do
),
hmr_timeout: server_config.hmr_timeout,
morph: server_config.morph,
document: Keyword.get(opts, :document),
stylesheet_url: tailwind_root && tailwind_root.dev_url,
stylesheet_source: tailwind_root && tailwind_root.css,
session_supervisor: Keyword.get(opts, :session_supervisor),
Expand Down Expand Up @@ -301,7 +307,9 @@ defmodule Volt.DevServer do

defp do_call(%Conn{request_path: "/@volt/ws"} = conn, config) do
conn
|> WebSockAdapter.upgrade(Volt.HMR.Socket, [session: config.session],
|> WebSockAdapter.upgrade(
Volt.HMR.Socket,
[session: config.session, document: config.document, morph: config.morph],
timeout: config.hmr_timeout
)
|> Conn.halt()
Expand Down Expand Up @@ -370,11 +378,20 @@ defmodule Volt.DevServer do
serve(conn, relative, config)

:no_match ->
Volt.DevServer.ClientTag.register(conn, config.morph)
Volt.DevServer.ClientTag.register(conn, config.morph,
keep_for: document_session(config)
)
end
end
end

# Served HTML is kept only when a page's websocket process will compare renders.
defp document_session(%{document: document, morph: morph, session: session})
when not is_nil(document) and morph != false,
do: session

defp document_session(_config), do: nil

defp serve_virtual(conn, id, config) do
case Volt.PluginRunner.load(config.plugins, id) do
{:ok, source, content_type} ->
Expand Down
34 changes: 24 additions & 10 deletions lib/volt/dev_server/client_tag.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,46 @@ defmodule Volt.DevServer.ClientTag do
Add the dev client to the HTML this connection sends.

`morph` is the server's `:morph` setting: `false`, `true`, or
`[preserve: selector]`.
`[preserve: selector]`. With `keep_for: session`, the HTML of a successful
page is kept for the websocket process of the page to start from.
"""
@spec register(Conn.t(), boolean() | keyword()) :: Conn.t()
def register(conn, morph \\ false), do: Conn.register_before_send(conn, &inject(&1, morph))
@spec register(Conn.t(), boolean() | keyword(), keyword()) :: Conn.t()
def register(conn, morph \\ false, opts \\ []) do
Conn.register_before_send(conn, &inject(&1, morph, Keyword.get(opts, :keep_for)))
end

defp inject(%Conn{resp_body: body} = conn, morph) when not is_nil(body) do
if html?(conn), do: inject_client(conn, IO.iodata_to_binary(body), morph), else: conn
defp inject(%Conn{resp_body: body} = conn, morph, session) when not is_nil(body) do
if html?(conn),
do: inject_client(conn, IO.iodata_to_binary(body), morph, session),
else: conn
end

defp inject(conn, _morph), do: conn
defp inject(conn, _morph, _session), do: conn

# Successful pages carry an entity tag so the client can revalidate them
# instead of reloading; see `Volt.HMR.Document`.
defp inject_client(%Conn{status: 200} = conn, html, morph) do
defp inject_client(%Conn{status: 200} = conn, html, morph, session) do
etag = Document.etag(html)

if Document.fresh?(conn, etag) do
%{conn | status: 304, resp_body: ""}
else
attributes = attribute(Document.attribute(), etag) <> morph_attribute(morph)
if session, do: Volt.HMR.Documents.put(session, etag, html)
conn = Conn.put_resp_header(conn, "etag", etag)
%{conn | resp_body: put_client(html, attributes)}
%{conn | resp_body: tag(html, etag, morph)}
end
end

defp inject_client(conn, html, _morph), do: %{conn | resp_body: put_client(html, "")}
defp inject_client(conn, html, _morph, _session), do: %{conn | resp_body: put_client(html, "")}

@doc """
Add the dev client to a successful page, with the page's entity tag and the
server's `:morph` setting, as a response sent through the dev server has them.
"""
@spec tag(String.t(), String.t(), boolean() | keyword()) :: String.t()
def tag(html, etag, morph) do
put_client(html, attribute(Document.attribute(), etag) <> morph_attribute(morph))
end

# A page may already load the client, as pages rendered by a site generator
# do. Its tag gets the attributes; otherwise the client is added to the head.
Expand Down
1 change: 1 addition & 0 deletions lib/volt/dev_server/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule Volt.DevServer.Config do
define: %{},
hmr_timeout: 60_000,
morph: false,
document: nil,
session: :default,
tables: nil,
stylesheet_url: nil,
Expand Down
131 changes: 131 additions & 0 deletions lib/volt/hmr/document.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,135 @@ defmodule Volt.HMR.Document do
@doc "Return whether the request already holds the HTML that `etag` identifies."
@spec fresh?(Plug.Conn.t(), String.t()) :: boolean()
def fresh?(conn, etag), do: etag in Plug.Conn.get_req_header(conn, "if-none-match")

@typedoc """
A preserved element whose server-rendered attributes changed: its position
among the elements matching the preserve selector, and its new attributes.
"""
@type owned_change :: %{index: non_neg_integer(), attributes: %{String.t() => String.t()}}

@typedoc "Attributes the server set or changed on an element, and those it removed."
@type attribute_changes :: %{set: %{String.t() => String.t()}, remove: [String.t()]}

@typedoc """
What the server changed outside the body's contents, which are patched from
the new HTML itself:

* `:owned` — preserved elements whose attributes changed
* `:root` — attribute changes on `"html"` and `"body"`, for those that have any
* `:head` — head elements, as HTML, that are gone and that are new. Scripts,
stylesheets and the title are not among them.
"""
@type patch :: %{
owned: [owned_change()],
root: %{String.t() => attribute_changes()},
head: %{remove: [String.t()], add: [String.t()]}
}

@doc """
Decide how a page gets from one server render to the next.

Both arguments are HTML the server rendered, so the comparison sees only what
the server changed, never what scripts did to the page since. An attribute a
script set on `<html>` is in neither render and is left alone.

Returns `:reload` when patching cannot apply the change: the scripts the page
runs or its stylesheets differ, or elements matching `preserve` were added or
removed. Otherwise returns `{:patch, patch}`.
"""
@spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, patch()}
def changes(previous, next, preserve) do
previous = Floki.parse_document!(previous)
next = Floki.parse_document!(next)

with true <- scripts(previous) == scripts(next),
true <- stylesheets(previous) == stylesheets(next),
{:ok, owned} <- owned_changes(preserved(previous, preserve), preserved(next, preserve)) do
{:patch,
%{
owned: owned,
root: root_changes(previous, next),
head: head_changes(head_elements(previous), head_elements(next))
}}
else
_cannot_patch -> :reload
end
end

defp root_changes(previous, next) do
for tag <- ["html", "body"],
changes = attribute_changes(root_attributes(previous, tag), root_attributes(next, tag)),
changes != %{set: %{}, remove: []},
into: %{},
do: {tag, changes}
end

defp root_attributes(document, tag) do
case Floki.find(document, tag) do
[{^tag, attributes, _children} | _] -> Map.new(attributes)
[] -> %{}
end
end

defp attribute_changes(previous, next) do
%{
set: Map.reject(next, fn {name, value} -> Map.get(previous, name) == value end),
remove: Map.keys(previous) -- Map.keys(next)
}
end

# Head elements that can be replaced without side effects: metadata and links
# other than stylesheets.
defp head_elements(document) do
for {"head", _attributes, children} <- Floki.find(document, "head"),
{tag, attributes, _children} = element <- children,
tag not in ["script", "style", "title"],
not (tag == "link" and attribute(attributes, "rel") == "stylesheet"),
do: Floki.raw_html(element)
end

defp head_changes(previous, next), do: %{remove: previous -- next, add: next -- previous}

# Scripts the browser runs. Data blocks such as JSON are content and are patched.
defp scripts(document) do
for {"script", attributes, children} <- Floki.find(document, "script"),
type = attribute(attributes, "type"),
type in ["", "module", "text/javascript"],
do: {type, attribute(attributes, "src"), Floki.raw_html(children)}
end

defp stylesheets(document) do
links =
for {"link", attributes, _children} <- Floki.find(document, "link[rel=stylesheet]"),
do: attribute(attributes, "href")

styles = for {"style", _attributes, children} <- Floki.find(document, "style"), do: children
{links, styles}
end

defp preserved(_document, preserve) when preserve in [nil, ""], do: []

defp preserved(document, preserve) do
for {_tag, attributes, _children} <- Floki.find(document, preserve), do: Map.new(attributes)
end

defp owned_changes(previous, next) do
if length(previous) == length(next) do
owned =
for {{before, now}, index} <- Enum.with_index(Enum.zip(previous, next)),
before != now,
do: %{index: index, attributes: now}

{:ok, owned}
else
:error
end
end

defp attribute(attributes, name) do
case List.keyfind(attributes, name, 0) do
{^name, value} -> value
nil -> ""
end
end
end
31 changes: 31 additions & 0 deletions lib/volt/hmr/documents.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
defmodule Volt.HMR.Documents do
@moduledoc false
# The HTML recently served for pages, per session and entity tag, so the
# websocket process of a page that connects can start from the HTML the page
# was served without rendering it again.

@table :volt_hmr_documents

# Pages that never connect, such as requests from other tools, leave their
# HTML behind. A session keeps at most this many before starting over.
@limit 64

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

def put(session, etag, html) do
if count(session) >= @limit, do: clear_session(session)
Volt.ETS.put(@table, {{session, etag}, html})
end

@spec fetch(term(), String.t()) :: {:ok, String.t()} | :error
def fetch(session, etag) do
case :ets.lookup(@table, {session, etag}) do
[{_key, html}] -> {:ok, html}
[] -> :error
end
end

def clear_session(session), do: Volt.ETS.clear_session(@table, session)

defp count(session), do: :ets.select_count(@table, [{{{session, :_}, :_}, [], [true]}])
end
3 changes: 2 additions & 1 deletion lib/volt/hmr/message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defmodule Volt.HMR.Message do
* `update` — an HMR update payload (`path`, `changes`, optional `boundary`, `timestamp`)
* `error` — the current build errors (`errors`, as `t:Volt.Dev.Error.entry/0` maps);
an empty list hides the overlay
* `page` — sent by the browser client once connected: the `path` it shows and the `etag` of its HTML
* `ping` — heartbeat sent by the browser client
* `pong` — heartbeat reply from the server

Expand All @@ -18,7 +19,7 @@ defmodule Volt.HMR.Message do
use JSONCodec

@type t :: %__MODULE__{
type: :update | :error | :ping | :pong,
type: :update | :error | :page | :ping | :pong,
payload: term() | nil
}

Expand Down
Loading
Loading