Skip to content

Instantly share code, notes, and snippets.

@bensheldon
Last active June 27, 2025 14:32
Show Gist options
  • Save bensheldon/94483f08e6e2fd48da0b7630d9b583d6 to your computer and use it in GitHub Desktop.
Save bensheldon/94483f08e6e2fd48da0b7630d9b583d6 to your computer and use it in GitHub Desktop.
import {Controller} from "@hotwired/stimulus"
// Warns if form fields have unsaved changes before leaving the page.
// Changes are stored in Session Storage to restore un-warnable events
// like using the back button
//
// To use:
// <form data-controller="unsaved-changes">
// <input type="text" name="name" data-unsaved-changes-target="field">
export default class extends Controller {
static targets = ["field"]
static values = {
message: {type: String, default: "You have unsaved changes. Are you sure you want to leave?"}
}
initialize() {
this.fieldStorage = new FieldStorage();
this.isLinkClick = false;
this.isFormSubmission = false;
this.isOtherFormSubmission = false;
this.inputChanged = this.inputChanged.bind(this);
this.restoreFormValues = this.restoreFormValues.bind(this);
}
connect() {
this.restoreFormValues();
document.addEventListener("turbo:load", this.restoreFormValues);
window.addEventListener("popstate", this.restoreFormValues);
this.linkClickHandler = (event) => {
this.isLinkClick = true;
}
document.addEventListener("turbo:click", this.linkClickHandler);
this.formSubmitHandler = (event) => {
this.isFormSubmission = true;
if (event.target === this.element) {
this.isThisFormSubmission = true;
this.#reset();
} else {
this.isThisFormSubmission = false;
}
};
document.addEventListener("submit", this.formSubmitHandler);
this.turboBeforeVisitHandler = (event) => {
const isTurboRefresh = !this.isLinkClick && !this.isFormSubmission;
const isTurboPermanent = !!this.element.closest('[data-turbo-permanent]')
const isOkFormSubmission = this.isThisFormSubmission || isTurboPermanent
if (this.#hasUnsavedChanges() && !isTurboRefresh && (!this.isFormSubmission || !isOkFormSubmission)) {
if (confirm(this.messageValue)) {
this.#reset();
}
else {
event.preventDefault();
}
}
this.isLinkClick = false;
this.isFormSubmission = false;
this.isThisFormSubmission = false;
};
document.addEventListener("turbo:before-visit", this.turboBeforeVisitHandler);
// The beforeunload handler is only enabled when there are changes because it disables bf cache
// Because Turbo intercepts most events, this only is triggered for:
// - Closing the browser tab/window
// - Refreshing the page
this._beforeUnloadHandler = (event) => {
if (this.#hasUnsavedChanges() && !(this.isFormSubmission && this.isThisFormSubmission)) {
event.preventDefault();
// This message may not be displayed in modern browsers, but a confirmation dialog will still appear.
event.returnValue = this.messageValue;
return event.returnValue;
}
};
}
disconnect() {
this.#unregisterUnloadHandler();
document.removeEventListener("turbo:load", this.restoreFormValues);
window.removeEventListener("popstate", this.restoreFormValues);
document.removeEventListener("turbo:click", this.linkClickHandler);
document.removeEventListener("submit", this.formSubmitHandler);
document.removeEventListener("turbo:before-visit", this.turboBeforeVisitHandler);
}
// Restore form values from sessionStorage
restoreFormValues() {
this.fieldTargets.forEach(field => {
const storedValue = this.fieldStorage.get(field);
if (storedValue !== null) {
field.value = storedValue;
this.inputChanged({target: field});
}
});
}
fieldTargetConnected(element) {
element.addEventListener("input", this.inputChanged);
element.addEventListener("change", this.inputChanged);
}
fieldTargetDisconnected(element) {
element.removeEventListener("input", this.inputChanged);
element.removeEventListener("change", this.inputChanged);
}
inputChanged(event) {
const field = event.target;
if (!this.fieldTargets.includes(field)) { return }
const isModified = field.value !== field.defaultValue;
field.classList.toggle('is-modified', isModified);
if (isModified) {
this.fieldStorage.set(field);
this.#registerUnloadHandler();
} else {
this.fieldStorage.unset(field);
if (!this.#hasUnsavedChanges()) {
this.#unregisterUnloadHandler();
}
}
}
#reset() {
this.fieldStorage.unset(this.fieldTargets);
this.#unregisterUnloadHandler();
}
#hasUnsavedChanges() {
return this.fieldTargets.some(field => {
return field.value !== field.defaultValue;
});
}
#registerUnloadHandler() {
if (!this.beforeUnloadHandler) {
this.beforeUnloadHandler = this._beforeUnloadHandler;
window.addEventListener("beforeunload", this.beforeUnloadHandler);
}
}
#unregisterUnloadHandler() {
if (this.beforeUnloadHandler) {
window.removeEventListener("beforeunload", this.beforeUnloadHandler);
this.beforeUnloadHandler = null;
}
}
}
class FieldStorage {
get(field) {
const key = this.key(field);
if (key) {
return sessionStorage.getItem(key);
}
}
set(field) {
const key = this.key(field);
if (key) {
sessionStorage.setItem(key, field.value);
}
}
unset(fields) {
fields = Array.isArray(fields) ? fields : [fields];
fields.forEach(field => {
const key = this.key(field);
if (key) {
sessionStorage.removeItem(key);
}
});
}
key(field) {
const url = window.location.pathname;
const fieldId = field.id || field.name
if (fieldId) {
return `unsaved_changes_${btoa(url)}_${fieldId}`.replace(/[^a-z0-9]/gi, '_');
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment