From 2afddb48399afff8b456fd2ede0478280639722f Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Fri, 16 Oct 2026 20:20:49 +0200 Subject: [PATCH 1/3] Let browser Back leave the form Settings page Co-Authored-By: Claude Sonnet 5.5 --- resources/js/Pages/Forms/Settings.test.ts | 30 +++++++++++++++++++++++ resources/js/Pages/Forms/Settings.vue | 5 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/resources/js/Pages/Forms/Settings.test.ts b/resources/js/Pages/Forms/Settings.test.ts index 30ad825a..39b916a9 100644 --- a/resources/js/Pages/Forms/Settings.test.ts +++ b/resources/js/Pages/Forms/Settings.test.ts @@ -110,6 +110,36 @@ describe("settings page from history", () => { expect(callGetForm).not.toHaveBeenCalled(); }); + it("adds no history entry when you open Settings or switch tabs", async () => { + const length = history.length; + history.replaceState( + { component: "x", url: "/forms/1/settings" }, + "", + "/forms/1/settings", + ); + + mountSettings(); + await wrapper!.findAll("button[role=tab]")[1].trigger("click"); + await wrapper!.findAll("button[role=tab]")[3].trigger("click"); + + expect(history.length).toBe(length); + expect(location.hash).toBe("#appearance"); + expect(history.state).toEqual({ + component: "x", + url: "/forms/1/settings#appearance", + }); + }); + + it("opens the tab named in the address", async () => { + history.replaceState(null, "", "/forms/1/settings#completion-page"); + + mountSettings(); + + expect( + wrapper!.findAll("button[role=tab]")[2].attributes("aria-selected"), + ).toBe("true"); + }); + it("keeps the tab you switch to while the form loads again", async () => { let landed = () => {}; trackFormSave(new Promise((resolve) => (landed = resolve))); diff --git a/resources/js/Pages/Forms/Settings.vue b/resources/js/Pages/Forms/Settings.vue index e6e6fbc0..25e12295 100644 --- a/resources/js/Pages/Forms/Settings.vue +++ b/resources/js/Pages/Forms/Settings.vue @@ -108,7 +108,10 @@ const navigation: Array<{ const tabChanged = (index: any) => { const item = navigation[index]; - location.hash = item.slug; + // replace the entry: a new one per tab would trap browser Back on Settings. + // Inertia reads history.state.url on a Back that reloads, so it follows the hash + const url = `${location.pathname}${location.search}#${item.slug}`; + history.replaceState({ ...history.state, url }, "", url); selectedTabIndex.value = index; }; From 7f10c4ff6b7354897906520ceca63a8e8c833d0c Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Fri, 16 Oct 2026 20:21:19 +0200 Subject: [PATCH 2/3] Add changelog line for #287 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da88dfdc..21de6e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Lines marked **Upgrade step** need you to act after updating. - A logic rule that uses or goes to a deleted question, or one of another form, is now refused with a message that names the field. Before, it saved and looked like it worked. The blocks API now lists, for each question, the rules that use it (`used_by`). (#264) - The form editor now shows "Saving…", "Saved" or "Not saved" with Retry. A failed save names the question by number and text, and the red bar offers Retry. Reloading or closing the page right after typing keeps the change, and the browser asks before you leave while a change isn't saved. Before, the editor showed nothing, and a reload within a second lost the change. (#267) - A webhook answer that isn't valid UTF-8 text is now saved and shows on the submissions page, with each unreadable character as "?". Before, it wasn't saved and the webhook was called up to 5 times. With `QUEUE_CONNECTION=sync`, the submission itself failed with a server error. (#284) +- Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) ### Security From 61d97179d20fbf7162fc9fa71526226386c855b3 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Fri, 16 Oct 2026 20:36:28 +0200 Subject: [PATCH 3/3] Don't write the Settings tab to the address on load; keep the tab for Back Co-Authored-By: Claude Sonnet 5.5 --- resources/js/Pages/Forms/Settings.test.ts | 23 ++++++++++++++++++++++- resources/js/Pages/Forms/Settings.vue | 17 ++++++++--------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/resources/js/Pages/Forms/Settings.test.ts b/resources/js/Pages/Forms/Settings.test.ts index 39b916a9..6854834b 100644 --- a/resources/js/Pages/Forms/Settings.test.ts +++ b/resources/js/Pages/Forms/Settings.test.ts @@ -9,7 +9,7 @@ import { trackFormSave } from "@/utils/useFreshForm"; vi.mock("@inertiajs/vue3", async (importOriginal) => ({ ...(await importOriginal()), - router: { on: vi.fn() }, + router: { on: vi.fn(), page: { url: "" } }, })); vi.mock("@/api/forms", async (importOriginal) => ({ ...(await importOriginal()), @@ -130,6 +130,27 @@ describe("settings page from history", () => { }); }); + it("leaves the address alone when Settings opens, so Inertia saves its page once", async () => { + const state = { component: "x", url: "/forms/1/settings" }; + history.replaceState(state, "", "/forms/1/settings"); + + mountSettings(); + + expect(location.href).toBe("http://localhost:3000/forms/1/settings"); + expect(history.state).toEqual(state); + }); + + it("tells Inertia the tab, so a Back to this page opens the same tab", async () => { + history.replaceState(null, "", "/forms/1/settings"); + + mountSettings(); + await wrapper!.findAll("button[role=tab]")[3].trigger("click"); + + expect((router as unknown as { page: { url: string } }).page.url).toBe( + "/forms/1/settings#appearance", + ); + }); + it("opens the tab named in the address", async () => { history.replaceState(null, "", "/forms/1/settings#completion-page"); diff --git a/resources/js/Pages/Forms/Settings.vue b/resources/js/Pages/Forms/Settings.vue index 25e12295..724aa714 100644 --- a/resources/js/Pages/Forms/Settings.vue +++ b/resources/js/Pages/Forms/Settings.vue @@ -74,6 +74,7 @@ import Privacy from "@/components/Factory/Settings/Privacy.vue"; import TemplateExport from "@/components/Factory/Settings/TemplateExport.vue"; import { useForm } from "@/stores"; import { useFreshForm } from "@/utils/useFreshForm"; +import { router } from "@inertiajs/vue3"; import { onBeforeUnmount, ref } from "vue"; const props = defineProps<{ @@ -107,23 +108,21 @@ const navigation: Array<{ ]; const tabChanged = (index: any) => { - const item = navigation[index]; // replace the entry: a new one per tab would trap browser Back on Settings. - // Inertia reads history.state.url on a Back that reloads, so it follows the hash - const url = `${location.pathname}${location.search}#${item.slug}`; + // Inertia saves router.page.url and history.state.url when you leave and on a + // Back that reloads, so both follow the hash + const url = `${location.pathname}${location.search}#${navigation[index].slug}`; + // shortcut: page is protected in Inertia 1.x's types, no public way to set its url + (router as unknown as { page: { url: string } }).page.url = url; history.replaceState({ ...history.state, url }, "", url); selectedTabIndex.value = index; }; +// don't write the address here: before Inertia saves its first page, that would add a 2nd entry const foundIndex = navigation.findIndex( (item) => item.slug === location.hash.replace("#", ""), ); - -if (foundIndex !== -1) { - tabChanged(foundIndex); -} else { - tabChanged(0); -} +selectedTabIndex.value = Math.max(foundIndex, 0); // the panels copy the form when they load const freshForms = ref(0);