Guard unsaved changes
Before you start
Start with a form or editor whose model can say whether leaving would discard
work, served by ZioHttp.routes as in the
quick start.
The guard runtime
The guard needs a small browser runtime. ZioHttp.routes serves it below
/_scalive/assets and adds it to every page's <head>, before the Phoenix
clients and the root layout's own scripts, so it is installed before
LiveSocket connects. Applications with a
custom client bundle
get it too. Do not add its JavaScript to the application's asset tree or
deployment manifest.
The runtime script is marked phx-track-static. A runtime URL change therefore
participates in the same static-change metadata as the application bundle. The
script is external and same-origin, so a common script-src 'self' policy does
not require inline-script permission.
Render a guard
Derive dirty state from canonical typed form values and add
navigation.guardWhendef guardWhen(dirty: Signal[Boolean], message: String): Mod.Attr[Nothing]Guards browser-initiated navigation while dirty is true. to the
element which owns it:
final case class Note(value: String)
object NoteForm:
val Root = FormRoot("note")
val NoteText = Root.text("value")
val Definition = Root.product[Note]((value = NoteText))View source (documentation/fixtures/guides/src/guards/NoteLiveView.scala:8-13)
final class NoteLiveView(notes: Notes) extends LiveView[NoteLiveView.Msg, NoteLiveView.Model]:
import NoteLiveView.*
def mount(ctx: MountContext): Task[Model] =
val form = NoteForm.Definition.initial()
ZIO.succeed(Model(form, baseline = form.values))
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Validate(event) =>
ZIO.succeed(model.copy(form = event.form))
case Msg.Save(event) =>
event.form.result match
case Right(note) =>
notes
.save(note)
.as(model.copy(form = event.form, baseline = event.form.values))
case Left(_) =>
ZIO.succeed(model.copy(form = event.form))
def view(model: Signal[Model]): HtmlElement[Msg] =
val noteField = model.map(_.form).field(NoteForm.NoteText)
form(
idAttr := "note-form",
navigation.guardWhen(
dirty = model.map(_.isDirty),
message = "Discard unsaved changes?"
),
NoteForm.Definition.onChange(Msg.Validate(_)),
NoteForm.Definition.onSubmit(Msg.Save(_)),
label(forId := noteField.id, "Note"),
noteField.text(noteField.validationAttributes),
button(typ := "submit", "Save")
)
end NoteLiveView
object NoteLiveView:
final case class Model(form: NoteForm.Definition.Form, baseline: NoteForm.Definition.Values):
def isDirty: Boolean = form.values != baseline
enum Msg:
case Validate(event: NoteForm.Definition.Event)
case Save(event: NoteForm.Definition.Event)View source (documentation/fixtures/guides/src/guards/NoteLiveView.scala:20-62)
Here Notes is the application's persistence service. Because as advances
the FormValues baseline only after that effect succeeds, a failed save leaves
the guard active. mount builds the initial baseline from the values of the
initial form. A reset or deliberate discard should likewise replace the form
from that baseline. For screens that need reset and stale-save handling
together, use NoteForm.Definition.workflow(form) and derive dirty state from
workflow.isDirty.
The marker is absent while clean and contains the confirmation message while dirty. A blank message is rejected. If several guards are active, the first one in document order supplies the page-level message; prefer one combined guard when several editors belong to the same page.
Understand the coverage
The runtime builds on Phoenix's cancelable phx:before-navigate event and the
browser's beforeunload event:
| Trigger | Behavior |
|---|---|
link.pushNavigate and link.replaceNavigate | Uses the configured confirmation message before browser-initiated live navigation. |
link.pushPatch and link.replacePatch | Uses the configured confirmation message before the live patch. |
| Same-document LiveView Back and Forward | Uses the configured message; Phoenix restores the current history entry when rejected. |
| Cross-document Back and Forward, reload, ordinary navigation, and tab close | Requests the browser's generic unload confirmation. |
ctx.nav, redirects, and server-issued patches | Bypass the configured confirmation because the server lifecycle may already have committed or ended. A resulting document unload may still request the browser's generic confirmation. |
JS.pushNavigate, JS.replaceNavigate, JS.pushPatch, and JS.replacePatch | Bypass the configured confirmation. A resulting document unload may still request the browser's generic confirmation. |
Crossing an incompatible live-session or root-layout boundary may make an accepted live link fall back to an HTTP request. The runtime carries that one acceptance into a link-initiated fallback for up to ten seconds so an ordinary fallback does not prompt twice. After that bound, unload protection is restored; a stalled fallback may therefore show the browser's generic confirmation as a second safety check. A LiveView Back or Forward traversal is reported only after browser history has moved; if that traversal subsequently requires an HTTP fallback, the browser may likewise show its generic unload confirmation.
beforeunload is attached only while a marker is active, which avoids its
Firefox back/forward-cache cost on clean pages. Browsers control the unload
dialog text, require prior user interaction, and may suppress it. Mobile app
termination may not dispatch beforeunload at all. Persist important drafts;
the guard is not a durable-storage mechanism.
Account for server timing
The dirty signal is server-rendered. A browser edit must reach the LiveView, update the model, and apply the resulting diff before the marker becomes active. Do not debounce the change which establishes dirty state when timely guarding matters. There is still a round-trip window in which an immediate Back, reload, or close can precede the marker.
Use local draft persistence or autosave when that window is unacceptable. The framework does not infer dirty state from arbitrary input events because it cannot know the saved baseline, whether a submit succeeded, or which edits are safe to discard.
Related tasks
Build the model and validation flow with Typed forms and validation.
Choose patch and navigation semantics with Routes, parameters, and navigation.
Test native dialogs and history traversal with Testing LiveViews.