Skip to content

Commit 3e5beee

Browse files
authored
Merge pull request #54 from elixir-volt/tab-documents
Compare page renders in each tab's websocket process
2 parents 4c04f83 + 8d5df24 commit 3e5beee

17 files changed

Lines changed: 794 additions & 31 deletions

File tree

‎.reach.exs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ infrastructure = [
154154
"Volt.Dev.ConsoleForwarder.Payload",
155155
"Volt.ETS",
156156
"Volt.HMR.Boundary",
157+
"Volt.HMR.Documents",
157158
"Volt.HMR.Errors",
158159
"Volt.HMR.GlobGraph",
159160
"Volt.HMR.ImportGraph",

‎CHANGELOG.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
- 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.
88

9+
### Added
10+
11+
- `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.
12+
- 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.
13+
- 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.
14+
915
### Fixed
1016

1117
- 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)).
@@ -14,7 +20,6 @@
1420
- 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)).
1521
- 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)).
1622

17-
1823
## 0.20.0 - 2026-10-03
1924

2025
### Breaking changes

‎guides/features/hmr.md‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,34 @@ After a patch the client dispatches `volt:document-updated` on `document`, for c
127127
document.addEventListener("volt:document-updated", () => enhance(document))
128128
```
129129

130-
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.
130+
### Letting the server compare renders
131+
132+
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:
133+
134+
```elixir
135+
plug Volt.DevServer, document: {MySite, :render_document, []}
136+
```
137+
138+
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`.
139+
140+
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:
141+
142+
- nothing, when its HTML is the same;
143+
- a reload, when scripts or stylesheets differ or preserved elements were added or removed;
144+
- 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.
145+
146+
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.
147+
148+
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.
149+
150+
```javascript
151+
island.addEventListener("volt:element-update", (event) => {
152+
event.preventDefault()
153+
rerender(JSON.parse(island.dataset.props))
154+
})
155+
```
156+
157+
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.
131158

132159
## Ignoring watcher paths
133160

‎lib/volt/application.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule Volt.Application do
1212
Volt.HMR.StyleGraph.create_table()
1313
Volt.HMR.ModuleGraph.create_table()
1414
Volt.HMR.Errors.create_table()
15+
Volt.HMR.Documents.create_table()
1516
Volt.Dev.Prebundled.create_table()
1617

1718
# Watchers release their Tailwind contexts when they terminate, so the

‎lib/volt/dev/cleanup.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Volt.Dev.Cleanup do
1010
Volt.HMR.StyleGraph.clear_session(session)
1111
Volt.HMR.ModuleGraph.clear_session(session)
1212
Volt.HMR.Errors.clear_session(session)
13+
Volt.HMR.Documents.clear_session(session)
1314
:ok
1415
end
1516
end

‎lib/volt/dev_server.ex‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ defmodule Volt.DevServer do
1919
* `:target` — JS downlevel target (e.g. `:es2020`)
2020
* `:import_source` — JSX import source (e.g. `"vue"`)
2121
* `:vapor` — use Vue Vapor mode (default: `false`)
22+
* `:document` — a function, or `{module, function, args}`, called with a
23+
page's path, followed by `args`, to render the HTML a request for it is
24+
answered with. With
25+
it and the server's `:morph` setting, Volt compares renders for each open
26+
page itself; see the HMR guide.
2227
* `:watch` — start the supervised file watcher on the first request (default: `true`)
2328
2429
## Example
@@ -144,6 +149,7 @@ defmodule Volt.DevServer do
144149
),
145150
hmr_timeout: server_config.hmr_timeout,
146151
morph: server_config.morph,
152+
document: Keyword.get(opts, :document),
147153
stylesheet_url: tailwind_root && tailwind_root.dev_url,
148154
stylesheet_source: tailwind_root && tailwind_root.css,
149155
session_supervisor: Keyword.get(opts, :session_supervisor),
@@ -301,7 +307,9 @@ defmodule Volt.DevServer do
301307

302308
defp do_call(%Conn{request_path: "/@volt/ws"} = conn, config) do
303309
conn
304-
|> WebSockAdapter.upgrade(Volt.HMR.Socket, [session: config.session],
310+
|> WebSockAdapter.upgrade(
311+
Volt.HMR.Socket,
312+
[session: config.session, document: config.document, morph: config.morph],
305313
timeout: config.hmr_timeout
306314
)
307315
|> Conn.halt()
@@ -370,11 +378,20 @@ defmodule Volt.DevServer do
370378
serve(conn, relative, config)
371379

372380
:no_match ->
373-
Volt.DevServer.ClientTag.register(conn, config.morph)
381+
Volt.DevServer.ClientTag.register(conn, config.morph,
382+
keep_for: document_session(config)
383+
)
374384
end
375385
end
376386
end
377387

388+
# Served HTML is kept only when a page's websocket process will compare renders.
389+
defp document_session(%{document: document, morph: morph, session: session})
390+
when not is_nil(document) and morph != false,
391+
do: session
392+
393+
defp document_session(_config), do: nil
394+
378395
defp serve_virtual(conn, id, config) do
379396
case Volt.PluginRunner.load(config.plugins, id) do
380397
{:ok, source, content_type} ->

‎lib/volt/dev_server/client_tag.ex‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,46 @@ defmodule Volt.DevServer.ClientTag do
1515
Add the dev client to the HTML this connection sends.
1616
1717
`morph` is the server's `:morph` setting: `false`, `true`, or
18-
`[preserve: selector]`.
18+
`[preserve: selector]`. With `keep_for: session`, the HTML of a successful
19+
page is kept for the websocket process of the page to start from.
1920
"""
20-
@spec register(Conn.t(), boolean() | keyword()) :: Conn.t()
21-
def register(conn, morph \\ false), do: Conn.register_before_send(conn, &inject(&1, morph))
21+
@spec register(Conn.t(), boolean() | keyword(), keyword()) :: Conn.t()
22+
def register(conn, morph \\ false, opts \\ []) do
23+
Conn.register_before_send(conn, &inject(&1, morph, Keyword.get(opts, :keep_for)))
24+
end
2225

23-
defp inject(%Conn{resp_body: body} = conn, morph) when not is_nil(body) do
24-
if html?(conn), do: inject_client(conn, IO.iodata_to_binary(body), morph), else: conn
26+
defp inject(%Conn{resp_body: body} = conn, morph, session) when not is_nil(body) do
27+
if html?(conn),
28+
do: inject_client(conn, IO.iodata_to_binary(body), morph, session),
29+
else: conn
2530
end
2631

27-
defp inject(conn, _morph), do: conn
32+
defp inject(conn, _morph, _session), do: conn
2833

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

3439
if Document.fresh?(conn, etag) do
3540
%{conn | status: 304, resp_body: ""}
3641
else
37-
attributes = attribute(Document.attribute(), etag) <> morph_attribute(morph)
42+
if session, do: Volt.HMR.Documents.put(session, etag, html)
3843
conn = Conn.put_resp_header(conn, "etag", etag)
39-
%{conn | resp_body: put_client(html, attributes)}
44+
%{conn | resp_body: tag(html, etag, morph)}
4045
end
4146
end
4247

43-
defp inject_client(conn, html, _morph), do: %{conn | resp_body: put_client(html, "")}
48+
defp inject_client(conn, html, _morph, _session), do: %{conn | resp_body: put_client(html, "")}
49+
50+
@doc """
51+
Add the dev client to a successful page, with the page's entity tag and the
52+
server's `:morph` setting, as a response sent through the dev server has them.
53+
"""
54+
@spec tag(String.t(), String.t(), boolean() | keyword()) :: String.t()
55+
def tag(html, etag, morph) do
56+
put_client(html, attribute(Document.attribute(), etag) <> morph_attribute(morph))
57+
end
4458

4559
# A page may already load the client, as pages rendered by a site generator
4660
# do. Its tag gets the attributes; otherwise the client is added to the head.

‎lib/volt/dev_server/config.ex‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule Volt.DevServer.Config do
1616
define: %{},
1717
hmr_timeout: 60_000,
1818
morph: false,
19+
document: nil,
1920
session: :default,
2021
tables: nil,
2122
stylesheet_url: nil,

‎lib/volt/hmr/document.ex‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,135 @@ defmodule Volt.HMR.Document do
4949
@doc "Return whether the request already holds the HTML that `etag` identifies."
5050
@spec fresh?(Plug.Conn.t(), String.t()) :: boolean()
5151
def fresh?(conn, etag), do: etag in Plug.Conn.get_req_header(conn, "if-none-match")
52+
53+
@typedoc """
54+
A preserved element whose server-rendered attributes changed: its position
55+
among the elements matching the preserve selector, and its new attributes.
56+
"""
57+
@type owned_change :: %{index: non_neg_integer(), attributes: %{String.t() => String.t()}}
58+
59+
@typedoc "Attributes the server set or changed on an element, and those it removed."
60+
@type attribute_changes :: %{set: %{String.t() => String.t()}, remove: [String.t()]}
61+
62+
@typedoc """
63+
What the server changed outside the body's contents, which are patched from
64+
the new HTML itself:
65+
66+
* `:owned` — preserved elements whose attributes changed
67+
* `:root` — attribute changes on `"html"` and `"body"`, for those that have any
68+
* `:head` — head elements, as HTML, that are gone and that are new. Scripts,
69+
stylesheets and the title are not among them.
70+
"""
71+
@type patch :: %{
72+
owned: [owned_change()],
73+
root: %{String.t() => attribute_changes()},
74+
head: %{remove: [String.t()], add: [String.t()]}
75+
}
76+
77+
@doc """
78+
Decide how a page gets from one server render to the next.
79+
80+
Both arguments are HTML the server rendered, so the comparison sees only what
81+
the server changed, never what scripts did to the page since. An attribute a
82+
script set on `<html>` is in neither render and is left alone.
83+
84+
Returns `:reload` when patching cannot apply the change: the scripts the page
85+
runs or its stylesheets differ, or elements matching `preserve` were added or
86+
removed. Otherwise returns `{:patch, patch}`.
87+
"""
88+
@spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, patch()}
89+
def changes(previous, next, preserve) do
90+
previous = Floki.parse_document!(previous)
91+
next = Floki.parse_document!(next)
92+
93+
with true <- scripts(previous) == scripts(next),
94+
true <- stylesheets(previous) == stylesheets(next),
95+
{:ok, owned} <- owned_changes(preserved(previous, preserve), preserved(next, preserve)) do
96+
{:patch,
97+
%{
98+
owned: owned,
99+
root: root_changes(previous, next),
100+
head: head_changes(head_elements(previous), head_elements(next))
101+
}}
102+
else
103+
_cannot_patch -> :reload
104+
end
105+
end
106+
107+
defp root_changes(previous, next) do
108+
for tag <- ["html", "body"],
109+
changes = attribute_changes(root_attributes(previous, tag), root_attributes(next, tag)),
110+
changes != %{set: %{}, remove: []},
111+
into: %{},
112+
do: {tag, changes}
113+
end
114+
115+
defp root_attributes(document, tag) do
116+
case Floki.find(document, tag) do
117+
[{^tag, attributes, _children} | _] -> Map.new(attributes)
118+
[] -> %{}
119+
end
120+
end
121+
122+
defp attribute_changes(previous, next) do
123+
%{
124+
set: Map.reject(next, fn {name, value} -> Map.get(previous, name) == value end),
125+
remove: Map.keys(previous) -- Map.keys(next)
126+
}
127+
end
128+
129+
# Head elements that can be replaced without side effects: metadata and links
130+
# other than stylesheets.
131+
defp head_elements(document) do
132+
for {"head", _attributes, children} <- Floki.find(document, "head"),
133+
{tag, attributes, _children} = element <- children,
134+
tag not in ["script", "style", "title"],
135+
not (tag == "link" and attribute(attributes, "rel") == "stylesheet"),
136+
do: Floki.raw_html(element)
137+
end
138+
139+
defp head_changes(previous, next), do: %{remove: previous -- next, add: next -- previous}
140+
141+
# Scripts the browser runs. Data blocks such as JSON are content and are patched.
142+
defp scripts(document) do
143+
for {"script", attributes, children} <- Floki.find(document, "script"),
144+
type = attribute(attributes, "type"),
145+
type in ["", "module", "text/javascript"],
146+
do: {type, attribute(attributes, "src"), Floki.raw_html(children)}
147+
end
148+
149+
defp stylesheets(document) do
150+
links =
151+
for {"link", attributes, _children} <- Floki.find(document, "link[rel=stylesheet]"),
152+
do: attribute(attributes, "href")
153+
154+
styles = for {"style", _attributes, children} <- Floki.find(document, "style"), do: children
155+
{links, styles}
156+
end
157+
158+
defp preserved(_document, preserve) when preserve in [nil, ""], do: []
159+
160+
defp preserved(document, preserve) do
161+
for {_tag, attributes, _children} <- Floki.find(document, preserve), do: Map.new(attributes)
162+
end
163+
164+
defp owned_changes(previous, next) do
165+
if length(previous) == length(next) do
166+
owned =
167+
for {{before, now}, index} <- Enum.with_index(Enum.zip(previous, next)),
168+
before != now,
169+
do: %{index: index, attributes: now}
170+
171+
{:ok, owned}
172+
else
173+
:error
174+
end
175+
end
176+
177+
defp attribute(attributes, name) do
178+
case List.keyfind(attributes, name, 0) do
179+
{^name, value} -> value
180+
nil -> ""
181+
end
182+
end
52183
end

‎lib/volt/hmr/documents.ex‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule Volt.HMR.Documents do
2+
@moduledoc false
3+
# The HTML recently served for pages, per session and entity tag, so the
4+
# websocket process of a page that connects can start from the HTML the page
5+
# was served without rendering it again.
6+
7+
@table :volt_hmr_documents
8+
9+
# Pages that never connect, such as requests from other tools, leave their
10+
# HTML behind. A session keeps at most this many before starting over.
11+
@limit 64
12+
13+
def create_table, do: Volt.ETS.create_named_set(@table)
14+
15+
def put(session, etag, html) do
16+
if count(session) >= @limit, do: clear_session(session)
17+
Volt.ETS.put(@table, {{session, etag}, html})
18+
end
19+
20+
@spec fetch(term(), String.t()) :: {:ok, String.t()} | :error
21+
def fetch(session, etag) do
22+
case :ets.lookup(@table, {session, etag}) do
23+
[{_key, html}] -> {:ok, html}
24+
[] -> :error
25+
end
26+
end
27+
28+
def clear_session(session), do: Volt.ETS.clear_session(@table, session)
29+
30+
defp count(session), do: :ets.select_count(@table, [{{{session, :_}, :_}, [], [true]}])
31+
end

0 commit comments

Comments
 (0)