From 93036aaf8fa3b4635fbecda79436120d55d42847 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 20:44:41 +0400 Subject: [PATCH 1/2] Compare page renders in each tab's websocket process A page found out what changed by requesting itself again and diffing the answer against its live DOM, which scripts have modified since. So a change to an owned element's attributes, such as an island's props, could only be handled by reloading. Each open page already has a websocket process that lives as long as the page. With the new :document option, a function that renders a page's HTML, and :morph, that process keeps the HTML the server last rendered for its page. On a document update it renders the page again and compares the two server renders, then sends the page only what applies to it: nothing, a reload, or the new HTML with the owned elements whose attributes the server changed. For those elements the client sets the attributes and dispatches a cancelable volt:element-update event; the page reloads only if nothing handles it. Pages that were served other HTML than the server can reproduce fall back to revalidating themselves. --- CHANGELOG.md | 7 ++ guides/features/hmr.md | 25 +++++++ lib/volt/dev_server.ex | 10 ++- lib/volt/dev_server/client_tag.ex | 12 +++- lib/volt/dev_server/config.ex | 1 + lib/volt/hmr/document.ex | 73 +++++++++++++++++++++ lib/volt/hmr/message.ex | 3 +- lib/volt/hmr/socket.ex | 79 ++++++++++++++++++++++- priv/ts/client/document.ts | 80 +++++++++++++++++++---- priv/ts/client/hmr.ts | 14 +++- test/volt/hmr/document_test.exs | 70 ++++++++++++++++++++ test/volt/hmr/socket_test.exs | 104 ++++++++++++++++++++++++++++++ 12 files changed, 459 insertions(+), 19 deletions(-) create mode 100644 test/volt/hmr/document_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index c849de3..17153a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### 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. +- 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. + ## 0.20.0 - 2026-10-03 ### Breaking changes diff --git a/guides/features/hmr.md b/guides/features/hmr.md index c715b50..0778ff9 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -127,6 +127,31 @@ After a patch the client dispatches `volt:document-updated` on `document`, for c document.addEventListener("volt:document-updated", () => enhance(document)) ``` +### 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 the preserved elements whose attributes the server changed. + +Because both sides of the comparison are server renders, it sees what the server changed and nothing that scripts did to the page since. 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)) +}) +``` + Only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. ## Ignoring watcher paths diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index d3744b4..f7ae1cf 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -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 @@ -124,6 +129,7 @@ defmodule Volt.DevServer do Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix), 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), @@ -281,7 +287,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() diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex index 8c9aa42..0cfc0fb 100644 --- a/lib/volt/dev_server/client_tag.ex +++ b/lib/volt/dev_server/client_tag.ex @@ -34,14 +34,22 @@ defmodule Volt.DevServer.ClientTag do if Document.fresh?(conn, etag) do %{conn | status: 304, resp_body: ""} else - attributes = attribute(Document.attribute(), etag) <> morph_attribute(morph) 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, "")} + @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. defp put_client(html, attributes) do diff --git a/lib/volt/dev_server/config.ex b/lib/volt/dev_server/config.ex index 9fbcfba..50cc62b 100644 --- a/lib/volt/dev_server/config.ex +++ b/lib/volt/dev_server/config.ex @@ -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, diff --git a/lib/volt/hmr/document.ex b/lib/volt/hmr/document.ex index d3a9792..478b874 100644 --- a/lib/volt/hmr/document.ex +++ b/lib/volt/hmr/document.ex @@ -49,4 +49,77 @@ 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()}} + + @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. + + 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, owned}`, where `owned` lists the + preserved elements whose attributes changed. Their owner, such as a mounted + component, is told and re-renders; the rest of the page is patched. + """ + @spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, [owned_change()]} + def changes(previous, next, preserve) do + previous = Floki.parse_document!(previous) + next = Floki.parse_document!(next) + + cond do + scripts(previous) != scripts(next) -> :reload + stylesheets(previous) != stylesheets(next) -> :reload + true -> owned_changes(preserved(previous, preserve), preserved(next, preserve)) + end + end + + # 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} + + {:patch, owned} + else + :reload + end + end + + defp attribute(attributes, name) do + case List.keyfind(attributes, name, 0) do + {^name, value} -> value + nil -> "" + end + end end diff --git a/lib/volt/hmr/message.ex b/lib/volt/hmr/message.ex index d46557d..4ea0ecf 100644 --- a/lib/volt/hmr/message.ex +++ b/lib/volt/hmr/message.ex @@ -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 @@ -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 } diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index dda6748..0370090 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -8,6 +8,15 @@ defmodule Volt.HMR.Socket do @behaviour WebSock require Logger + alias Volt.HMR.Document + + # One socket process lives as long as one open page. When the server can + # render documents and pages are patched in place, the process keeps the HTML + # the server last rendered for its page. On a document update it renders the + # page again and compares the two renders, so each page hears only what + # applies to it: nothing, a reload, or a patch. + defstruct session: :default, document: nil, morph: false, path: nil, html: nil + @impl true def init(nil), do: init([]) @@ -15,12 +24,27 @@ defmodule Volt.HMR.Socket do session = Keyword.get(args, :session, :default) Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) + morph = Keyword.get(args, :morph, false) + + state = %__MODULE__{ + session: session, + document: document(Keyword.get(args, :document), morph), + morph: morph + } + case Volt.HMR.Errors.list(session) do - [] -> {:ok, %{}} - errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, %{}) + [] -> {:ok, state} + errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, state) end end + # Without patching, a changed page reloads and fetches its own HTML. + defp document(_document, false), do: nil + defp document(document, _morph), do: document + + defp preserve(morph) when is_list(morph), do: Keyword.get(morph, :preserve) + defp preserve(_morph), do: nil + # Heartbeat: the browser client sends a `{"type":"ping"}` JSON message # every few seconds to keep Bandit's websocket `read_timeout` from closing # an otherwise idle connection. We reply with `{"type":"pong"}` so the @@ -32,6 +56,9 @@ defmodule Volt.HMR.Socket do {:ok, %Volt.HMR.Message{type: :ping}} -> push(%Volt.HMR.Message{type: :pong}, state) + {:ok, %Volt.HMR.Message{type: :page, payload: payload}} -> + {:ok, open_page(state, payload)} + {:ok, %Volt.HMR.Message{type: type} = message} -> Logger.debug("[Volt.HMR] Received: #{inspect(type)} #{inspect(message.payload)}") {:ok, state} @@ -42,7 +69,38 @@ defmodule Volt.HMR.Socket do end end + # The page says where it is and which HTML it was served. The render kept for + # it must be that HTML; if the sources changed in between, the page falls back + # to revalidating itself. + defp open_page(%__MODULE__{document: document} = state, %{"path" => path, "etag" => etag}) + when not is_nil(document) and is_binary(path) do + case render(document, path) do + {:ok, html} -> %{state | path: path, html: if(Document.etag(html) == etag, do: html)} + _error -> %{state | path: path, html: nil} + end + end + + defp open_page(state, _payload), do: state + @impl true + def handle_info( + {:volt_hmr, :update, %{changes: ["document"]} = payload}, + %__MODULE__{html: html} = state + ) + when is_binary(html) do + case render(state.document, state.path) do + {:ok, ^html} -> + {:ok, state} + + {:ok, next} -> + update_document(Document.changes(html, next, preserve(state.morph)), next, payload, state) + + _error -> + # The page revalidates itself and shows what the server answers. + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) + end + end + def handle_info({:volt_hmr, type, payload}, state) do push(%Volt.HMR.Message{type: type, payload: payload}, state) end @@ -51,6 +109,23 @@ defmodule Volt.HMR.Socket do {:ok, state} end + defp update_document(:reload, _next, payload, state) do + payload = %{payload | changes: ["full"]} + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) + end + + defp update_document({:patch, owned}, next, payload, state) do + etag = Document.etag(next) + + # The page gets the HTML a request for it would be answered with. + tagged = Volt.DevServer.ClientTag.tag(next, etag, state.morph) + payload = Map.merge(payload, %{html: tagged, etag: etag, owned: owned}) + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: next}) + end + + defp render({module, function, args}, path), do: apply(module, function, [path | args]) + defp render(document, path) when is_function(document, 1), do: document.(path) + defp push(%Volt.HMR.Message{} = message, state) do case encode(message) do {:ok, frame} -> {:push, frame, state} diff --git a/priv/ts/client/document.ts b/priv/ts/client/document.ts index fbdd720..a6419e0 100644 --- a/priv/ts/client/document.ts +++ b/priv/ts/client/document.ts @@ -4,6 +4,61 @@ import { pageReload } from './reload' const ETAG = 'data-volt-etag' const MORPH = 'data-volt-morph' +export type OwnedChange = { index: number; attributes: Record } + +/** A page render pushed by the server, which already compared it with the previous one. */ +export type PushedDocument = { html: string; etag: string; owned: OwnedChange[] } + +/** Where this page is and which HTML it was served, for the server to keep track of it. */ +export function pageIdentity() { + const etag = document.querySelector(`script[${ETAG}]`)?.getAttribute(ETAG) + return etag ? { path: location.pathname + location.search, etag } : null +} + +/** + * Apply a render the server pushed. The server compared it with the HTML it + * last rendered for this page, so it knows which owned elements it changed. + */ +export function applyDocument({ html, etag, owned }: PushedDocument) { + const preserve = document.querySelector(`script[${ETAG}]`)?.getAttribute(MORPH) + if (preserve === null || preserve === undefined) return pageReload() + + const next = new DOMParser().parseFromString(html, 'text/html') + const changed = new Set(owned.map((change) => change.index)) + if (!morphDocument(next, preserve, changed)) return pageReload() + if (!updateOwned(preserve, owned)) return pageReload() + + finishUpdate(etag) +} + +// The owner of an element re-renders it from its new attributes and says so by +// cancelling the event. Without an owner listening, only a reload applies them. +function updateOwned(preserve: string, owned: OwnedChange[]) { + if (owned.length === 0) return true + const elements = [...document.querySelectorAll(preserve)] + + return owned.every(({ index, attributes }) => { + const element = elements[index] + if (!element) return false + + for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value) + + const event = new CustomEvent('volt:element-update', { + bubbles: true, + cancelable: true, + detail: { attributes } + }) + + return !element.dispatchEvent(event) + }) +} + +function finishUpdate(etag: string) { + document.querySelector(`script[${ETAG}]`)?.setAttribute(ETAG, etag) + document.dispatchEvent(new CustomEvent('volt:document-updated')) + console.log('[Volt] Document updated') +} + let revalidation: Promise | undefined // The server-rendered HTML may have changed. A page that carries the entity tag @@ -43,9 +98,7 @@ async function updateDocument() { const next = new DOMParser().parseFromString(await response.text(), 'text/html') if (!morphDocument(next, preserve)) return pageReload() - document.querySelector(`script[${ETAG}]`)?.setAttribute(ETAG, nextEtag) - document.dispatchEvent(new CustomEvent('volt:document-updated')) - console.log('[Volt] Document updated') + finishUpdate(nextEtag) } /** @@ -54,14 +107,15 @@ async function updateDocument() { * Returns false, changing nothing, when the difference cannot be applied by * patching: scripts do not run again, stylesheets are not reloaded, and * elements matching `preserve` are owned by client code such as a mounted - * component. + * component. `changed` holds the positions of owned elements the server changed. */ -export function morphDocument(next: Document, preserve: string) { +export function morphDocument(next: Document, preserve: string, changed = new Set()) { if (!sameItems(scripts(document), scripts(next))) return false if (!sameItems(stylesheets(document), stylesheets(next))) return false const preserved = preserve ? [...document.querySelectorAll(preserve)] : [] - if (!samePreserved(preserved, preserve ? [...next.querySelectorAll(preserve)] : [])) return false + const nextPreserved = preserve ? [...next.querySelectorAll(preserve)] : [] + if (!samePreserved(preserved, nextPreserved, changed)) return false const owned = (node: Node) => preserve !== '' && node instanceof Element && node.matches(preserve) @@ -114,13 +168,17 @@ function stylesheetUrl(href: string) { // Client code may add attributes to an element it owns, such as a mount marker, // so only the attributes the server rendered are compared. -function samePreserved(current: Element[], next: Element[]) { +// The elements in `changed` are ones the server knows it changed; their owners +// are told separately. +function samePreserved(current: Element[], next: Element[], changed: Set) { return ( current.length === next.length && - next.every((element, index) => - [...element.attributes].every( - (attribute) => current[index]?.getAttribute(attribute.name) === attribute.value - ) + next.every( + (element, index) => + changed.has(index) || + [...element.attributes].every( + (attribute) => current[index]?.getAttribute(attribute.name) === attribute.value + ) ) ) } diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 54c7bbf..83eb178 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -6,7 +6,7 @@ import { type HotCallback } from './hot' import { clearErrorOverlay, renderErrorOverlay, type VoltError } from './overlay' -import { revalidateDocument } from './document' +import { applyDocument, pageIdentity, revalidateDocument, type PushedDocument } from './document' import { pageReload } from './reload' import { removeStyle, updateStyle, updateStyles } from './styles' @@ -42,6 +42,9 @@ function connect() { connected = true console.log('[Volt] HMR connected') + const identity = pageIdentity() + if (identity) ws?.send(JSON.stringify({ type: 'page', payload: identity })) + if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = undefined @@ -126,7 +129,14 @@ async function handleUpdate(payload: { } if (changes.length === 1 && changes[0] === 'document') { - await revalidateDocument() + const pushed = payload as Partial + + if (typeof pushed.html === 'string' && typeof pushed.etag === 'string') { + applyDocument({ html: pushed.html, etag: pushed.etag, owned: pushed.owned ?? [] }) + } else { + await revalidateDocument() + } + return } diff --git a/test/volt/hmr/document_test.exs b/test/volt/hmr/document_test.exs new file mode 100644 index 0000000..61f0154 --- /dev/null +++ b/test/volt/hmr/document_test.exs @@ -0,0 +1,70 @@ +defmodule Volt.HMR.DocumentTest do + use ExUnit.Case, async: true + + alias Volt.HMR.Document + + defp page(body, head \\ "") do + "t#{head}#{body}" + end + + describe "changes/3" do + test "patches changes to content" do + assert Document.changes(page("

