From 9bbaf1c8b3f11565165b79e9356e2b19a2b57069 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Tue, 29 Apr 2025 11:21:08 -0400 Subject: [PATCH 01/25] wip --- app/layout.tsx | 6 ++- .../globals/ServiceWorker.tsx | 23 ++++++++++ public/service-worker.js | 45 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 components/clientComponents/globals/ServiceWorker.tsx create mode 100644 public/service-worker.js diff --git a/app/layout.tsx b/app/layout.tsx index 013dc49f58..631c9c177a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,6 +10,7 @@ import { Noto_Sans, Lato } from "next/font/google"; import { googleTagManager } from "@lib/cspScripts"; import { headers } from "next/headers"; import { auth } from "@lib/auth"; +import ServiceWorker from "@clientComponents/globals/ServiceWorker"; const notoSans = Noto_Sans({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], @@ -93,7 +94,10 @@ export default async function Layout({ children }: { children: React.ReactNode } - {children} + + + {children} + ); } diff --git a/components/clientComponents/globals/ServiceWorker.tsx b/components/clientComponents/globals/ServiceWorker.tsx new file mode 100644 index 0000000000..767c90237d --- /dev/null +++ b/components/clientComponents/globals/ServiceWorker.tsx @@ -0,0 +1,23 @@ +"use client"; +import { useEffect } from "react"; +import { logMessage } from "@lib/logger"; + +export default function ServiceWorker() { + useEffect(() => { + if ("serviceWorker" in navigator) { + navigator.serviceWorker + .register("/service-worker.js") + .then((registration) => { + logMessage.info(`Service worker registered: ${registration.scope}`); + registration.pushManager.subscribe({ + userVisibleOnly: true, + }); + }) + .catch((error) => { + logMessage.info("Service worker registration failed:", error); + }); + } + }, []); + + return null; +} diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000000..473d78c06f --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,45 @@ +/* eslint-disable no-console */ +const installEvent = () => { + self.addEventListener("install", () => { + console.log("service worker installed"); + }); +}; + +const activateEvent = () => { + self.addEventListener("activate", () => { + console.log("service worker activated"); + }); +}; +installEvent(); +activateEvent(); + +// const fetchEvent = () => { +// self.addEventListener("fetch", (event) => { +// event.respondWith( +// caches.match(event.request).then((response) => { +// if (response) { +// return response; +// } +// return fetch(event.request); +// }) +// ); +// }); +// }; + +self.addEventListener("push", (event) => { + const data = event.data.json(); + const title = data.title; + const body = data.message; + const icon = "some-icon.png"; + const notificationOptions = { + body: body, + tag: "simple-push-notification-example", + icon: icon, + }; + + return self.Notification.requestPermission().then((permission) => { + if (permission === "granted") { + return new self.Notification(title, notificationOptions); + } + }); +}); From 9c3d5aa3f2937f389c0c55a5ea5a4f66c219fb40 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Wed, 18 Feb 2026 14:52:39 -0500 Subject: [PATCH 02/25] base service worker --- app/layout.tsx | 2 +- .../globals/ServiceWorker.tsx | 20 ++++---- public/service-worker.js | 46 ++----------------- 3 files changed, 14 insertions(+), 54 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 198e367ec8..a1f2bf6ecc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -97,7 +97,7 @@ export default async function Layout({ children }: { children: React.ReactNode } - + {process.env.NODE_ENV === "production" && } {children} diff --git a/components/clientComponents/globals/ServiceWorker.tsx b/components/clientComponents/globals/ServiceWorker.tsx index 767c90237d..1995e31049 100644 --- a/components/clientComponents/globals/ServiceWorker.tsx +++ b/components/clientComponents/globals/ServiceWorker.tsx @@ -1,21 +1,17 @@ "use client"; import { useEffect } from "react"; -import { logMessage } from "@lib/logger"; + +export async function registerServiceWorker() { + return navigator.serviceWorker.register("/service-worker.js", { + scope: "/", + updateViaCache: "none", + }); +} export default function ServiceWorker() { useEffect(() => { if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register("/service-worker.js") - .then((registration) => { - logMessage.info(`Service worker registered: ${registration.scope}`); - registration.pushManager.subscribe({ - userVisibleOnly: true, - }); - }) - .catch((error) => { - logMessage.info("Service worker registration failed:", error); - }); + registerServiceWorker(); } }, []); diff --git a/public/service-worker.js b/public/service-worker.js index 473d78c06f..2058c31f6b 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,45 +1,9 @@ /* eslint-disable no-console */ -const installEvent = () => { - self.addEventListener("install", () => { - console.log("service worker installed"); - }); -}; -const activateEvent = () => { - self.addEventListener("activate", () => { - console.log("service worker activated"); - }); -}; -installEvent(); -activateEvent(); - -// const fetchEvent = () => { -// self.addEventListener("fetch", (event) => { -// event.respondWith( -// caches.match(event.request).then((response) => { -// if (response) { -// return response; -// } -// return fetch(event.request); -// }) -// ); -// }); -// }; - -self.addEventListener("push", (event) => { - const data = event.data.json(); - const title = data.title; - const body = data.message; - const icon = "some-icon.png"; - const notificationOptions = { - body: body, - tag: "simple-push-notification-example", - icon: icon, - }; +self.addEventListener("install", () => { + console.log("service worker installed"); +}); - return self.Notification.requestPermission().then((permission) => { - if (permission === "granted") { - return new self.Notification(title, notificationOptions); - } - }); +self.addEventListener("activate", async () => { + console.log("service worker activated"); }); From bee2bd96c6e89cdb6ba462b54bb466285f97fc21 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Fri, 20 Feb 2026 09:24:48 -0500 Subject: [PATCH 03/25] wip --- .../globals/Header/Header.tsx | 2 + .../clientComponents/globals/Update.tsx | 60 +++++++++++++++++++ lib/hooks/useGCFormContext.tsx | 43 +------------ public/service-worker.js | 48 +++++++++++++-- 4 files changed, 108 insertions(+), 45 deletions(-) create mode 100644 components/clientComponents/globals/Update.tsx diff --git a/components/clientComponents/globals/Header/Header.tsx b/components/clientComponents/globals/Header/Header.tsx index ea58f3d7cc..e40c2d5565 100644 --- a/components/clientComponents/globals/Header/Header.tsx +++ b/components/clientComponents/globals/Header/Header.tsx @@ -13,6 +13,7 @@ import Markdown from "markdown-to-jsx"; import { useFeatureFlags } from "@lib/hooks/useFeatureFlags"; import { FeatureFlags } from "@lib/cache/types"; import { SkipLink } from "../SkipLink"; + type HeaderParams = { context?: "admin" | "formBuilder" | "default"; className?: string; @@ -63,6 +64,7 @@ export const Header = ({ context = "default", className }: HeaderParams) => { )} +
{ + const [updateNeeded, setUpdateNeeded] = useState(true); + + const handleMessage = (event: MessageEvent) => { + logMessage.info(event.data); + if (event.data.type === "GCFORMS_UPDATE") { + setUpdateNeeded(true); + } + }; + + useEffect(() => { + navigator.serviceWorker.addEventListener("message", handleMessage); + return () => { + navigator.serviceWorker.removeEventListener("message", handleMessage); + }; + }, []); + + // short circuit when no update needed + if (!updateNeeded) return null; + + return ( + + ); +}; diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index e34b9f8d04..be8a0d7b89 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -264,47 +264,8 @@ export const GCFormsProvider = ({ export const useGCFormsContext = () => { const formsContext = useContext(GCFormsContext); if (formsContext === undefined) { - // For now just return a default context if we're not inside the provider - return { - updateValues: () => { - return "noop"; - }, - getValues: () => { - return; - }, - submissionId: undefined, - setSubmissionId: () => void 0, - submissionDate: undefined, - setSubmissionDate: () => void 0, - matchedIds: [""], - filteredMatchedIds: [""], - groups: {}, - currentGroup: "", - getPreviousGroup: () => "", - setGroup: () => void 0, - hasNextAction: () => void 0, - isOffBoardSection: () => false, - handleNextAction: () => void 0, - formRecord: {} as PublicFormRecord, - groupsCheck: () => false, - getGroupHistory: () => [], - pushIdToHistory: () => [], - clearHistoryAfterId: () => [], - getGroupTitle: () => "", - saveSessionProgress: () => void 0, - getProgressData: () => { - return { - id: "", - values: {}, - history: [], - currentGroup: "", - }; - }, - restoreSessionProgress: () => { - return {}; - }, - getNonce: () => "", - }; + throw new Error("useGCFormsContext but be used within the GCFormsProvider"); } + return formsContext; }; diff --git a/public/service-worker.js b/public/service-worker.js index 2058c31f6b..b0a6a3f620 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,9 +1,49 @@ +/// +// @ts-check + +/** @type {ServiceWorkerGlobalScope} */ +// @ts-expect-error - self is ServiceWorkerGlobalScope in worker context +const sw = self; + /* eslint-disable no-console */ -self.addEventListener("install", () => { - console.log("service worker installed"); +sw.addEventListener("install", () => { + console.info("service worker installed"); + sw.skipWaiting(); }); -self.addEventListener("activate", async () => { - console.log("service worker activated"); +sw.addEventListener("activate", async () => { + console.info("service worker activated"); }); + +sw.addEventListener("fetch", (event) => { + const requestMethod = event.request.method; + const nextAction = Boolean(event.request.headers.get("next-action")); + if (requestMethod === "POST" && nextAction) { + event.respondWith( + new Promise((resolve) => { + fetch(event.request).then((response) => { + if ( + response.status === 404 && + Boolean(response.headers.get("x-nextjs-action-not-found")) + ) { + console.info("Asking clients to update"); + broadcastMessageToClients({ + type: "GCFORMS_UPDATE", + message: "Update is required to use server actions", + }); + } + resolve(response); + }); + }) + ); + } +}); + +function broadcastMessageToClients(messageData) { + sw.clients.matchAll({ includeUncontrolled: true, type: "window" }).then((clients) => { + clients.forEach((client) => { + client.postMessage(messageData); + }); + }); +} From 3aff6773a849e34857da54012f7a4477d25fa4f4 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 23 Feb 2026 07:14:16 -0500 Subject: [PATCH 04/25] wip --- .../clientComponents/globals/Update.tsx | 35 ++++------------ lib/hooks/useGCFormContext.tsx | 42 ++++++++++++------- lib/hooks/useUpdateRequired.tsx | 27 ++++++++++++ lib/store/storage.ts | 1 + 4 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 lib/hooks/useUpdateRequired.tsx diff --git a/components/clientComponents/globals/Update.tsx b/components/clientComponents/globals/Update.tsx index 434151ea27..c705935567 100644 --- a/components/clientComponents/globals/Update.tsx +++ b/components/clientComponents/globals/Update.tsx @@ -1,27 +1,17 @@ -import { useState, useEffect } from "react"; -import { logMessage } from "@lib/logger"; +"use client"; + import Markdown from "markdown-to-jsx"; import { Button } from "./Buttons"; +import { useUpdateRequired } from "@root/lib/hooks/useUpdateRequired"; +import { logMessage } from "@root/lib/logger"; export const Update = () => { - const [updateNeeded, setUpdateNeeded] = useState(true); - - const handleMessage = (event: MessageEvent) => { - logMessage.info(event.data); - if (event.data.type === "GCFORMS_UPDATE") { - setUpdateNeeded(true); - } - }; - - useEffect(() => { - navigator.serviceWorker.addEventListener("message", handleMessage); - return () => { - navigator.serviceWorker.removeEventListener("message", handleMessage); - }; - }, []); + const updateRequired = useUpdateRequired(() => + logMessage.info("This should hopefully now show the update page modal") + ); // short circuit when no update needed - if (!updateNeeded) return null; + if (!updateRequired) return null; return (
{ window.location.reload(); }} type="submit" + theme="primary" > {"Update"} -
diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index be8a0d7b89..ea390fcea6 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { createContext, useContext, ReactNode } from "react"; +import React, { createContext, useContext, ReactNode, useCallback } from "react"; import { type FormValues, type GroupsType, type PublicFormRecord } from "@gcforms/types"; import { type Language } from "@lib/types/form-builder-types"; @@ -29,6 +29,9 @@ import { toggleSavedValues } from "@i18n/toggleSavedValues"; import { type FileInputResponse } from "@lib/types"; import { LOCKED_GROUPS } from "@formBuilder/components/shared/right-panel/headless-treeview/constants"; +import { useUpdateRequired } from "./useUpdateRequired"; +import { useTranslation } from "@i18n/client"; +import { logMessage } from "../logger"; interface GCFormsContextValueType { updateValues: ({ formValues }: { formValues: FormValues }) => void; @@ -84,6 +87,9 @@ export const GCFormsProvider = ({ const [currentGroup, setCurrentGroup] = React.useState(initialGroup); const [submissionId, setSubmissionId] = React.useState(undefined); const [submissionDate, setSubmissionDate] = React.useState(undefined); + const { + i18n: { language }, + } = useTranslation(); // eslint-disable-next-line react-hooks/refs const filteredResponses = filterValuesByVisibleElements(formRecord, values.current); @@ -189,19 +195,22 @@ export const GCFormsProvider = ({ }; }; - const saveSessionProgress = (language: Language = "en") => { - const vals = - language === "en" - ? values.current - : (toggleSavedValues(formRecord.form, { values: values.current }, "en") as FormValues); - - saveToSession(language, { - id: formRecord.id, - values: vals, - history: history.current, - currentGroup: currentGroup || "", - }); - }; + const saveSessionProgress = useCallback( + (language: Language = "en") => { + const vals = + language === "en" + ? values.current + : (toggleSavedValues(formRecord.form, { values: values.current }, "en") as FormValues); + + saveToSession(language, { + id: formRecord.id, + values: vals, + history: history.current, + currentGroup: currentGroup || "", + }); + }, + [formRecord, currentGroup] + ); const restoreSessionProgress = (language: Language) => { return restoreSession({ id: formRecord.id, form: formRecord.form, language }); @@ -226,6 +235,11 @@ export const GCFormsProvider = ({ return visibleGroups[idx - 1]; }; + useUpdateRequired(() => { + logMessage.info(`Saving progress to session storage for form responses with lang: ${language}`); + saveSessionProgress(language as Language); + }); + return ( void) => { + const [updateRequired, setUpdateRequired] = useState(false); + const handleMessage = (event: MessageEvent) => { + logMessage.info("Message to Update Recieved"); + if (event.data.type === "GCFORMS_UPDATE") { + setUpdateRequired(true); + } + }; + useEffect(() => { + if (updateRequired) { + save(); + } + }, [updateRequired, save]); + + useEffect(() => { + navigator.serviceWorker.addEventListener("message", handleMessage); + + return () => { + navigator.serviceWorker.removeEventListener("message", handleMessage); + }; + }, []); + return updateRequired; +}; diff --git a/lib/store/storage.ts b/lib/store/storage.ts index 3f3cd35b49..85be2ee156 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -1,3 +1,4 @@ +"use client"; import { logMessage } from "@lib/logger"; import { TemplateStoreState } from "./types"; import { StateStorage } from "zustand/middleware"; From 4d738dbed2ad2dc35a85105f2566e45e45ef3b43 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 23 Feb 2026 07:15:14 -0500 Subject: [PATCH 05/25] wip --- app/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/layout.tsx b/app/layout.tsx index a1f2bf6ecc..bc4a418651 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,6 +11,7 @@ import { googleTagManager } from "@lib/cspScripts"; import { headers } from "next/headers"; import { auth } from "@lib/auth"; import ServiceWorker from "@clientComponents/globals/ServiceWorker"; +import { Update } from "@clientComponents/globals/Update"; export const dynamic = "force-dynamic"; @@ -98,6 +99,7 @@ export default async function Layout({ children }: { children: React.ReactNode } {process.env.NODE_ENV === "production" && } + {children} From 407d1bceafd1252032d7f4aa9f4150c11413ae25 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 23 Mar 2026 08:34:33 -0400 Subject: [PATCH 06/25] wip --- public/service-worker.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/public/service-worker.js b/public/service-worker.js index b0a6a3f620..0f41e39e9b 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -5,6 +5,8 @@ // @ts-expect-error - self is ServiceWorkerGlobalScope in worker context const sw = self; +let triggerUpdate = false; + /* eslint-disable no-console */ sw.addEventListener("install", () => { @@ -23,6 +25,28 @@ sw.addEventListener("fetch", (event) => { event.respondWith( new Promise((resolve) => { fetch(event.request).then((response) => { + // Here for testing purposes only, remove below before merging + if (triggerUpdate) { + const testingHeaders = new Headers(response.headers); + testingHeaders.set("x-nextjs-action-not-found", "1"); + testingHeaders.set("cache-control", "no-cache, no-store, max-age=0, must-revalidate"); + + const modifiedResponse = new Response("Server action not found.", { + status: 404, + statusText: "Not Found", + headers: testingHeaders, + }); + + console.info("Asking clients to update"); + broadcastMessageToClients({ + type: "GCFORMS_UPDATE", + message: "Update is required to use server actions", + }); + + return resolve(modifiedResponse); + } + // Here for testing purposes only, remove above before merging + if ( response.status === 404 && Boolean(response.headers.get("x-nextjs-action-not-found")) From 90243ab89bd58be45d4846581f6d6e81219e936b Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 23 Mar 2026 14:54:35 -0400 Subject: [PATCH 07/25] continue testing --- public/service-worker.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/public/service-worker.js b/public/service-worker.js index 0f41e39e9b..4aef15f0d7 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -6,6 +6,7 @@ const sw = self; let triggerUpdate = false; +let triggerRef = null; /* eslint-disable no-console */ @@ -21,6 +22,7 @@ sw.addEventListener("activate", async () => { sw.addEventListener("fetch", (event) => { const requestMethod = event.request.method; const nextAction = Boolean(event.request.headers.get("next-action")); + fakeUpdateRequirement(); if (requestMethod === "POST" && nextAction) { event.respondWith( new Promise((resolve) => { @@ -42,6 +44,7 @@ sw.addEventListener("fetch", (event) => { type: "GCFORMS_UPDATE", message: "Update is required to use server actions", }); + triggerUpdate = false; return resolve(modifiedResponse); } @@ -71,3 +74,16 @@ function broadcastMessageToClients(messageData) { }); }); } + +// Here for testing purposes only, remove below before merging +function fakeUpdateRequirement() { + // Set that a update is required every min. + if (!triggerUpdate && !triggerRef) { + console.info("Setting Timer to request site update"); + triggerRef = setTimeout(() => { + triggerUpdate = true; + console.info("Update ready to be triggered"); + }, 60000); + } +} +// Here for testing purposes only, remove above before merging From 779c8075265e0151ae83ebf37b48514eddf5b128 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 30 Mar 2026 14:23:14 -0400 Subject: [PATCH 08/25] wip --- app/layout.tsx | 3 + .../globals/ClientContexts.tsx | 5 +- .../clientComponents/globals/Update.tsx | 7 +- lib/hooks/useAppUpdate.tsx | 86 +++++++++++++++++++ lib/hooks/useGCFormContext.tsx | 19 ++-- lib/hooks/useUpdateRequired.tsx | 27 ------ public/service-worker.js | 1 + 7 files changed, 107 insertions(+), 41 deletions(-) create mode 100644 lib/hooks/useAppUpdate.tsx delete mode 100644 lib/hooks/useUpdateRequired.tsx diff --git a/app/layout.tsx b/app/layout.tsx index bc4a418651..ad9e84ed53 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -99,6 +99,9 @@ export default async function Layout({ children }: { children: React.ReactNode } {process.env.NODE_ENV === "production" && } + {/* For Dev testing only */} + + {/* For Dev testing only */} {children} diff --git a/components/clientComponents/globals/ClientContexts.tsx b/components/clientComponents/globals/ClientContexts.tsx index 3bb1289ba4..8d8c2173a7 100644 --- a/components/clientComponents/globals/ClientContexts.tsx +++ b/components/clientComponents/globals/ClientContexts.tsx @@ -7,6 +7,7 @@ import { RefsProvider } from "@formBuilder/[id]/edit/components/RefsContext"; import { FeatureFlagsProvider } from "@lib/hooks/useFeatureFlags"; import { Flags } from "@lib/cache/types"; import { Announce } from "@gcforms/announce"; +import { AppUpdateProvider } from "@lib/hooks/useAppUpdate"; export const ClientContexts: React.FC<{ session: Session | null; @@ -14,7 +15,7 @@ export const ClientContexts: React.FC<{ featureFlags: Flags; }> = ({ session, children, featureFlags }) => { return ( - <> + - + ); }; diff --git a/components/clientComponents/globals/Update.tsx b/components/clientComponents/globals/Update.tsx index c705935567..9ba3801ff1 100644 --- a/components/clientComponents/globals/Update.tsx +++ b/components/clientComponents/globals/Update.tsx @@ -2,13 +2,10 @@ import Markdown from "markdown-to-jsx"; import { Button } from "./Buttons"; -import { useUpdateRequired } from "@root/lib/hooks/useUpdateRequired"; -import { logMessage } from "@root/lib/logger"; +import { useAppUpdate } from "@lib/hooks/useAppUpdate"; export const Update = () => { - const updateRequired = useUpdateRequired(() => - logMessage.info("This should hopefully now show the update page modal") - ); + const { updateRequired } = useAppUpdate(); // short circuit when no update needed if (!updateRequired) return null; diff --git a/lib/hooks/useAppUpdate.tsx b/lib/hooks/useAppUpdate.tsx new file mode 100644 index 0000000000..f10258233c --- /dev/null +++ b/lib/hooks/useAppUpdate.tsx @@ -0,0 +1,86 @@ +"use client"; +import { useState, useEffect, createContext, useContext } from "react"; +import { logMessage } from "../logger"; +import Markdown from "markdown-to-jsx"; +import { Button } from "@clientComponents/globals/Buttons"; + +const AppUpdateContext = createContext({ + updateTriggered: false, + updateRequired: false, +}); + +export const AppUpdateProvider = ({ children }: { children: React.ReactNode }) => { + const [updateRequired, setUpdateRequired] = useState(false); + const [updateTriggered, setUpdateTriggered] = useState(false); + + const handleMessage = (event: MessageEvent) => { + logMessage.info("Message to Update Recieved"); + if (event.data.type === "GCFORMS_UPDATE") { + sessionStorage.setItem("gcFormsUpdate", "inProgress"); + setUpdateRequired(true); + } + }; + // Register listeners on component load + useEffect(() => { + navigator.serviceWorker.addEventListener("message", handleMessage); + + return () => { + navigator.serviceWorker.removeEventListener("message", handleMessage); + }; + }, []); + + // Only run on initial page load + useEffect(() => { + const localUpdate = Boolean(sessionStorage?.getItem("gcFormsUpdate")); + if (localUpdate) { + sessionStorage.removeItem("gcFormsUpdate"); + // eslint-disable-next-line react-hooks/set-state-in-effect + setUpdateTriggered(true); + } + }, []); + + return ( + + {updateRequired && } + {children} + + ); +}; + +export const useAppUpdate = () => { + const context = useContext(AppUpdateContext); + if (context === undefined) { + throw new Error("useAppUpdate must be used within the AppUpdateProvider"); + } + return context; +}; + +export const Update = () => { + return ( + + ); +}; diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index ea390fcea6..b0070fd8c4 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { createContext, useContext, ReactNode, useCallback } from "react"; +import React, { createContext, useContext, ReactNode, useCallback, useEffect } from "react"; import { type FormValues, type GroupsType, type PublicFormRecord } from "@gcforms/types"; import { type Language } from "@lib/types/form-builder-types"; @@ -29,7 +29,7 @@ import { toggleSavedValues } from "@i18n/toggleSavedValues"; import { type FileInputResponse } from "@lib/types"; import { LOCKED_GROUPS } from "@formBuilder/components/shared/right-panel/headless-treeview/constants"; -import { useUpdateRequired } from "./useUpdateRequired"; +import { useAppUpdate } from "@lib/hooks/useAppUpdate"; import { useTranslation } from "@i18n/client"; import { logMessage } from "../logger"; @@ -234,11 +234,16 @@ export const GCFormsProvider = ({ } return visibleGroups[idx - 1]; }; - - useUpdateRequired(() => { - logMessage.info(`Saving progress to session storage for form responses with lang: ${language}`); - saveSessionProgress(language as Language); - }); + const { updateRequired } = useAppUpdate(); + + useEffect(() => { + if (updateRequired) { + logMessage.info( + `Saving progress to session storage for form responses with lang: ${language}` + ); + saveSessionProgress(language as Language); + } + }, [updateRequired, language, saveSessionProgress]); return ( void) => { - const [updateRequired, setUpdateRequired] = useState(false); - const handleMessage = (event: MessageEvent) => { - logMessage.info("Message to Update Recieved"); - if (event.data.type === "GCFORMS_UPDATE") { - setUpdateRequired(true); - } - }; - useEffect(() => { - if (updateRequired) { - save(); - } - }, [updateRequired, save]); - - useEffect(() => { - navigator.serviceWorker.addEventListener("message", handleMessage); - - return () => { - navigator.serviceWorker.removeEventListener("message", handleMessage); - }; - }, []); - return updateRequired; -}; diff --git a/public/service-worker.js b/public/service-worker.js index 4aef15f0d7..bb4b3f0939 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -17,6 +17,7 @@ sw.addEventListener("install", () => { sw.addEventListener("activate", async () => { console.info("service worker activated"); + sw.clients.claim(); }); sw.addEventListener("fetch", (event) => { From ce405e484bfc427be7e9b504685288d9a7271f2d Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Wed, 20 May 2026 08:53:44 -0400 Subject: [PATCH 09/25] ignore otel folder with config files for testing --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cc58d29d53..463beedcef 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,5 @@ packages/database/src/generated tests/.auth/*.json eslint_report.html + +/otel \ No newline at end of file From 6e2e84a706eeb0da1315c7c9d9e1d4ef0d17808b Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Fri, 22 May 2026 14:37:06 -0400 Subject: [PATCH 10/25] skip hcaptcha in local dev --- .../clientComponents/globals/FormCaptcha/FormCaptcha.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/clientComponents/globals/FormCaptcha/FormCaptcha.tsx b/components/clientComponents/globals/FormCaptcha/FormCaptcha.tsx index ffd49f23c0..876d563d26 100644 --- a/components/clientComponents/globals/FormCaptcha/FormCaptcha.tsx +++ b/components/clientComponents/globals/FormCaptcha/FormCaptcha.tsx @@ -47,7 +47,10 @@ export const FormCaptcha = ({ }; // Skip the hCaptcha flow for test and Draft forms where we don't need an hCaptcha verification - const doHCaptchaFlow = process.env.NEXT_PUBLIC_APP_ENV !== "test" && isPublished; + const doHCaptchaFlow = + process.env.NEXT_PUBLIC_APP_ENV !== "test" && + isPublished && + process.env.NODE_ENV !== "development"; // see https://github.com/hCaptcha/react-hcaptcha return ( From 2b48355d41b4cb9a05748aa8078f5af1029c09ce Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Fri, 22 May 2026 14:38:45 -0400 Subject: [PATCH 11/25] edit page now restores state properly --- lib/hooks/form-builder/useTemplateContext.tsx | 28 +++------- lib/hooks/useAppUpdate.tsx | 17 +++--- lib/store/hooks/useRehydrate.tsx | 8 +-- lib/store/storage.ts | 16 ++++-- lib/store/useTemplateStore.tsx | 29 +++++++--- public/service-worker.js | 56 +++++++++++-------- 6 files changed, 83 insertions(+), 71 deletions(-) diff --git a/lib/hooks/form-builder/useTemplateContext.tsx b/lib/hooks/form-builder/useTemplateContext.tsx index e03e861f00..7f8de2ca0a 100644 --- a/lib/hooks/form-builder/useTemplateContext.tsx +++ b/lib/hooks/form-builder/useTemplateContext.tsx @@ -4,10 +4,11 @@ import isEqual from "lodash.isequal"; import { useSession } from "next-auth/react"; import { logMessage } from "@lib/logger"; import { safeJSONParse } from "@lib/utils"; -import { CreateOrUpdateTemplateType, createOrUpdateTemplate } from "@formBuilder/actions"; -import { FormProperties, FormRecord } from "@lib/types"; +import { createOrUpdateTemplate } from "@formBuilder/actions"; +import { FormProperties } from "@lib/types"; import { useTemplateStore } from "@lib/store/useTemplateStore"; import { useSubscibeToTemplateStore } from "@lib/store/hooks/useSubscibeToTemplateStore"; +import { useAppUpdate } from "../useAppUpdate"; export type SaveDraftStatus = "saved" | "skipped" | "invalid" | "locked" | "error"; @@ -17,23 +18,10 @@ type SaveDraftResult = { }; interface TemplateApiType { - templateIsDirty: React.MutableRefObject; + templateIsDirty: React.RefObject; updatedAt: number | undefined; setUpdatedAt: React.Dispatch>; - createOrUpdateTemplate: - | (({ - id, - formConfig, - name, - deliveryOption, - securityAttribute, - saveAndResume, - notificationsInterval, - }: CreateOrUpdateTemplateType) => Promise<{ - formRecord: FormRecord | null; - error?: string; - }>) - | null; + saveDraft: () => Promise; saveDraftIfNeeded: () => Promise; resetState: () => void; @@ -43,7 +31,6 @@ const defaultTemplateApi: TemplateApiType = { templateIsDirty: { current: false }, updatedAt: undefined, setUpdatedAt: () => {}, - createOrUpdateTemplate: null, saveDraft: async () => ({ status: "skipped" }), saveDraftIfNeeded: async () => ({ status: "skipped" }), resetState: () => {}, @@ -63,6 +50,7 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } const [updatedAt, setUpdatedAt] = useState(); const [, setDirtyTick] = useState(0); const { status } = useSession(); + const { updateTriggered } = useAppUpdate(); const { getDeliveryOption, @@ -86,11 +74,12 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } setFromRecord: s.setFromRecord, })); - const templateIsDirty = useRef(false); + const templateIsDirty = useRef(updateTriggered); const savedSnapshot = useRef(null); const saveInFlight = useRef(false); const queuedSaveRequested = useRef(false); const saveDraftQueue = useRef void>>([]); + const resetState = useCallback(() => { templateIsDirty.current = false; savedSnapshot.current = null; @@ -241,7 +230,6 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } templateIsDirty, updatedAt, setUpdatedAt, - createOrUpdateTemplate, saveDraft: queueSaveDraft, saveDraftIfNeeded, resetState, diff --git a/lib/hooks/useAppUpdate.tsx b/lib/hooks/useAppUpdate.tsx index f10258233c..71e5e116b4 100644 --- a/lib/hooks/useAppUpdate.tsx +++ b/lib/hooks/useAppUpdate.tsx @@ -11,7 +11,12 @@ const AppUpdateContext = createContext({ export const AppUpdateProvider = ({ children }: { children: React.ReactNode }) => { const [updateRequired, setUpdateRequired] = useState(false); - const [updateTriggered, setUpdateTriggered] = useState(false); + const updateTriggered = + typeof window !== "undefined" ? Boolean(sessionStorage?.getItem("gcFormsUpdate")) : false; + + if (updateTriggered) { + logMessage.debug("useAppUpdate flagging update was triggered"); + } const handleMessage = (event: MessageEvent) => { logMessage.info("Message to Update Recieved"); @@ -29,14 +34,10 @@ export const AppUpdateProvider = ({ children }: { children: React.ReactNode }) = }; }, []); - // Only run on initial page load + // After an update clean up and remove the update flag from session storage useEffect(() => { - const localUpdate = Boolean(sessionStorage?.getItem("gcFormsUpdate")); - if (localUpdate) { - sessionStorage.removeItem("gcFormsUpdate"); - // eslint-disable-next-line react-hooks/set-state-in-effect - setUpdateTriggered(true); - } + logMessage.debug("Removing session storage update flag"); + sessionStorage.removeItem("gcFormsUpdate"); }, []); return ( diff --git a/lib/store/hooks/useRehydrate.tsx b/lib/store/hooks/useRehydrate.tsx index 91925974ba..719dfb08e6 100644 --- a/lib/store/hooks/useRehydrate.tsx +++ b/lib/store/hooks/useRehydrate.tsx @@ -1,4 +1,4 @@ -import { useEffect, use } from "react"; +import { use } from "react"; import { useTemplateStore, TemplateStoreContext } from "../useTemplateStore"; export const useRehydrate = () => { @@ -7,11 +7,5 @@ export const useRehydrate = () => { if (!store) throw new Error("Missing Template Store Provider in tree"); - useEffect(() => { - if (!hasHydrated) { - store.persist.rehydrate(); - } - }, [store, hasHydrated]); - return hasHydrated; }; diff --git a/lib/store/storage.ts b/lib/store/storage.ts index 012df884ef..4ada97f4ec 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -4,21 +4,18 @@ import { TemplateStoreState } from "./types"; import { StateStorage } from "zustand/middleware"; import { createJSONStorage } from "zustand/middleware"; -/* Note: "async" getItem is intentional here to work-around a hydration issue */ -/* https://github.com/pmndrs/zustand/issues/324#issuecomment-1031392610 */ - const storage: StateStorage = { - getItem: async (name: string) => { + getItem: (name: string) => { return sessionStorage.getItem(name) || null; }, - setItem: async (name: string, value: string) => { + setItem: (name: string, value: string) => { try { sessionStorage.setItem(name, value); } catch (error) { logMessage.info(`Error setting item in session storage: ${JSON.stringify(error)}`); } }, - removeItem: async (name: string) => { + removeItem: (name: string) => { sessionStorage.removeItem(name); }, }; @@ -38,4 +35,11 @@ export const storageOptions = { state?.setHasHydrated(); }; }, + + merge: (persisted: unknown, current: TemplateStoreState) => { + logMessage.debug("Merging state action"); + const persistedState = persisted as TemplateStoreState; + + return { ...current, ...persistedState }; + }, }; diff --git a/lib/store/useTemplateStore.tsx b/lib/store/useTemplateStore.tsx index 2b826f9784..5bf1af12ac 100644 --- a/lib/store/useTemplateStore.tsx +++ b/lib/store/useTemplateStore.tsx @@ -59,6 +59,7 @@ import { useFeatureFlags } from "../hooks/useFeatureFlags"; import { ErrorPanel } from "@clientComponents/globals/ErrorPanel"; import { useTranslation } from "@root/i18n/client"; import { getImportedTemplate } from "./importBuffer"; +import { useRehydrate } from "./hooks/useRehydrate"; const createTemplateStore = ( checkFeatureFlag: (flag: string) => boolean, @@ -188,14 +189,10 @@ const createTemplateStore = ( generateElementId: generateElementId(set, get), transform: transform(set, get), getSchema: () => { - // hasHydrated should work here but we get an error. leaving this timeout for now. - setTimeout(() => { - if (!get().hasTransformed) { - get().transform(); - } + if (get().hasHydrated && !get().hasTransformed) { + get().transform(); set({ hasTransformed: true }); - }, 500); - + } return JSON.stringify(getSchemaFromState(get(), get().allowGroupsFlag), null, 2); }, getId: () => get().id, @@ -275,11 +272,19 @@ export const TemplateStoreProvider = ({ } }, [store, props.isPublished, props.closingDate]); + useEffect(() => { + if (!store.getState().hasHydrated) { + store.persist.rehydrate(); + } + }, [store]); + try { return ( - {children} + + {children} + ); @@ -300,3 +305,11 @@ export const useTemplateStore = ( if (!store) throw new Error("Missing Template Store Provider in tree"); return useStoreWithEqualityFn(store, selector, equalityFn ?? shallow); }; + +const OnlyRenderOnceHydrated = ({ children }: React.PropsWithChildren) => { + const hasHydrated = useRehydrate(); + if (hasHydrated) { + return <>{children}; + } + return null; +}; diff --git a/public/service-worker.js b/public/service-worker.js index bb4b3f0939..657399a025 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -6,10 +6,17 @@ const sw = self; let triggerUpdate = false; + +/** @type {ReturnType | null} */ let triggerRef = null; -/* eslint-disable no-console */ +/** + * @typedef {Object} GCFormsMessage + * @property {string} type + * @property {string} message + */ +/* eslint-disable no-console */ sw.addEventListener("install", () => { console.info("service worker installed"); sw.skipWaiting(); @@ -27,30 +34,31 @@ sw.addEventListener("fetch", (event) => { if (requestMethod === "POST" && nextAction) { event.respondWith( new Promise((resolve) => { - fetch(event.request).then((response) => { - // Here for testing purposes only, remove below before merging - if (triggerUpdate) { - const testingHeaders = new Headers(response.headers); - testingHeaders.set("x-nextjs-action-not-found", "1"); - testingHeaders.set("cache-control", "no-cache, no-store, max-age=0, must-revalidate"); + // Here for testing purposes only, remove below before merging + if (triggerUpdate) { + const testingHeaders = new Headers(); + testingHeaders.set("x-nextjs-action-not-found", "1"); + testingHeaders.set("cache-control", "no-cache, no-store, max-age=0, must-revalidate"); - const modifiedResponse = new Response("Server action not found.", { - status: 404, - statusText: "Not Found", - headers: testingHeaders, - }); + const modifiedResponse = new Response("Server action not found.", { + status: 404, + statusText: "Not Found", + headers: testingHeaders, + }); - console.info("Asking clients to update"); - broadcastMessageToClients({ - type: "GCFORMS_UPDATE", - message: "Update is required to use server actions", - }); - triggerUpdate = false; + console.info("Asking clients to update"); + broadcastMessageToClients({ + type: "GCFORMS_UPDATE", + message: "Update is required to use server actions", + }); + triggerUpdate = false; - return resolve(modifiedResponse); - } - // Here for testing purposes only, remove above before merging + return resolve(modifiedResponse); + } + + // Here for testing purposes only, remove above before merging + fetch(event.request).then((response) => { if ( response.status === 404 && Boolean(response.headers.get("x-nextjs-action-not-found")) @@ -68,6 +76,9 @@ sw.addEventListener("fetch", (event) => { } }); +/** + * @param {GCFormsMessage} messageData + */ function broadcastMessageToClients(messageData) { sw.clients.matchAll({ includeUncontrolled: true, type: "window" }).then((clients) => { clients.forEach((client) => { @@ -83,8 +94,9 @@ function fakeUpdateRequirement() { console.info("Setting Timer to request site update"); triggerRef = setTimeout(() => { triggerUpdate = true; + triggerRef = null; console.info("Update ready to be triggered"); - }, 60000); + }, 30000); } } // Here for testing purposes only, remove above before merging From 07f0d7d2057c744f922d20f60d4c3aced5daec27 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Fri, 22 May 2026 14:59:39 -0400 Subject: [PATCH 12/25] update state after server save --- .../[id]/settings/components/FormProfile.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/FormProfile.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/FormProfile.tsx index 62daba18fa..49121e410a 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/FormProfile.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/components/FormProfile.tsx @@ -57,8 +57,6 @@ export const FormProfile = ({ hasBrandingRequestForm }: { hasBrandingRequestForm // Update local state setClassification(classification); - updateSecurityAttribute(classification); - const resultAttribute = (await updateTemplateSecurityAttribute({ id, securityAttribute: classification, @@ -68,6 +66,8 @@ export const FormProfile = ({ hasBrandingRequestForm }: { hasBrandingRequestForm toast.error(, "wide"); return; } + // Set local store state after a sucessfull server side save + updateSecurityAttribute(classification); toast.success(savedSuccessMessage); }, @@ -83,9 +83,6 @@ export const FormProfile = ({ hasBrandingRequestForm }: { hasBrandingRequestForm const { value } = event.target; const purposeOption = value as PurposeOption; - // Update local state - setPurposeOption(purposeOption); - // Update the template store updateField("formPurpose", purposeOption); @@ -99,6 +96,8 @@ export const FormProfile = ({ hasBrandingRequestForm }: { hasBrandingRequestForm toast.error(, "wide"); return; } + // Update local state after server side is sucessful + setPurposeOption(purposeOption); toast.success(savedSuccessMessage); }, @@ -113,7 +112,7 @@ export const FormProfile = ({ hasBrandingRequestForm }: { hasBrandingRequestForm * Classification section *--------------------------------------------*/} Date: Mon, 25 May 2026 08:40:04 -0400 Subject: [PATCH 13/25] service worker on built versions only --- app/layout.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 0d50a7bc17..0ddc18975f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -99,9 +99,6 @@ export default async function Layout({ children }: { children: React.ReactNode } {process.env.NODE_ENV === "production" && } - {/* For Dev testing only */} - - {/* For Dev testing only */} {children} From 7550d52b1f46ce249471c5f5a5b25a04c6c87efe Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 25 May 2026 08:50:40 -0400 Subject: [PATCH 14/25] fix package version bump --- packages/core/CHANGELOG.md | 4 ++++ packages/core/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index feff89553b..8e5a492bb3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [2.2.11] - 2026-05-25 + +- Fixes self referring package import + ## [2.2.10] - 2026-05-15 - Adds toast background override to /src/styles/_toast.scss diff --git a/packages/core/package.json b/packages/core/package.json index 1e62506057..f171d92b3d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/core", - "version": "2.2.10", + "version": "2.2.11", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { From 48a0fd09faa9fe5de2be4784db61b23561f949bf Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 25 May 2026 09:49:50 -0400 Subject: [PATCH 15/25] some dependencies for tests to pass until refactored --- lib/hooks/useGCFormContext.tsx | 43 ++++++++++++++++++++++++++++++++-- lib/store/storage.ts | 6 +++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index 64b7d2872e..da68e2228a 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -299,8 +299,47 @@ export const GCFormsProvider = ({ export const useGCFormsContext = () => { const formsContext = useContext(GCFormsContext); if (formsContext === undefined) { - throw new Error("useGCFormsContext but be used within the GCFormsProvider"); + // For now just return a default context if we're not inside the provider + return { + updateValues: () => { + return "noop"; + }, + getValues: () => { + return; + }, + submissionId: undefined, + setSubmissionId: () => void 0, + submissionDate: undefined, + setSubmissionDate: () => void 0, + matchedIds: [""], + filteredMatchedIds: [""], + groups: {}, + currentGroup: "", + getPreviousGroup: () => "", + setGroup: () => void 0, + hasNextAction: () => void 0, + isOffBoardSection: () => false, + handleNextAction: () => void 0, + formRecord: {} as PublicFormRecord, + groupsCheck: () => false, + getGroupHistory: () => [], + pushIdToHistory: () => [], + clearHistoryAfterId: () => [], + getGroupTitle: () => "", + saveSessionProgress: () => void 0, + getProgressData: () => { + return { + id: "", + values: {}, + history: [], + currentGroup: "", + }; + }, + restoreSessionProgress: () => { + return {}; + }, + getNonce: () => "", + }; } - return formsContext; }; diff --git a/lib/store/storage.ts b/lib/store/storage.ts index 4ada97f4ec..b6d960d3f5 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -40,6 +40,12 @@ export const storageOptions = { logMessage.debug("Merging state action"); const persistedState = persisted as TemplateStoreState; + // During vitest runs do not merge state, return the state + // that was mocked and passed in + if (process.env.NODE_ENV === "test") { + return current; + } + return { ...current, ...persistedState }; }, }; From 51b82a00fc597ba374b7b61d529a1a32eba20060 Mon Sep 17 00:00:00 2001 From: Bryan Robitaille Date: Mon, 25 May 2026 10:40:42 -0400 Subject: [PATCH 16/25] fix filter for checking for migrations --- .github/workflows/pr-review-client-deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-review-client-deploy.yml b/.github/workflows/pr-review-client-deploy.yml index e8b1c9b833..d7d89d4459 100644 --- a/.github/workflows/pr-review-client-deploy.yml +++ b/.github/workflows/pr-review-client-deploy.yml @@ -43,7 +43,7 @@ jobs: with: filters: | migrations: - - 'prisma/migrations/**' + - 'packages/database/prisma/migrations/**' build-and-push-container: needs: [run-check] From 28cb9e8cd5511bd5f2394a47d102ff1b255648ab Mon Sep 17 00:00:00 2001 From: Tim Arney Date: Thu, 4 Jun 2026 08:47:32 -0400 Subject: [PATCH 17/25] add strings --- app/layout.tsx | 4 +- .../clientComponents/globals/Update.tsx | 37 ++++++++++++++++--- i18n/translations/en/common.json | 12 ++++++ i18n/translations/fr/common.json | 12 ++++++ lib/hooks/useAppUpdate.tsx | 36 ++---------------- 5 files changed, 60 insertions(+), 41 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 0ddc18975f..60e4483c47 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -11,7 +11,7 @@ import { googleTagManager } from "@lib/cspScripts"; import { headers } from "next/headers"; import { auth } from "@lib/auth"; import ServiceWorker from "@clientComponents/globals/ServiceWorker"; -import { Update } from "@clientComponents/globals/Update"; +import { AppUpdater } from "@clientComponents/globals/Update"; export const dynamic = "force-dynamic"; @@ -99,7 +99,7 @@ export default async function Layout({ children }: { children: React.ReactNode } {process.env.NODE_ENV === "production" && } - + {children} diff --git a/components/clientComponents/globals/Update.tsx b/components/clientComponents/globals/Update.tsx index 9ba3801ff1..8be87d1f17 100644 --- a/components/clientComponents/globals/Update.tsx +++ b/components/clientComponents/globals/Update.tsx @@ -3,13 +3,40 @@ import Markdown from "markdown-to-jsx"; import { Button } from "./Buttons"; import { useAppUpdate } from "@lib/hooks/useAppUpdate"; +import { useTranslation } from "@i18n/client"; +import { useEffect, useState } from "react"; -export const Update = () => { +export const AppUpdater = () => { const { updateRequired } = useAppUpdate(); // short circuit when no update needed if (!updateRequired) return null; + return ; +}; + +const regex = /^\/?(en|fr)\/id\/[a-z0-9]+$/; + +export const UpdateModal = () => { + const { t } = useTranslation("common"); + + const [isPublicFacing, setIsPublicFacing] = useState(undefined); + + useEffect(() => { + const id = window.setTimeout(() => { + setIsPublicFacing(regex.test(window.location.pathname)); + }, 0); + return () => window.clearTimeout(id); + }, []); + + const heading = isPublicFacing ? t("appUpdate.user.title") : t("appUpdate.builder.title"); + const message = isPublicFacing + ? t("appUpdate.user.description") + : t("appUpdate.builder.description"); + const buttonText = isPublicFacing ? t("appUpdate.user.button") : t("appUpdate.builder.button"); + + if (isPublicFacing === undefined) return null; + return (