Repository: livebook-dev/kino
Pull request #489 (2025-02-10 22:12:59)

Add Kino.Screen
I choose to keep `control/2` for now because using `handle_event/3` requires us to wrap everything in `{:noreply, ...}` tuples. But if you prefer `handle_event`, let me know @hugobarauna / @jonatanklosko.

I also have to figure out how to control that a certain page should only be seen by a certain user (the `client_id`).

---

If someone wants to give it a try without depending on Kino main, here is a notebook:

`````
# Kino.Screen demo

```elixir
Mix.install [:kino]
```

## Section

```elixir
defmodule Kino.Screen do
  @moduledoc ~S"""
  Provides a LiveView like experience for building forms in Livebook.

  Each screen must implement the `c:init/1` and `c:render/1` callbacks.
  Event handlers can be attached by calling the `control/2` function.
  Note the screen state is shared across all users seeing the given page.

  Let's see some examples.

  ## Dynamic select

  Here is an example that allows you to render different forms depending
  on the value of a select, each form triggering a different action:

      defmodule MyScreen do
        @behaviour Kino.Screen

        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
        import Kino.{Control, Input, Screen}

        # In the state, we track the current selection and the frame to print results to
        def init(results_frame) do
          {:ok, %{selection: :name, frame: results_frame}}
        end

        # A form to search by name...
        def render(%{selection: :name} = state) do
          form(
            [name: text("Name")],
            submit: "Search"
          )
          |> control(&by_name/2)
          |> add_layout(state)
        end

        # A form to search by address...
        def render(%{selection: :address} = state) do
          form(
            [address: text("Address")],
            submit: "Search"
          )
          |> control(&by_address/2)
          |> add_layout(state)
        end

        # The general layout of the scren
        defp add_layout(element, state) do
          select =
            select("Search by", [name: "Name", address: "Address"], default: state.selection)
            |> control(&selection/2)

          Kino.Layout.grid([select, element, state.frame])
        end

        ## Events handlers

        defp selection(%{value: selection}, state) do
          %{state | selection: selection}
        end

        defp by_name(%{data: %{name: name}}, state) do
          Kino.Frame.render(state.frame, "SEARCHING BY NAME: #{name}")
          state
        end

        defp by_address(%{data: %{address: address}}, state) do
          Kino.Frame.render(state.frame, "SEARCHING BY ADDRESS: #{address}")
          state
        end
      end

      results_frame = Kino.Frame.new()
      Kino.Screen.new(MyScreen, results_frame)

  ## Wizard like

  Here is an example of how to build wizard like functionality with `Kino.Screen`:

      defmodule MyScreen do
        @behaviour Kino.Screen

        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
        import Kino.{Control, Input, Screen}

        # Our screen will guide the user to provide its name and address.
        # We also have a field keeping the current page and if there is an error.
        def init(:ok) do
          {:ok, %{page: 1, name: nil, address: nil, error: nil}}
        end

        # The first screen gets the name.
        #
        # The `control/2` function comes from `Kino.Screen` and it specifies
        # which function to be invoked on form submission.
        def render(%{page: 1} = state) do
          form(
            [name: text("Name", default: state.name)],
            submit: "Step one"
          )
          |> control(&step_one/2)
          |> add_layout(state)
        end

        # The next screen gets the address.
        #
        # We also call `add_go_back/1` to add a back button.
        def render(%{page: 2} = state) do
          form(
            [address: text("Address", default: state.address)],
            submit: "Step two"
          )
          |> control(&step_two/2)
          |> add_layout(state)
        end

        # The final screen shows a success message.
        def render(%{page: 3} = state) do
          Kino.Text.new("Well done, #{state.name}. You live in #{state.address}.")
          |> add_layout(state)
        end

        # This is the layout shared across all pages.
        defp add_layout(element, state) do
          prefix = if state.error do
            Kino.Text.new("Error: #{state.error}!")
          end

          suffix = if state.page > 1 do
            button("Go back")
            |> control(&go_back/2)
          end

          [prefix, element, suffix]
          |> Enum.reject(&is_nil/1)
          |> Kino.Layout.grid()
        end

        ## Events handlers

        defp step_one(%{data: %{name: name}}, state) do
          if name == "" do
            %{state | name: name, error: "name can't be blank"}
          else
            %{state | name: name, page: 2}
          end
        end

        defp step_two(%{data: %{address: address}}, state) do
          if address == "" do
            %{state | address: address, error: "address can't be blank"}
          else
            %{state | address: address, page: 2}
          end
        end

        defp go_back(_, state) do
          %{state | page: state.page - 1}
        end
      end

      Kino.Screen.new(MyScreen, :ok)
  """

  defmodule Server do
    @moduledoc false

    use GenServer

    def start_link(mod_frame_state) do
      GenServer.start_link(__MODULE__, mod_frame_state)
    end

    def control(from, fun) when is_function(fun, 2) do
      Kino.Control.subscribe(from, {__MODULE__, fun})
      from
    end

    @impl true
    def init({module, frame, state}) do
      {:ok, state} = module.init(state)
      {:ok, render(module, frame, nil, state)}
    end

    @impl true
    def handle_info({{__MODULE__, fun}, data}, {module, frame, client_id, state}) do
      state = fun.(data, state)
      {:noreply, render(module, frame, client_id, state)}
    end

    defp render(module, frame, client_id, state) do
      Kino.Frame.render(frame, module.render(state), to: client_id)
      {module, frame, client_id, state}
    end
  end

  @typedoc "The state of the screen"
  @type state :: term()

  @doc """
  Callback invoked when the screen is initialized.

  It receives the second argument given to `new/2` and
  it must return the screen state.
  """
  @callback init(state) :: {:ok, state}

  @doc """
  Callback invoked to render the screen, whenever there
  is a control event.

  It receives the state and it must return a renderable output.
  """
  @callback render(state) :: term()

  @doc """
  Receives a control or an input and invokes the given 2-arity function
  once its actions are triggered.
  """

  @spec control(element, (map(), state() -> state())) :: element
        when element: Kino.Control.t() | Kino.Input.t()
  defdelegate control(element, fun), to: Server

  def new(module, state) when is_atom(module) do
    frame = Kino.Frame.new()
    {:ok, _pid} = Kino.start_child({Server, {module, frame, state}})
    frame
  end
