beforeunload in Phoenix LiveView

Usually beforeunload is pretty straightforward:

function formHasChanged() {
  window.addEventListener(`beforeunload`, (e) => {
    e.preventDefault()
  })
}

Now just call formHasChanged once form field values have changed.

However, Phoenix LiveView is a different case because of its pushState-based navigation nature, which means we need to polyfill this in addition to the browser-native implementation above:

const message = `You have unsaved changes. Are you sure you want to leave?`

function handleWinBeforeunload(e) {
  e.preventDefault()
}

function handleDocClick(e) {
  const link = e.target.closest(`a[data-phx-link]`)
  if (!link) return

  if (!confirm(message)) {
    e.preventDefault()
    e.stopImmediatePropagation()
  } else stop()
}

function start() {
  window.addEventListener(`beforeunload`, handleWinBeforeunload)
  document.addEventListener(`click`, handleDocClick, true)
}

function stop() {
  window.removeEventListener(`beforeunload`, handleWinBeforeunload)
  document.removeEventListener(`click`, handleDocClick, true)
}

window.addEventListener(`phx:start-beforeunload`, start)
window.addEventListener(`phx:stop-beforeunload`, stop)

Everything starts with phx:*-beforeunload listeners that can be initiated from either server or client side. That leads to browser-native beforeunload handling to cover typical navigation cases, and handleDocClick that handles Phoenix’s link clicks and fires up a confirmation dialog with a custom message.

Initiate “before unload” on form changes in a Phoenix module:

def render(assigns) do
  ~H"""
    <.form phx-submit="save" phx-change="validate">
      <!-- inputs -->
    </form>
  """
end

def handle_event("validate", %{"post" => post_params}, socket) do
  changeset =
    %Post{}
    |> Blog.change_post(post_params)
    |> push_event("start-beforeunload", %{})

  {:noreply, assign(socket, :changeset, changeset)}
end

Finally cancel “before unload” when form values are saved successfully:

def handle_event("save", %{"post" => post_params}, socket) do
  case Blog.create_post(post_params) do
    {:ok, _post} ->
      {:noreply,
        socket
        |> push_event("stop-beforeunload", %{})
        |> assign(:changeset, Blog.change_post(%Post{}))}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

May Elixir prolong your life!

&