Bundling

The compiler builds one runtime bundle, which every page loads, one bundle per page, and chunks, which hold the code of struct types and load only when a struct needs them. A page bundle holds the Elixir code the page's client side can reach from its template, its actions and its layout's, compiled to JavaScript. Code that runs only on the server, like init/3 and commands, is not bundled, but the values it hands to the client can bring code with them, as the sections below describe.

Struct Types

Client code that works with a struct needs the struct's code: its protocol implementations, like String.Chars to interpolate it in a template, and its __struct__ functions, which build it. Neither the runtime bundle nor a page bundle holds them. The compiler writes them to chunks, which are separate files, and the browser loads a type's chunks when a struct of that type reaches it: in the state init/3 sets, in an action a command returns, or in an action the server broadcasts.

def init(_params, component, _server) do
  # Loads Price's chunks: the struct reaches the state.
  price = %Price{amount: 1_000, currency: :EUR}

  # Loads nothing: only the string reaches the state.
  every = "Every #{Duration.to_iso8601(Duration.new!(minute: 30))}"

  put_state(component, price: price, every: every)
end

Before the server sends a value to the browser, it checks which struct types the value holds and tells the browser which chunks to load. In the example above the state holds a Price, so the browser loads Price's chunks. The Duration never leaves the server. Only a string made from it does, so the browser loads nothing for it.

Where a struct comes from makes no difference. One read from the database or received from another process loads its chunks the same way as one built in init/3.

A module atom counts too. When init/3 puts type: User into the state, or a command sends it in an action's params, the browser loads User's chunks, so struct(type, name: "Ada") in an action works. A module the client builds itself from a string, with String.to_existing_atom/1 or Module.concat/2, loads nothing.

A struct that client code builds never passes through the server. A page therefore preloads the chunks of the struct types its own client code names, in its template or in its actions. Every page also preloads the chunks of the few struct types the runtime bundle's own code builds, like Range and MapSet.

A page mounts once the chunks its state needs have loaded, and an action the server sends runs once its chunks have loaded. The development server speaks HTTP/1.1, so the browser fetches six chunks at a time there. A deployment served over HTTP/2 fetches them in parallel on one connection.

Reflection Functions

An Ecto schema's __changeset__/0 and __schema__/1,2 are reflection functions. A call that names the module, like User.__changeset__(), reaches them like any other function. A call on a module the code does not name reaches them only at runtime:

schema = component.state.schema
schema.__changeset__()

So a type's reflection functions ship with a page when two things hold. The type can reach the page: the page's client code names it, or init/3, a command or code that broadcasts an action creates it. And the page's client code, or the runtime every page loads, calls the function on a module it does not name, like schema.__changeset__() above or apply(schema, :__schema__, [:fields]). A call on a function's parameter counts only when some caller can pass that parameter something other than a module written in the code.

A call through apply/3 with a function name known only at runtime is not detected, as for any other function, so a reflection function reached only that way is not bundled and the call raises an UndefinedFunctionError on the client, whose message says why. Name the function in the call, or reference it anywhere in client-reachable code.

Dynamic Components

A dynamic node renders a component whose module is picked at runtime. It is covered in the Template Syntax documentation, under Dynamic Nodes.

To render a dynamic component on the client, the compiler must know its module at build time. It bundles a component whenever the module atom appears as a literal in code it can see - a template, any client-reachable function, a page's or component's init/3 or command/3 server callback, or any code that broadcasts an action - put_broadcast/3,4 inside a handler, Hologram.Realtime.broadcast_action/2,3 outside one. So a module put into state by init/3 or sent back in action params by command/3 works without any extra declaration.

The search follows calls forward, so functions those callbacks call count too, but their own callers don't - a module picked elsewhere and passed down into a broadcasting helper won't be found. Modules that exist only at runtime don't work at all: ones built with Module.concat/1, resolved with String.to_existing_atom/1, or read from the database or config without a literal anywhere in the app's code are not bundled, and rendering them on the client raises.

A component referenced in broadcast code goes into the shared runtime bundle rather than a page bundle, so it ships with every page - a broadcast is addressed to a cid, and which page has that cid mounted is only known at runtime.

Related

To fail the build when a bundle grows past a size, see max_bundle_size in the Configuration documentation. The limit covers the runtime bundle and the page bundles. It does not cover chunks, since the browser loads one only when a struct of its type reaches it.

Sponsored by
Curiosum
Main sponsor
Erlang Ecosystem Foundation
Milestone sponsor
Evo Store
Performance sponsor