end

```

```elixir
defmodule MyScreen do
  @behaviour Kino.Screen

  # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
  import Kino.{Control, Input, Screen}

  def init(results_frame) do
    {:ok, %{selection: :name, frame: results_frame}}
  end

  # A form to search by name...
  def render(%{selection: :name} = state) do
    form(
      [name: text("Name")],
      submit: "Search"
    )
    |> control(&by_name/2)
    |> add_layout(state)
  end

  # A form to search by address...
  def render(%{selection: :address} = state) do
    form(
      [address: text("Address")],
      submit: "Search"
    )
    |> control(&by_address/2)
    |> add_layout(state)
  end

  # Here we add the select
  defp add_layout(element, state) do
    select =
      select("Search by", [name: "Name", address: "Address"], default: state.selection)
      |> control(&selection/2)

    Kino.Layout.grid([select, element, state.frame])
  end

  ## Events handlers

  defp selection(%{value: selection}, state) do
    %{state | selection: selection}
  end

  # On the search, you can either add new clauses
  defp by_name(%{data: %{name: name}}, state) do
    Kino.Frame.render(state.frame, "SEARCHING BY NAME: #{name}")
    state
  end

  defp by_address(%{data: %{address: address}}, state) do
    Kino.Frame.render(state.frame, "SEARCHING BY ADDRESS: #{address}")
    state
  end
end

