Domphy

Navigation Blocking

Blocking with beforeunload

The simplest pattern — show the native browser dialog when the user tries to close/refresh the tab with unsaved changes:

import { effect } from "@domphy/core"
import { toState } from "@domphy/core"

const formDirty = toState(false)

effect(() => {
  const dirty = formDirty.get()

  if (dirty) {
    const handler = (e: BeforeUnloadEvent) => {
      e.preventDefault()
      e.returnValue = ""   // Chrome requires this to show the dialog
    }
    window.addEventListener("beforeunload", handler)
    return () => window.removeEventListener("beforeunload", handler)
  }
})

Note: modern browsers show their own generic message in beforeunload — you cannot customize the text.

Blocking router navigation with a confirmation dialog

For in-app navigation (router.navigate, Link clicks, back button), intercept via router.subscribe("onBeforeNavigate", ...) and show a custom dialog:

import { createRouter } from "@domphy/router"
import { toState } from "@domphy/core"

const formDirty = toState(false)
const pendingNavigation = toState<(() => void) | null>(null)

const router = createRouter({ routeTree })

router.subscribe("onBeforeNavigate", ({ event }) => {
  if (formDirty.get()) {
    // Store the navigation intent so we can resume it after confirmation
    pendingNavigation.set(() => event.preventDefault = false)
    event.preventDefault()   // block for now
  }
})

const ConfirmDialog = {
  div: [
    { h2: "Unsaved changes" },
    { p: "You have unsaved changes. Leave anyway?" },
    {
      div: [
        {
          button: "Stay",
          onClick: () => pendingNavigation.set(null),
        },
        {
          button: "Leave",
          onClick: () => {
            formDirty.set(false)                 // clear dirty state
            const resume = pendingNavigation.get()
            pendingNavigation.set(null)
            if (resume) resume()                 // retry the navigation
          },
        },
      ],
    },
  ],
  hidden: (l) => pendingNavigation.get(l) === null,
  style: {
    position: "fixed",
    inset: 0,
    background: "rgba(0,0,0,0.5)",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    zIndex: 100,
  },
}

Auto-save (alternative to blocking)

Instead of blocking navigation, auto-save periodically so there's never unsaved work to lose:

import { effect } from "@domphy/core"
import { createForm } from "@domphy/form/domphy"

const form = createForm<EditInput>({
  defaultValues: { title: "", body: "" },
  onSubmit: async ({ value }) => saveFinal(value),
})

// Auto-save every 10 seconds when dirty
effect(() => {
  const dirty = form.isDirty()
  if (!dirty) return

  const timer = setInterval(() => {
    if (form.isDirty()) saveDraft(form.form.state.values)
  }, 10_000)

  return () => clearInterval(timer)
})

// Also save on tab hide (user switches to another tab)
document.addEventListener("visibilitychange", () => {
  if (document.hidden && form.isDirty()) {
    saveDraft(form.form.state.values)
  }
})

For a specific link that should confirm before leaving:

import { toState } from "@domphy/core"

const showConfirm = toState(false)
const targetHref = toState("")

const SafeLink = (href: string, label: string) => ({
  a: label,
  href,
  onClick: (e: MouseEvent) => {
    if (formDirty.get()) {
      e.preventDefault()
      targetHref.set(href)
      showConfirm.set(true)
    }
  },
})

const LeaveConfirmDialog = {
  div: [
    { h2: "Unsaved changes" },
    { p: "Leave without saving?" },
    {
      div: [
        { button: "Cancel", onClick: () => showConfirm.set(false) },
        {
          button: "Leave",
          onClick: () => {
            formDirty.set(false)
            showConfirm.set(false)
            router.navigate({ to: targetHref.get() })
          },
        },
      ],
    },
  ],
  hidden: (l) => !showConfirm.get(l),
}

ignoreBlocker — bypass confirmation

Some navigations should never be blocked (e.g., logout, error recovery):

router.navigate({
  to: "/login",
  ignoreBlocker: true,   // skip any blocking logic
  replace: true,
})