Current section
Files
Jump to
Current section
Files
lib/filament/renderer.ex
defmodule Filament.Renderer do
@moduledoc false
alias Filament.Fiber
alias Filament.RenderContext
alias Phoenix.HTML.Safe
alias Phoenix.LiveView.Rendered
@doc """
Renders a component with the given props and context.
## Algorithm
1. Validate props via __validate_props__!
2. Set render context in process dictionary (reset hook_index, new_hook_slots, pending_effects)
3. Call component.render(props)
4. Collect new fibers, hook slots, and effects from context
5. Clear render context
6. Return {Rendered, new_hook_slots, pending_effects, new_fibers}
"""
@spec render(module(), map(), RenderContext.t()) ::
{Rendered.t(), %{non_neg_integer() => term()}, list(), %{String.t() => Fiber.t()},
%{non_neg_integer() => function()}}
def render(component_module, props, %RenderContext{} = context) do
# Apply prop defaults for any props not supplied.
# Code.ensure_loaded is required because function_exported?/3 returns false
# for modules that haven't been called yet, since modules load lazily on
# their first function call — which happens later (component_module.render/1).
Code.ensure_loaded(component_module)
props = apply_prop_defaults(component_module, props)
# Validate props
if function_exported?(component_module, :__validate_props__!, 1) do
component_module.__validate_props__!(props)
end
# Look up existing fiber for hook state continuity
existing_fiber = Map.get(context.fiber_tree, context.fiber_id)
hook_slots = if existing_fiber, do: existing_fiber.hook_slots, else: %{}
# Save current context (if any) and set new context with reset state
Process.put(:filament_render_context, %{
context
| hook_index: 0,
new_hook_slots: %{},
pending_effects: [],
event_handler_index: 0,
new_event_handlers: %{},
hook_slots: hook_slots
})
try do
# Call render/1
result = component_module.render(props)
# If render returns a vnode instead of Rendered, recursively render it
rendered =
case result do
%Rendered{} ->
result
vnode when is_tuple(vnode) ->
render_vnode(vnode, context)
_ ->
raise ArgumentError,
"render/1 must return %Phoenix.LiveView.Rendered{} or vnode, got: #{inspect(result)}"
end
# Force evaluation of dynamic expressions so side effects like
# register_event_handler run while the render context is active.
_ = Safe.to_iodata(rendered)
# Harvest context fields
final_ctx = Process.get(:filament_render_context)
{rendered, final_ctx.new_hook_slots, final_ctx.pending_effects, final_ctx.new_fibers,
final_ctx.new_event_handlers}
after
# Always clear render context after render
Process.delete(:filament_render_context)
end
end
@doc """
Renders a Filament child component within the current render pass, creating its
own fiber and registering it as a child of the current fiber.
Called by `Filament.TagEngine.component/3` when inside a Filament render pass
so that sub-components get isolated fibers (and thus isolated hook state).
"""
@spec render_component_child(RenderContext.t(), module(), map()) ::
Rendered.t()
def render_component_child(parent_ctx, mod, props) do
indices = parent_ctx.child_component_indices
index = Map.get(indices, mod, 0)
parent_fiber = Map.get(parent_ctx.fiber_tree, parent_ctx.fiber_id)
child_id =
if parent_fiber do
Fiber.child_id(parent_fiber, mod, {:index, index})
else
"#{parent_ctx.fiber_id}.#{mod}[#{index}]"
end
existing_fiber = Map.get(parent_ctx.fiber_tree, child_id)
hook_slots = if existing_fiber, do: existing_fiber.hook_slots, else: %{}
child_ctx = %RenderContext{
fiber_id: child_id,
fiber_tree: parent_ctx.fiber_tree,
owner_pid: parent_ctx.owner_pid,
new_fibers: %{},
pending_effects: [],
observable_stubs: parent_ctx.observable_stubs,
subscribe_enabled: parent_ctx.subscribe_enabled,
hook_index: 0,
new_hook_slots: %{},
event_handler_index: 0,
new_event_handlers: %{},
hook_slots: hook_slots
}
{rendered_child, child_new_hook_slots, child_pending_effects, grandchild_fibers, child_event_handlers} =
render(mod, props, child_ctx)
child_fiber = %Fiber{
id: child_id,
component: mod,
props: props,
hook_slots: Map.merge(hook_slots, child_new_hook_slots),
event_handlers: child_event_handlers,
children: Map.keys(grandchild_fibers),
parent_id: parent_ctx.fiber_id,
status: if(existing_fiber, do: :stable, else: :mounting)
}
updated_new_fibers =
parent_ctx.new_fibers
|> Map.put(child_id, child_fiber)
|> Map.merge(grandchild_fibers)
updated_ctx = %{
parent_ctx
| new_fibers: updated_new_fibers,
pending_effects: parent_ctx.pending_effects ++ child_pending_effects,
child_component_indices: Map.put(indices, mod, index + 1)
}
Process.put(:filament_render_context, updated_ctx)
rendered_child
end
@doc """
Like `render_component_child/3` but uses a caller-supplied key for fiber identity
instead of a positional index. Used by `Filament.TagEngine.component_keyed/4`.
"""
@spec render_component_child_keyed(RenderContext.t(), module(), map(), term()) :: Rendered.t()
def render_component_child_keyed(parent_ctx, mod, props, key) do
parent_fiber = Map.get(parent_ctx.fiber_tree, parent_ctx.fiber_id)
child_id =
if parent_fiber do
Fiber.child_id(parent_fiber, mod, {:key, key})
else
"#{parent_ctx.fiber_id}.#{mod}[key=#{inspect(key)}]"
end
existing_fiber = Map.get(parent_ctx.fiber_tree, child_id)
hook_slots = if existing_fiber, do: existing_fiber.hook_slots, else: %{}
child_ctx = %RenderContext{
fiber_id: child_id,
fiber_tree: parent_ctx.fiber_tree,
owner_pid: parent_ctx.owner_pid,
new_fibers: %{},
pending_effects: [],
observable_stubs: parent_ctx.observable_stubs,
subscribe_enabled: parent_ctx.subscribe_enabled,
hook_index: 0,
new_hook_slots: %{},
event_handler_index: 0,
new_event_handlers: %{},
hook_slots: hook_slots
}
{rendered_child, child_new_hook_slots, child_pending_effects, grandchild_fibers, child_event_handlers} =
render(mod, props, child_ctx)
child_fiber = %Fiber{
id: child_id,
key: key,
component: mod,
props: props,
hook_slots: Map.merge(hook_slots, child_new_hook_slots),
event_handlers: child_event_handlers,
children: Map.keys(grandchild_fibers),
parent_id: parent_ctx.fiber_id,
status: if(existing_fiber, do: :stable, else: :mounting)
}
updated_new_fibers =
parent_ctx.new_fibers
|> Map.put(child_id, child_fiber)
|> Map.merge(grandchild_fibers)
updated_ctx = %{
parent_ctx
| new_fibers: updated_new_fibers,
pending_effects: parent_ctx.pending_effects ++ child_pending_effects
}
Process.put(:filament_render_context, updated_ctx)
rendered_child
end
@doc """
Recursively renders a vnode tree into Rendered structs.
"""
@spec render_vnode(Filament.VNode.t(), RenderContext.t()) :: term()
def render_vnode({:text, content}, _context) do
content
end
def render_vnode({:element, tag, attrs, children}, context) do
tag_str = to_string(tag)
rendered_children = Enum.map(children, &render_vnode(&1, context))
if void_element?(tag_str) do
["<", tag_str, render_attrs(attrs), ">"]
else
["<", tag_str, render_attrs(attrs), ">", rendered_children, "</", tag_str, ">"]
end
end
def render_vnode({:component, mod, props, key}, context) do
parent_fiber = Map.get(context.fiber_tree, context.fiber_id)
discriminator = if key, do: {:key, key}, else: {:index, 0}
child_id = Fiber.child_id(parent_fiber, mod, discriminator)
existing_fiber = Map.get(context.fiber_tree, child_id)
hook_slots = if existing_fiber, do: existing_fiber.hook_slots, else: %{}
child_context = %RenderContext{
fiber_id: child_id,
fiber_tree: context.fiber_tree,
owner_pid: context.owner_pid,
new_fibers: %{},
pending_effects: [],
observable_stubs: context.observable_stubs,
subscribe_enabled: context.subscribe_enabled,
hook_index: 0,
new_hook_slots: %{},
event_handler_index: 0,
new_event_handlers: %{},
hook_slots: hook_slots
}
# Save parent context before child render/3 overwrites and deletes it
parent_ctx = Process.get(:filament_render_context)
{rendered_child, child_new_hook_slots, child_pending_effects, grandchild_fibers, child_event_handlers} =
render(mod, props, child_context)
child_fiber = %Fiber{
id: child_id,
key: key,
component: mod,
props: props,
hook_slots: Map.merge(hook_slots, child_new_hook_slots),
event_handlers: child_event_handlers,
children: Map.keys(grandchild_fibers),
parent_id: context.fiber_id,
status: if(existing_fiber, do: :stable, else: :mounting)
}
# Restore parent context (deleted by render/3's after block) and update with child info
updated_new_fibers =
parent_ctx.new_fibers
|> Map.put(child_id, child_fiber)
|> Map.merge(grandchild_fibers)
Process.put(:filament_render_context, %{
parent_ctx
| new_fibers: updated_new_fibers,
pending_effects: parent_ctx.pending_effects ++ child_pending_effects
})
rendered_child
end
def render_vnode({:fragment, children}, context) do
Enum.map(children, &render_vnode(&1, context))
end
def render_vnode(invalid, _context) do
raise ArgumentError, "invalid vnode: #{inspect(invalid)}"
end
defp apply_prop_defaults(component_module, props) do
if function_exported?(component_module, :__props__, 0) do
Enum.reduce(component_module.__props__(), props, &apply_single_default(&1, &2))
else
props
end
end
defp apply_single_default({name, meta}, acc) do
if Map.has_key?(acc, name) or meta.default == :__NO_DEFAULT__ do
acc
else
Map.put(acc, name, meta.default)
end
end
defp void_element?("br"), do: true
defp void_element?("hr"), do: true
defp void_element?("input"), do: true
defp void_element?("img"), do: true
defp void_element?("meta"), do: true
defp void_element?("link"), do: true
defp void_element?("area"), do: true
defp void_element?("base"), do: true
defp void_element?("col"), do: true
defp void_element?("embed"), do: true
defp void_element?("param"), do: true
defp void_element?("source"), do: true
defp void_element?("track"), do: true
defp void_element?("wbr"), do: true
defp void_element?(_), do: false
defp render_attrs([]), do: ""
defp render_attrs(attrs) do
{parts, _} =
Enum.map_reduce(attrs, 0, fn {key, value}, on_idx ->
key_str = to_string(key)
if String.starts_with?(key_str, "on_") do
attr_key = "phx-" <> String.slice(key_str, 3..-1//1)
wire_ref = Filament.Hooks.event_at(on_idx, value)
{[" ", attr_key, "=\"", wire_ref, "\""], on_idx + 1}
else
{render_attr_value(key_str, value), on_idx}
end
end)
parts
end
defp render_attr_value(_key_str, false), do: []
defp render_attr_value(key_str, true), do: [" ", key_str]
defp render_attr_value(key_str, value) do
escaped_value = Plug.HTML.html_escape_to_iodata(to_string(value))
[" ", key_str, "=\"", escaped_value, "\""]
end
@doc """
Returns the current render context from the process dictionary.
"""
@spec current_context() :: RenderContext.t() | nil
def current_context do
Process.get(:filament_render_context)
end
@doc """
Gets the next hook slot index and increments the counter.
## Examples
iex> {index, _ctx} = Filament.Renderer.next_hook_slot()
iex> index
0
iex> {index, _ctx} = Filament.Renderer.next_hook_slot()
iex> index
1
"""
@spec next_hook_slot() :: {non_neg_integer(), RenderContext.t()}
def next_hook_slot do
context =
Process.get(:filament_render_context) ||
raise "hook called outside render context"
index = context.hook_index
new_context = %{context | hook_index: index + 1}
Process.put(:filament_render_context, new_context)
{index, new_context}
end
end