results_frame = Kino.Frame.new()
Kino.Screen.new(MyScreen, results_frame)
`````


hugobarauna: @josevalim as we talked, I like this direction. I built this dynamic form based on this PR:

https://github.com/user-attachments/assets/e85e20da-5f8e-4636-9628-4622648328eb

Here's the source code:

```elixir
defmodule MyForm do
  @behaviour Kino.Screen
  import Kino.{Control, Input, Layout, Screen}

  # Constants
  @country_options [empty: "", usa: "USA", can: "Canada"]
  @language_options %{
    usa: [empty: "", en: "English"],
    can: [empty: "", en: "English", fr: "French"]
  }

  @impl true
  def render(state), do: build_form(state)

  # Form Building
  defp build_form(%{values: values} = state) do
    fields = form_fields(values)

    form(fields, report_changes: true, submit: "Submit")
    |> control(&handle_event/2)
    |> add_layout(state)
  end

  defp form_fields(form_values) when map_size(form_values) == 0 do
    [country: country_select()]
  end

  defp form_fields(%{country: selected_country, language: selected_language}) do
    [
      country: country_select(selected_country),
      language: language_select(selected_country, selected_language)
    ]
  end

  defp form_fields(%{country: selected_country}) do
    [
      country: country_select(selected_country),
      language: language_select(selected_country)
    ]
  end

  # Form Controls
  defp country_select(selected \\ :empty) do
    select("Country", @country_options, default: selected)
  end

  defp language_select(country, selected \\ :empty) do
    language_options = Map.get(@language_options, country)
    select("Language", language_options, default: selected)
  end

  # Event Handling
  defp handle_event(%{data: %{country: :empty}, type: :change}, state) do
    Kino.Frame.append(state.debug_frame, "[event] country selected: empty")
    Kino.Frame.clear(state.frame)
    %{state | values: %{}}
  end

  defp handle_event(
         %{data: %{country: _country, language: :empty}, type: :change} = event,
         state
       ) do
    Kino.Frame.append(state.debug_frame, "[event] language selected: empty")
    Kino.Frame.clear(state.frame)
    %{state | values: event.data}
  end

  defp handle_event(
         %{data: %{country: country, language: language}, type: :change} = event,
         state
       ) do
    language_options = Map.get(@language_options, country)
    language_text = Keyword.get(language_options, language)
    Kino.Frame.append(state.debug_frame, "[event] language selected #{language_text}")
    %{state | values: event.data}
  end

  defp handle_event(%{data: %{country: country}, type: :change} = event, state) do
    country_text = Keyword.get(@country_options, country)
    Kino.Frame.append(state.debug_frame, "[event] country selected: #{country_text}")
    %{state | values: event.data}
  end

  defp handle_event(
         %{data: %{country: country, language: language}, type: :submit} = event,
         state
       ) do
    render_submission(state.frame, country, language)
    %{state | values: event.data}
  end

  # Helpers
  defp add_layout(element, state) do
    grid([element, state.frame, state.debug_frame])
  end

  defp render_submission(frame, country, language) do
    country_text = Keyword.get(@country_options, country)
    language_options = Map.get(@language_options, country)
    language_text = Keyword.get(language_options, language)

    Kino.Frame.render(
      frame,
      Kino.Markdown.new("""
      Form submission:
        - Country: #{country_text}
        - Language: #{language_text}
      """)
    )
  end
end

init_state = %{
  frame: Kino.Frame.new(placeholder: false),
  debug_frame: Kino.Frame.new(placeholder: false),
  values: %{}
}