one

"), page("

two

"), nil) == + {:patch, []} + end + + test "patches data blocks, which the browser does not run" do + before = page(~s()) + now = page(~s()) + + assert Document.changes(before, now, nil) == {:patch, []} + end + + test "reloads when the scripts the page runs differ" do + before = page(~s()) + + assert Document.changes(before, page(~s()), nil) == + :reload + + assert Document.changes(page(""), page(""), nil) == + :reload + + assert Document.changes(before, page(""), nil) == :reload + end + + test "reloads when the stylesheets differ" do + link = ~s() + + assert Document.changes( + page("", link), + page("", ~s()), + nil + ) == + :reload + + assert Document.changes(page("", ""), page("", ""), nil) == + :reload + end + + test "names the owned elements whose attributes the server changed" do + before = + page( + ~s(
) + ) + + now = + page( + ~s(
) + ) + + assert Document.changes(before, now, "[data-island]") == + {:patch, [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}]} + end + + test "reloads when owned elements are added or removed" do + before = page(~s(
)) + + assert Document.changes(before, page(""), "[data-island]") == :reload + end + end +end diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 6b6c665..6789572 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -23,6 +23,110 @@ defmodule Volt.HMR.SocketTest do end end + describe "a page the server keeps a render of" do + setup do + {:ok, renders} = Agent.start_link(fn -> page("first", ~s({"n":1})) end) + document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end + + {:ok, state} = + Volt.HMR.Socket.init(document: document, morph: [preserve: "[data-island]"]) + + html = Agent.get(renders, & &1) + opened = open_page(state, "/post", Volt.HMR.Document.etag(html)) + %{renders: renders, state: state, opened: opened} + end + + test "hears nothing when its HTML did not change", %{opened: opened} do + assert {:ok, ^opened} = Volt.HMR.Socket.handle_info(document_update(), opened) + end + + test "is sent the new HTML when it can be patched", %{renders: renders, opened: opened} do + Agent.update(renders, fn _ -> page("second", ~s({"n":1})) end) + + assert {:push, {:text, json}, state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + assert %{"changes" => ["document"], "html" => html, "etag" => etag, "owned" => []} = + Jason.decode!(json)["payload"] + + assert html =~ "second" + + # The pushed page keeps what marks it for the next update. + assert html =~ ~s(data-volt-morph="[data-island]") + assert html =~ "data-volt-etag" + assert etag == Volt.HMR.Document.etag(Agent.get(renders, & &1)) + + # The new render is what the next change is compared with. + assert {:ok, ^state} = Volt.HMR.Socket.handle_info(document_update(), state) + end + + test "is told which owned elements the server changed", %{renders: renders, opened: opened} do + Agent.update(renders, fn _ -> page("first", ~s({"n":2})) end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + assert [%{"index" => 0, "attributes" => %{"data-props" => ~s({"n":2})}}] = + Jason.decode!(json)["payload"]["owned"] + end + + test "reloads when the change cannot be patched", %{renders: renders, opened: opened} do + Agent.update(renders, fn html -> String.replace(html, "run()", "run(2)") end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + payload = Jason.decode!(json)["payload"] + assert payload["changes"] == ["full"] + refute Map.has_key?(payload, "html") + end + + test "revalidates itself when it was served different HTML", %{state: state} do + stale = open_page(state, "/post", ~s("another")) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), stale) + + payload = Jason.decode!(json)["payload"] + assert payload["changes"] == ["document"] + refute Map.has_key?(payload, "html") + end + + test "is not kept track of unless pages are patched in place", %{renders: renders} do + document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end + {:ok, state} = Volt.HMR.Socket.init(document: document) + html = Agent.get(renders, & &1) + opened = open_page(state, "/post", Volt.HMR.Document.etag(html)) + + Agent.update(renders, fn _ -> page("second", "{}") end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + refute Map.has_key?(Jason.decode!(json)["payload"], "html") + end + + defp page(text, props) do + """ + Post +

#{text}

+
+ + + + """ + end + + defp open_page(state, path, etag) do + frame = Jason.encode!(%{type: "page", payload: %{path: path, etag: etag}}) + {:ok, state} = Volt.HMR.Socket.handle_in({frame, opcode: :text}, state) + state + end + + defp document_update, + do: {:volt_hmr, :update, %{path: "content/post.md", changes: ["document"]}} + end + describe "handle_info/2" do test "broadcasts HMR messages as JSON" do {:ok, state} = Volt.HMR.Socket.init(nil) From 0f1d4d47190fa5eefa4dc35d5c0ccecdd8737bf0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 21:04:37 +0400 Subject: [PATCH 2/2] Push root and head changes, and start from the served HTML The comparison of two server renders now also names the attributes the server changed on and , and the metadata and links it changed in . The client applies only those, so an attribute a script set, such as a theme, stays; a blind copy from the new document would remove it. A page's websocket process rendered the page when it connected, to have something to compare with. The dev server now keeps the HTML of each page it serves until the page connects, so opening a page costs no extra render. At most 64 pages per session are kept, for pages that never connect. A browser test covers the whole push flow: no request from the page, an owned element re-rendered by its owner, and root and head changes applied. --- .reach.exs | 1 + CHANGELOG.md | 1 + guides/features/hmr.md | 8 +- lib/volt/application.ex | 1 + lib/volt/dev/cleanup.ex | 1 + lib/volt/dev_server.ex | 11 ++- lib/volt/dev_server/client_tag.ex | 22 ++++-- lib/volt/hmr/document.ex | 80 +++++++++++++++++--- lib/volt/hmr/documents.ex | 31 ++++++++ lib/volt/hmr/socket.ex | 27 ++++--- priv/ts/client/document.ts | 44 ++++++++++- priv/ts/client/hmr.ts | 8 +- test/volt/hmr/document_test.exs | 38 +++++++++- test/volt/hmr/socket_test.exs | 20 +++++ test/volt/integration/hmr_test.exs | 113 ++++++++++++++++++++++++++++- 15 files changed, 366 insertions(+), 40 deletions(-) create mode 100644 lib/volt/hmr/documents.ex diff --git a/.reach.exs b/.reach.exs index 95d1719..102124f 100644 --- a/.reach.exs +++ b/.reach.exs @@ -152,6 +152,7 @@ infrastructure = [ "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", "Volt.HMR.Boundary", + "Volt.HMR.Documents", "Volt.HMR.Errors", "Volt.HMR.GlobGraph", "Volt.HMR.ImportGraph", diff --git a/CHANGELOG.md b/CHANGELOG.md index 17153a0..2fa9fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 `` and `` and the metadata and links it changed in `` 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. ## 0.20.0 - 2026-10-03 diff --git a/guides/features/hmr.md b/guides/features/hmr.md index 0778ff9..e1a43cc 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -141,9 +141,11 @@ Each open page has its own websocket process, which lives as long as the page do - 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 the preserved elements whose attributes the server changed. +- otherwise the new HTML to patch, with what the server changed outside the body's contents: attributes of `` and ``, metadata and links in ``, 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. 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. +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 ``, 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) => { @@ -152,7 +154,7 @@ island.addEventListener("volt:element-update", (event) => { }) ``` -Only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. +Without `:document`, only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. ## Ignoring watcher paths diff --git a/lib/volt/application.ex b/lib/volt/application.ex index b1c20d9..8a8ac3b 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -11,6 +11,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() # Watchers release their Tailwind contexts when they terminate, so the # Tailwind processes start before them and stop after them. diff --git a/lib/volt/dev/cleanup.ex b/lib/volt/dev/cleanup.ex index ac813c3..7fdd716 100644 --- a/lib/volt/dev/cleanup.ex +++ b/lib/volt/dev/cleanup.ex @@ -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 diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index f7ae1cf..6f813d4 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -358,11 +358,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} -> diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex index 0cfc0fb..2121faa 100644 --- a/lib/volt/dev_server/client_tag.ex +++ b/lib/volt/dev_server/client_tag.ex @@ -15,31 +15,37 @@ 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 + if session, do: Volt.HMR.Documents.put(session, etag, html) conn = Conn.put_resp_header(conn, "etag", etag) %{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 diff --git a/lib/volt/hmr/document.ex b/lib/volt/hmr/document.ex index 478b874..b0a0b92 100644 --- a/lib/volt/hmr/document.ex +++ b/lib/volt/hmr/document.ex @@ -56,30 +56,88 @@ defmodule Volt.HMR.Document do """ @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. + the server changed, never what scripts did to the page since. An attribute a + script set on `` 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, owned}`, where `owned` lists the - preserved elements whose attributes changed. Their owner, such as a mounted - component, is told and re-renders; the rest of the page is patched. + removed. Otherwise returns `{:patch, patch}`. """ - @spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, [owned_change()]} + @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) - cond do - scripts(previous) != scripts(next) -> :reload - stylesheets(previous) != stylesheets(next) -> :reload - true -> owned_changes(preserved(previous, preserve), preserved(next, preserve)) + 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"), @@ -110,9 +168,9 @@ defmodule Volt.HMR.Document do before != now, do: %{index: index, attributes: now} - {:patch, owned} + {:ok, owned} else - :reload + :error end end diff --git a/lib/volt/hmr/documents.ex b/lib/volt/hmr/documents.ex new file mode 100644 index 0000000..d3a2bc5 --- /dev/null +++ b/lib/volt/hmr/documents.ex @@ -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 diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index 0370090..f84feee 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -69,15 +69,13 @@ defmodule Volt.HMR.Socket do end end - # The page says where it is and which HTML it was served. The render kept for - # it must be that HTML; if the sources changed in between, the page falls back + # The page says where it is and which HTML it was served. That HTML was kept + # when it was sent. If it is gone, the page is rendered again, and the render + # is used only if it is the HTML the page has; otherwise the page falls back # to revalidating itself. defp open_page(%__MODULE__{document: document} = state, %{"path" => path, "etag" => etag}) - when not is_nil(document) and is_binary(path) do - case render(document, path) do - {:ok, html} -> %{state | path: path, html: if(Document.etag(html) == etag, do: html)} - _error -> %{state | path: path, html: nil} - end + when not is_nil(document) and is_binary(path) and is_binary(etag) do + %{state | path: path, html: served_html(state, path, etag)} end defp open_page(state, _payload), do: state @@ -114,15 +112,26 @@ defmodule Volt.HMR.Socket do push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) end - defp update_document({:patch, owned}, next, payload, state) do + defp update_document({:patch, patch}, next, payload, state) do etag = Document.etag(next) # The page gets the HTML a request for it would be answered with. tagged = Volt.DevServer.ClientTag.tag(next, etag, state.morph) - payload = Map.merge(payload, %{html: tagged, etag: etag, owned: owned}) + payload = payload |> Map.merge(patch) |> Map.merge(%{html: tagged, etag: etag}) push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: next}) end + defp served_html(state, path, etag) do + with :error <- Volt.HMR.Documents.fetch(state.session, etag), + {:ok, html} <- render(state.document, path), + ^etag <- Document.etag(html) do + html + else + {:ok, html} -> html + _other -> nil + end + end + defp render({module, function, args}, path), do: apply(module, function, [path | args]) defp render(document, path) when is_function(document, 1), do: document.(path) diff --git a/priv/ts/client/document.ts b/priv/ts/client/document.ts index a6419e0..97e009d 100644 --- a/priv/ts/client/document.ts +++ b/priv/ts/client/document.ts @@ -6,8 +6,17 @@ const MORPH = 'data-volt-morph' export type OwnedChange = { index: number; attributes: Record } +export type AttributeChanges = { set: Record; remove: string[] } +export type HeadChanges = { remove: string[]; add: string[] } + /** A page render pushed by the server, which already compared it with the previous one. */ -export type PushedDocument = { html: string; etag: string; owned: OwnedChange[] } +export type PushedDocument = { + html: string + etag: string + owned: OwnedChange[] + root: Record + head: HeadChanges +} /** Where this page is and which HTML it was served, for the server to keep track of it. */ export function pageIdentity() { @@ -19,7 +28,7 @@ export function pageIdentity() { * Apply a render the server pushed. The server compared it with the HTML it * last rendered for this page, so it knows which owned elements it changed. */ -export function applyDocument({ html, etag, owned }: PushedDocument) { +export function applyDocument({ html, etag, owned, root, head }: PushedDocument) { const preserve = document.querySelector(`script[${ETAG}]`)?.getAttribute(MORPH) if (preserve === null || preserve === undefined) return pageReload() @@ -28,9 +37,40 @@ export function applyDocument({ html, etag, owned }: PushedDocument) { if (!morphDocument(next, preserve, changed)) return pageReload() if (!updateOwned(preserve, owned)) return pageReload() + updateRoot(root) + updateHead(head) finishUpdate(etag) } +// Only what the server changed is applied, so attributes that scripts set on +// these elements, such as a theme, stay. +function updateRoot(root: Record) { + for (const [tag, { set, remove }] of Object.entries(root)) { + const element = tag === 'html' ? document.documentElement : document.body + for (const name of remove) element.removeAttribute(name) + for (const [name, value] of Object.entries(set)) element.setAttribute(name, value) + } +} + +function updateHead({ remove, add }: HeadChanges) { + const live = [...document.head.children] + + for (const html of remove) { + const gone = parseHeadElement(html) + const index = live.findIndex((element) => gone !== null && element.isEqualNode(gone)) + if (index !== -1) live.splice(index, 1)[0]?.remove() + } + + for (const html of add) { + const element = parseHeadElement(html) + if (element) document.head.append(element) + } +} + +function parseHeadElement(html: string) { + return new DOMParser().parseFromString(`${html}`, 'text/html').head.firstElementChild +} + // The owner of an element re-renders it from its new attributes and says so by // cancelling the event. Without an owner listening, only a reload applies them. function updateOwned(preserve: string, owned: OwnedChange[]) { diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 83eb178..48d9a9d 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -132,7 +132,13 @@ async function handleUpdate(payload: { const pushed = payload as Partial if (typeof pushed.html === 'string' && typeof pushed.etag === 'string') { - applyDocument({ html: pushed.html, etag: pushed.etag, owned: pushed.owned ?? [] }) + applyDocument({ + html: pushed.html, + etag: pushed.etag, + owned: pushed.owned ?? [], + root: pushed.root ?? {}, + head: pushed.head ?? { remove: [], add: [] } + }) } else { await revalidateDocument() } diff --git a/test/volt/hmr/document_test.exs b/test/volt/hmr/document_test.exs index 61f0154..5612a4a 100644 --- a/test/volt/hmr/document_test.exs +++ b/test/volt/hmr/document_test.exs @@ -10,14 +10,15 @@ defmodule Volt.HMR.DocumentTest do describe "changes/3" do test "patches changes to content" do assert Document.changes(page("

one

"), page("

two

"), nil) == - {:patch, []} + {:patch, %{owned: [], root: %{}, head: %{remove: [], add: []}}} end test "patches data blocks, which the browser does not run" do before = page(~s()) now = page(~s()) - assert Document.changes(before, now, nil) == {:patch, []} + assert Document.changes(before, now, nil) == + {:patch, %{owned: [], root: %{}, head: %{remove: [], add: []}}} end test "reloads when the scripts the page runs differ" do @@ -57,8 +58,37 @@ defmodule Volt.HMR.DocumentTest do ~s(
) ) - assert Document.changes(before, now, "[data-island]") == - {:patch, [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}]} + assert {:patch, %{owned: owned}} = Document.changes(before, now, "[data-island]") + assert owned == [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}] + end + + test "names the attributes the server changed on html and body" do + before = + ~s(

x

) + + now = + ~s(

x

) + + assert {:patch, %{root: root}} = Document.changes(before, now, nil) + + assert root == %{ + "html" => %{set: %{"lang" => "ru"}, remove: ["data-a"]}, + "body" => %{set: %{"data-route" => "/x"}, remove: []} + } + end + + test "names the head elements that are gone and that are new" do + before = + page("", ~s()) + + now = page("", ~s()) + + assert {:patch, %{head: head}} = Document.changes(before, now, nil) + + assert head == %{ + remove: [~s()], + add: [~s()] + } end test "reloads when owned elements are added or removed" do diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 6789572..cffa620 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -92,6 +92,26 @@ defmodule Volt.HMR.SocketTest do refute Map.has_key?(payload, "html") end + test "starts from the HTML it was served without rendering again" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + + document = fn "/post" -> + Agent.update(calls, &(&1 + 1)) + {:ok, page("first", "{}")} + end + + served = page("first", "{}") + etag = Volt.HMR.Document.etag(served) + Volt.HMR.Documents.put(:default, etag, served) + on_exit(fn -> Volt.HMR.Documents.clear_session(:default) end) + + {:ok, state} = Volt.HMR.Socket.init(document: document, morph: true) + opened = open_page(state, "/post", etag) + + assert opened.html == served + assert Agent.get(calls, & &1) == 0 + end + test "is not kept track of unless pages are patched in place", %{renders: renders} do document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end {:ok, state} = Volt.HMR.Socket.init(document: document) diff --git a/test/volt/integration/hmr_test.exs b/test/volt/integration/hmr_test.exs index 901eba2..41fbb4a 100644 --- a/test/volt/integration/hmr_test.exs +++ b/test/volt/integration/hmr_test.exs @@ -16,8 +16,54 @@ defmodule Volt.Integration.TestPlug do def call(%Conn{request_path: "/morph/" <> name} = conn, opts), do: document(conn, name, opts[:root], opts[:morph_dev_server]) + # A page the server can render outside a request, so the page's websocket + # process compares renders and pushes what changed. + def call(%Conn{request_path: "/push/" <> _name = path} = conn, opts) do + {:ok, html} = render(path, opts[:root]) + + conn + |> Volt.DevServer.call(opts[:push_dev_server]) + |> Conn.put_resp_content_type("text/html") + |> Conn.send_resp(200, html) + end + + # Every page shares one websocket endpoint, served by the dev server that can + # render documents; it only knows the `/push/` pages. + def call(%Conn{request_path: "/@volt/ws"} = conn, opts), + do: Volt.DevServer.call(conn, opts[:push_dev_server]) + def call(conn, opts), do: asset(conn, opts) + def render("/push/" <> name, root) do + [text, props, lang, description] = + Path.join(root <> "-content", name <> ".md") |> File.read!() |> String.split("|") + + {:ok, + """ + + #{name} + + +
#{text}
+
+ + + """} + end + + def render(_path, _root), do: :error + defp document(conn, name, root, dev_server) do content = File.read!(Path.join(root <> "-content", name <> ".md")) @@ -148,7 +194,20 @@ defmodule Volt.Integration.HMRTest do dev_server_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets") morph_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets", morph: true) - plug_opts = %{root: @fixture_dir, dev_server: dev_server_opts, morph_dev_server: morph_opts} + push_opts = + Volt.DevServer.init( + root: @fixture_dir, + prefix: "/assets", + morph: [preserve: "[data-owned]"], + document: {Volt.Integration.TestPlug, :render, [@fixture_dir]} + ) + + plug_opts = %{ + root: @fixture_dir, + dev_server: dev_server_opts, + morph_dev_server: morph_opts, + push_dev_server: push_opts + } {:ok, server} = Bandit.start_link( @@ -537,6 +596,58 @@ defmodule Volt.Integration.HMRTest do GenServer.stop(watcher) end + test "the server pushes what it changed to a page it keeps a render of", %{frame: frame} do + content_dir = @fixture_dir <> "-content" + File.rm_rf!(content_dir) + File.mkdir_p!(content_dir) + on_exit(fn -> File.rm_rf!(content_dir) end) + File.write!(Path.join(content_dir, "page.md"), "first|1|en|old description") + + {:ok, _} = Frame.goto(frame.guid, url: base_url("/push/page"), timeout: 10_000) + assert {:ok, "1"} = eval_until(frame, "document.body.dataset.loads", "1") + {:ok, _} = eval_poll(frame, "(window.voltKept = 'kept')") + + # The page requests nothing more: what changed arrives over the websocket. + {:ok, _} = + eval_poll( + frame, + "(window.voltFetches = 0, window.fetch = ((f) => (...a) => (window.voltFetches++, f(...a)))(window.fetch), 0)" + ) + + {:ok, watcher} = + Volt.Watcher.start_link( + root: @fixture_dir, + reload_dirs: [content_dir], + name: String.to_atom("volt_integration_push_#{System.unique_integer([:positive])}") + ) + + Process.sleep(150) + File.write!(Path.join(content_dir, "page.md"), "second|2|ru|new description") + + assert {:ok, "second"} = + eval_until(frame, "document.getElementById('content')?.textContent", "second") + + # The owner of the element re-rendered it from the props the server changed. + assert {:ok, "props:2"} = + eval_until(frame, "document.getElementById('owned')?.textContent", "props:2") + + # Attributes the server changed are applied; one a script set stays. + assert {:ok, "ru"} = eval_poll(frame, "document.documentElement.lang") + assert {:ok, "dark"} = eval_poll(frame, "document.documentElement.dataset.theme") + + assert {:ok, "new description"} = + eval_poll(frame, "document.querySelector('meta[name=description]').content") + + assert {:ok, 1} = + eval_poll(frame, "document.querySelectorAll('meta[name=description]').length") + + assert {:ok, "kept"} = eval_poll(frame, "window.voltKept") + assert {:ok, "1"} = eval_poll(frame, "document.body.dataset.loads") + assert {:ok, 0} = eval_poll(frame, "window.voltFetches") + + GenServer.stop(watcher) + end + test "non-accepted module updates trigger a full reload", %{frame: frame} do write_fixture("full_reload.ts", """ const count = Number(sessionStorage.getItem('voltReloadCount') ?? '0') + 1