Kino.Screen.new(MyForm, init_state)
```

This already better that doing all of the event subscribing by myself, like [I did here](https://gist.github.com/hugobarauna/9a8598b6524cf62399149a33a9443667).

But, still feels kinda verbose to build a dynamic form with just two fields.

I don't know if there would be a better way, but it would be nice to try.
josevalim: I have updated the docs to include your dynamic form example.
jonatanklosko: @josevalim lgtm! We are only missing tests. If you prefer me to add some, let me know :)
hugobarauna: @josevalim I'm satisfied with the API and abstraction level. I even asked Claude if we could build a `Kino.DynamicForm` abstraction on top of `Kino.Screen` and it doesn't look like it added much more help at this point.

So for me it's :shipit: 

Patch:
diff --git a/lib/kino/control.ex b/lib/kino/control.ex
index f9f676b..7e262b6 100644
--- a/lib/kino/control.ex
+++ b/lib/kino/control.ex
@@ -178,11 +178,15 @@ defmodule Kino.Control do
   Creates a new form.
 
   A form is composed of regular inputs from the `Kino.Input` module,
-  however in a form input values are not synchronized between users.
-  Consequently, the form is another control for producing user-specific
-  events.
+  however in a form, input values are not synchronized between users.
+  Instead the form emits user-specific events with the input values.
 
-  Either `:submit` or `:report_changes` must be specified.
+  The first argument is a keyword list of fields, where the value is
+  either an input or nil. If the value is nil, it means the data has
+  the input value set to nil too. This is useful in cases where the
+  forms inputs may be generated dynamically.
+
+  Either `:submit` or `:report_changes` must be specified as option.
 
   ## Options
 
@@ -244,19 +248,19 @@ defmodule Kino.Control do
       #=>     type: :submit
       #=>   }}
   """
-  @spec form(list({atom(), Kino.Input.t()}), keyword()) :: t()
+  @spec form(list({atom(), Kino.Input.t() | nil}), keyword()) :: t()
   def form(fields, opts \\ []) when is_list(fields) do
     if fields == [] do
       raise ArgumentError, "expected at least one field, got: []"
     end
 
     for {field, input} <- fields do
-      unless is_atom(field) do
+      if not is_atom(field) do
         raise ArgumentError,
               "expected each field key to be an atom, got: #{inspect(field)}"
       end
 
-      unless is_struct(input, Kino.Input) do
+      if not is_struct(input, Kino.Input) and not is_nil(input) do
         raise ArgumentError,
               "expected each field to be a Kino.Input widget, got: #{inspect(input)} for #{inspect(field)}"
       end
@@ -267,10 +271,14 @@ defmodule Kino.Control do
     end
 
     fields =
-      Enum.map(fields, fn {field, input} ->
-        # Make sure we use this input only in the form and nowhere else
-        input = Kino.Input.duplicate(input)
-        {field, Kino.Render.to_livebook(input)}
+      Enum.map(fields, fn
+        {field, nil} ->
+          {field, nil}
+
+        {field, input} ->
+          # Make sure we use this input only in the form and nowhere else
+          input = Kino.Input.duplicate(input)
+          {field, Kino.Render.to_livebook(input)}
       end)
 
     submit = Keyword.get(opts, :submit, nil)
diff --git a/lib/kino/js.ex b/lib/kino/js.ex
index c1716e2..0b85ced 100644
--- a/lib/kino/js.ex
+++ b/lib/kino/js.ex
@@ -471,7 +471,7 @@ defmodule Kino.JS do
   ## Export
 
   The output can optionally be exported in notebook source by specifying
-  an `:export` function. The function receives `data` as an argument
+  an `:export` function. The function receives the `data` as an argument
   and should return a tuple `{info_string, payload}`. `info_string`
   is used to annotate the Markdown code block where the output is
   persisted. `payload` is the value persisted in the code block. The
@@ -496,7 +496,8 @@ defmodule Kino.JS do
   > You should prefer to use the `data` argument for computing the
   > export payload. However, if it cannot be inferred from `data`,
   > you should just reference the original value. Do not put additional
-  > fields in `data`, just to use it for export.
+  > fields in `data`, just to use it for export, given those fields
+  > are sent to the client.
   """
   @spec new(module(), term(), keyword()) :: t()
   def new(module, data, opts \\ []) do
diff --git a/lib/kino/js/live.ex b/lib/kino/js/live.ex
index d71663e..fce3df4 100644
--- a/lib/kino/js/live.ex
+++ b/lib/kino/js/live.ex
@@ -318,8 +318,8 @@ defmodule Kino.JS.Live do
   end
 
   @doc false
-  @spec output_info(t()) :: map()
-  def output_info(%__MODULE__{} = kino) do
+  @spec output_attrs(t()) :: map()
+  def output_attrs(%__MODULE__{} = kino) do
     %{
       js_view: %{
         ref: kino.ref,
diff --git a/lib/kino/mermaid.ex b/lib/kino/mermaid.ex
index 0390a90..93e2604 100644
--- a/lib/kino/mermaid.ex
+++ b/lib/kino/mermaid.ex
@@ -42,7 +42,7 @@ defmodule Kino.Mermaid do
     Kino.JS.new(
       __MODULE__,
       %{diagram: diagram, caption: opts[:caption], download: opts[:download]},
-      export: fn diagram -> {"mermaid", diagram} end
+      export: fn data -> {"mermaid", data.diagram} end
     )
   end
 end
diff --git a/lib/kino/render.ex b/lib/kino/render.ex
index 817d622..0184394 100644
--- a/lib/kino/render.ex
+++ b/lib/kino/render.ex
@@ -52,7 +52,7 @@ end
 
 defimpl Kino.Render, for: Kino.Inspect do
   def to_livebook(raw) do
-    Kino.Output.inspect(raw.term)
+    Kino.Render.Any.to_livebook(raw.term)
   end
 end
 
@@ -71,7 +71,7 @@ defimpl Kino.Render, for: Kino.JS.Live do
 
   def to_livebook(kino) do
     Kino.Bridge.reference_object(kino.pid, self())
-    %{js_view: js_view, export: export} = Kino.JS.Live.output_info(kino)
+    %{js_view: js_view, export: export} = Kino.JS.Live.output_attrs(kino)
     %{type: :js, js_view: js_view, export: export}
   end
 end
@@ -164,7 +164,7 @@ defimpl Kino.Render, for: Reference do
         reference |> Kino.ETS.new() |> Kino.Render.to_livebook()
 
       true ->
-        Kino.Output.inspect(reference)
+        Kino.Render.Any.to_livebook(reference)
     end
   end
 
@@ -198,7 +198,7 @@ defimpl Kino.Render, for: Atom do
         Kino.Render.to_livebook(tabs)
 
       true ->
-        Kino.Output.inspect(atom)
+        Kino.Render.Any.to_livebook(atom)
     end
   end
 
@@ -220,7 +220,7 @@ defimpl Kino.Render, for: PID do
         Kino.Render.to_livebook(tabs)
 
       true ->
-        Kino.Output.inspect(pid)
+        Kino.Render.Any.to_livebook(pid)
     end
   end
 end
@@ -229,7 +229,7 @@ defimpl Kino.Render, for: BitString do
   def to_livebook(string) do
     case Kino.Utils.get_image_type(string) do
       nil ->
-        Kino.Output.inspect(string)
+        Kino.Render.Any.to_livebook(string)
 
       type ->
         raw = Kino.Inspect.new(string)
diff --git a/lib/kino/screen.ex b/lib/kino/screen.ex
new file mode 100644
index 0000000..463f0a2
--- /dev/null
+++ b/lib/kino/screen.ex
@@ -0,0 +1,337 @@
+defmodule Kino.Screen do
+  @moduledoc ~S'''
+  Provides a LiveView-like experience for building forms in Livebook.
+
+  The screen receives its initial state and must implement the `c:render/1`
+  callback. Event handlers can be attached by calling the `control/2` function.
+  The first render of the screen is shared across all users and then further
+  interactions happen within a per-user process.
+
+  ## Dynamic form/select
+
+  Here is an example that allows you to build a dynamic form that renders
+  values depending on the chosen options. On submit, you then process the
+  data (with optional validation) and writes the result into a separate frame.
+  The output frame could also be configured to share results across all users.
+
+      defmodule MyScreen do
+        @behaviour Kino.Screen
+
+        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
+        import Kino.{Control, Input, Screen}
+
+        @countries [nil: "", usa: "United States", canada: "Canada"]
+
+        @languages [
+          usa: [nil: "", en: "English", es: "Spanish"],
+          canada: [nil: "", en: "English", fr: "French"]
+        ]
+
+        @defaults %{
+          country: nil,
+          language: nil
+        }
+
+        # This is a function we will use to start the screen.
+        #
+        # Our screen will be placed in a grid with one additional
+        # frame to render results into. And the state of the screen
+        # holds the form data and the result frame itself.
+        def new do
+          result_frame = Kino.Frame.new()
+          state = %{data: @defaults, frame: result_frame}
+
+          Kino.Layout.grid([
+            Kino.Screen.new(__MODULE__, state),
+            result_frame
+          ])
+        end
+
+        def render(%{data: data}) do
+          form(
+            [
+              country: country_select(data),
+              language: language_select(data)
+            ],
+            report_changes: true,
+            submit: "Submit"
+          )
+          |> control(&handle_event/2)
+        end
+
+        defp country_select(data) do
+          select("Country", @countries, default: data.country)
+        end
+
+        defp language_select(data) do
+          if languages = @languages[data.country] do
+            default = if languages[data.language], do: data.language
+            select("Language", languages, default: default)
+          end
+        end
+
+        def handle_event(%{data: data, type: :change}, state) do
+          %{state | data: data}
+        end
+
+        def handle_event(%{data: data, type: :submit, origin: client}, state) do
+          # If you want to validate the data, you could do
+          # here and render a different message.
+          markdown =
+            Kino.Markdown.new("""
+            Submitted!
+            * **Country**: #{data.country}
+            * **Language**: #{data.language}
+            """)
+
+          # We render the results only for the user who submits it,
+          # but you can share it across all by removing to: client.
+          Kino.Frame.render(state.frame, markdown, to: client)
+
+          # Reset form values on submission
+          %{state | data: @defaults}
+        end
+      end
+
+      MyScreen.new()
+
+  ## Wizard example
+
+  Here is an example of how to build wizard-like functionality with `Kino.Screen`:
+
+      defmodule MyScreen do
+        @behaviour Kino.Screen
+
+        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
+        import Kino.{Control, Input, Screen}
+
+        # Our screen will guide the user to provide its name and address.
+        # We also have a field keeping the current page and if there is an error.
+        def new do
+          state = %{page: 1, name: nil, address: nil, error: nil}
+          Kino.Screen.new(__MODULE__, state)
+        end
+
+        # The first screen gets the name.
+        #
+        # The `control/2` function comes from `Kino.Screen` and it specifies
+        # which function to be invoked on form submission.
+        def render(%{page: 1} = state) do
+          form(
+            [name: text("Name", default: state.name)],
+            submit: "Step one"
+          )
+          |> control(&step_one/2)
+          |> add_layout(state)
+        end
+
+        # The next screen gets the address.
+        def render(%{page: 2} = state) do
+          form(
+            [address: text("Address", default: state.address)],
+            submit: "Step two"
+          )
+          |> control(&step_two/2)
+          |> add_layout(state)
+        end
+
+        # The final screen shows a success message.
+        def render(%{page: 3} = state) do
+          Kino.Text.new("Well done, #{state.name}. You live in #{state.address}.")
+          |> add_layout(state)
+        end
+
+        # This is the layout shared across all pages.
+        defp add_layout(element, state) do
+          prefix = if state.error do
+            Kino.Text.new("Error: #{state.error}!")
+          end
+
+          suffix = if state.page > 1 do
+            button("Go back")
+            |> control(&go_back/2)
+          end
+
+          [prefix, element, suffix]
+          |> Enum.reject(&is_nil/1)
+          |> Kino.Layout.grid()
+        end
+
+        ## Events handlers
+
+        defp step_one(%{data: %{name: name}}, state) do
+          if name == "" do
+            %{state | name: name, error: "name can't be blank"}
+          else
+            %{state | name: name, error: nil, page: 2}
+          end
+        end
+
+        defp step_two(%{data: %{address: address}}, state) do
+          if address == "" do
+            %{state | address: address, error: "address can't be blank"}
+          else
+            %{state | address: address, error: nil, page: 3}
+          end
+        end
+
+        defp go_back(_, state) do
+          %{state | page: state.page - 1}
+        end
+      end
+
+      MyScreen.new()
+  '''
+
+  require Logger
+
+  defmodule Server do
+    @moduledoc false
+    use GenServer
+
+    def start_link(mod_frame_state) do
+      GenServer.start_link(__MODULE__, mod_frame_state)
+    end
+
+    @impl true
+    def init({module, frame, fun, event, state}) do
+      {:ok, render(module, frame, event.origin, fun.(event, state))}
+    end
+
+    @impl true
+    def handle_info({{Kino.Screen, fun}, data}, {module, frame, client_id, state}) do
+      state = fun.(data, state)
+      {:noreply, render(module, frame, client_id, state)}
+    end
+
+    def handle_info(msg, data) do
+      Logger.warning("unhandled message by #{inspect(__MODULE__)}: #{inspect(msg)}")
+      {:noreply, data}
+    end
+
+    defp render(module, frame, client_id, state) do
+      Kino.Frame.render(frame, module.render(state), to: client_id)
+      {module, frame, client_id, state}
+    end
+  end
+
+  defmodule Watcher do
+    @moduledoc false
+    use GenServer
+
+    def start_link(mod_frame_state) do
+      GenServer.start_link(__MODULE__, {mod_frame_state, self()})
+    end
+
+    @impl true
+    def init(mod_frame_state_parent) do
+      Kino.Bridge.monitor_clients(self())
+      {:ok, mod_frame_state_parent, {:continue, :init}}
+    end
+
+    @impl true
+    def handle_continue(:init, {{module, frame, state}, parent}) do
+      [_, {DynamicSupervisor, sup, _, _}] = Supervisor.which_children(parent)
+      Kino.Frame.render(frame, module.render(state))
+
+      data = %{
+        module: module,
+        frame: frame,
+        state: state,
+        sup: sup,
+        children: %{}
+      }
+
+      {:noreply, data}
+    end
+
+    @impl true
+    def handle_info({{Kino.Screen, fun}, event}, data) do
+      if not Map.has_key?(event, :origin) do
+        raise "expected control/2 to map to an event with origin"
+      end
+
+      %{module: module, frame: frame, state: state, sup: sup, children: children} = data
+
+      children =
+        case DynamicSupervisor.start_child(sup, {Server, {module, frame, fun, event, state}}) do
+          {:ok, pid} ->
+            Map.put(children, event.origin, pid)
+
+          {:error, error} ->
+            Logger.error(Exception.format_exit(error))
+            children
+        end
+
+      {:noreply, %{data | children: children}}
+    end
+
+    def handle_info({:client_leave, client_id}, %{sup: sup, children: children} = data) do
+      {pid, children} = Map.pop(children, client_id)
+      pid && DynamicSupervisor.terminate_child(sup, pid)
+      {:noreply, %{data | children: children}}
+    end
+
+    def handle_info({:client_join, _}, data) do
+      {:noreply, data}
+    end
+
+    def handle_info(msg, data) do
+      Logger.warning("unhandled message by #{inspect(__MODULE__)}: #{inspect(msg)}")
+      {:noreply, data}
+    end
+  end
+
+  @typedoc "The state of the screen"
+  @type state :: term()
+
+  @doc """
+  Callback invoked to render the screen, whenever there
+  is a control event.
+
+  It receives the state and it must return a renderable
+  output.
+
+  The first time this function is called, it is done within
+  a temporary process until the user first interacts with
+  an element via `control/2`. Then all events happen in a
+  user-specific process.
+  """
+  @callback render(state) :: term()
+
+  @doc """
+  Receives a control or an input and invokes the given 2-arity function
+  once its actions are triggered.
+  """
+  @spec control(element, (map(), state() -> state())) :: element
+        when element: Kino.Control.t() | Kino.Input.t()
+  def control(from, fun) when is_function(fun, 2) do
+    Kino.Control.subscribe(from, {__MODULE__, fun})
+    from
+  end
+
+  @doc """
+  Creates a new screen with the given module and state.
+  """
+  @spec new(module(), term()) :: Kino.Frame.t()
+  def new(module, state) when is_atom(module) do
+    frame = Kino.Frame.new()
+
+    children = [
+      # If they boot, we always restart them in case of errors
+      {DynamicSupervisor, max_restarts: 1_000_000, max_seconds: 1},
+      {Watcher, {module, frame, state}}
+    ]
+
+    opts = [strategy: :one_for_one]
+
+    {:ok, _pid} =
+      Kino.start_child(%{
+        id: __MODULE__,
+        start: {Supervisor, :start_link, [children, opts]},
+        type: :supervisor
+      })
+
+    frame
+  end
+end


Test patch:
diff --git a/lib/kino/test.ex b/lib/kino/test.ex
index c4df05c..623553f 100644
--- a/lib/kino/test.ex
+++ b/lib/kino/test.ex
@@ -200,4 +200,29 @@ defmodule Kino.Test do
   def push_smart_cell_editor_source(kino, source) do
     send(kino.pid, {:editor_source, source})
   end
+
+  @doc """
+  Invokes export for the given `Kino.JS` or `Kino.JS.Live` and returns
+  the result.
+
+  For more details, see `Kino.JS.new/3` and `Kino.JS.Live.new/3`
+  respectively.
+  """
+  def export(kino, timeout \\ 100)
+
+  def export(%Kino.JS{} = kino, timeout) do
+    %{js_view: %{ref: ref, pid: pid}} = Kino.JS.output_attrs(kino)
+    do_export(ref, pid, timeout)
+  end
+
+  def export(%Kino.JS.Live{} = kino, timeout) do
+    %{js_view: %{ref: ref, pid: pid}} = Kino.JS.Live.output_attrs(kino)
+    do_export(ref, pid, timeout)
+  end
+
+  defp do_export(ref, pid, timeout) do
+    send(pid, {:export, self(), %{ref: ref}})
+    assert_receive {:export_reply, export_result, %{ref: ^ref}}, timeout
+    export_result
+  end
 end
diff --git a/test/kino/control_test.exs b/test/kino/control_test.exs
index 4bc4a26..e686986 100644
--- a/test/kino/control_test.exs
+++ b/test/kino/control_test.exs
@@ -48,6 +48,13 @@ defmodule Kino.ControlTest do
                    end
     end
 
+    test "supports nil fields" do
+      assert %Kino.Control{attrs: %{fields: [name: %{}, age: nil]}} =
+               Kino.Control.form([name: Kino.Input.text("Name"), age: nil],
+                 submit: "Send"
+               )
+    end
+
     test "supports boolean values for :reset_on_submit" do
       assert %Kino.Control{attrs: %{reset_on_submit: [:name]}} =
                Kino.Control.form([name: Kino.Input.text("Name")],
diff --git a/test/kino/mermaid_test.exs b/test/kino/mermaid_test.exs
new file mode 100644
index 0000000..4f57fcd
--- /dev/null
+++ b/test/kino/mermaid_test.exs
@@ -0,0 +1,17 @@
+defmodule Kino.MermaidTest do
+  use Kino.LivebookCase, async: true
+
+  test "export" do
+    content = """
+    graph TD;
+      A-->B;
+      A-->C;
+      B-->D;
+      C-->D;
+    """
+
+    kino = Kino.Mermaid.new(content)
+
+    assert export(kino) == {"mermaid", content}
+  end
+end
diff --git a/test/kino/screen_test.exs b/test/kino/screen_test.exs
new file mode 100644
index 0000000..21ca79b
--- /dev/null
+++ b/test/kino/screen_test.exs
@@ -0,0 +1,106 @@
+defmodule Kino.ScreenTest do
+  use Kino.LivebookCase, async: true
+
+  import Kino.Control
+
+  defmodule MyScreen do
+    def new(rendering_fun) do
+      Kino.Screen.new(__MODULE__, rendering_fun)
+    end
+
+    def render(rendering_fun) do
+      rendering_fun.()
+    end
+  end
+
+  test "renders" do
+    _frame = MyScreen.new(fn -> "hello" end)
+
+    assert_output(%{
+      type: :frame_update,
+      update: {:replace, [%{type: :terminal_text, text: "\e[32m\"hello\"\e[0m"}]}
+    })
+  end
+
+  test "renders user update events on control" do
+    parent = self()
+    button = button("hello")
+    frame = MyScreen.new(fn -> control(button, parent) end)
+
+    assert_output(%{type: :frame_update, update: {:replace, [%{type: :control}]}})
+    {watcher, dyn_sup} = screen_tree(frame)
+
+    # Verify a new user spawns a new workers
+    click_button(button, "client1")
+    assert_output_to("client1", %{type: :frame_update, update: {:replace, [%{type: :control}]}})
+
+    assert [{_, user_screen, :worker, [Kino.Screen.Server]}] =
+             DynamicSupervisor.which_children(dyn_sup)
+
+    # Verify the user screen terminates when the client leaves
+    ref = Process.monitor(user_screen)
+    send(watcher, {:client_leave, "unknown_client1"})
+    send(watcher, {:client_leave, "client1"})
+    assert_receive {:DOWN, ^ref, :process, ^user_screen, _}
+  end
+
+  test "does not crash tree when user event cannot be processed/rendered" do
+    ok_button = button("ok")
+    fail_event_button = button("fail_event")
+    fail_render_button = button("fail_render")
+
+    frame =
+      MyScreen.new(fn ->
+        Kino.Layout.grid([
+          Kino.Screen.control(ok_button, fn _, _ -> fn -> ok_button end end),
+          Kino.Screen.control(fail_event_button, fn _, _ -> raise "oops" end),
+          Kino.Screen.control(fail_render_button, fn _, _ -> fn -> raise "oops" end end)
+        ])
+      end)
+
+    assert_output(%{type: :frame_update, update: {:replace, [%{type: :grid}]}})
+    {watcher, _dyn_sup} = screen_tree(frame)
+
+    assert ExUnit.CaptureLog.capture_log(fn ->
+             click_button(fail_event_button, "client1")
+             click_button(ok_button, "client1")
+             assert_output_to("client1", %{type: :frame_update})
+           end) =~ "** (RuntimeError) oops"
+
+    assert Process.alive?(watcher)
+
+    assert ExUnit.CaptureLog.capture_log(fn ->
+             click_button(fail_render_button, "client2")
+             click_button(ok_button, "client2")
+             assert_output_to("client2", %{type: :frame_update})
+           end) =~ "** (RuntimeError) oops"
+
+    assert Process.alive?(watcher)
+  end
+
+  defp control(button, parent) do
+    Kino.Screen.control(button, fn event, state ->
+      send(parent, {:control, event})
+      state
+    end)
+  end
+
+  defp click_button(button, client) do
+    info = %{origin: client}
+    send(button.destination, {:event, button.ref, info})
+  end
+
+  defp screen_tree(frame) do
+    # Fetch screen supervision tree
+    assert_receive {:livebook_reference_object, sup, _}
+                   when is_pid(sup) and sup != self() and sup != frame.pid
+
+    [
+      {Kino.Screen.Watcher, watcher, :worker, _},
+      {DynamicSupervisor, dyn_sup, :supervisor, _}
+    ] = Supervisor.which_children(sup)
+
+    assert [] = DynamicSupervisor.which_children(dyn_sup)
+    {watcher, dyn_sup}
+  end
+end