From 95ad5f903cbfa83e2658a64e75f4c8c782a4c8d6 Mon Sep 17 00:00:00 2001 From: Brendan Samek Date: Fri, 16 Oct 2026 09:41:12 -0600 Subject: [PATCH 1/2] Fix in-page button jumps and show the fallback when Formstack renders nothing Button links to a #hash now render a plain : next/link treated a click on a hash already in the URL as a no-op, so "Sign up to volunteer" didn't scroll on a second click or on a page opened at #signup. Same fix covers the #ballot button on when-is-the-election. FormstackForm now waits for Formstack's
to actually appear before calling the embed ready, and shows the open-in-a-new-tab fallback if it hasn't within 15 seconds, instead of a blank space. Failures are recorded in PostHog as formstack_form_failed. Co-Authored-By: Claude Opus 5.5 --- src/app/toronto/volunteer/FormstackForm.tsx | 38 +++++++++++++++++++-- src/components/ui/button.tsx | 12 +++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx index 4e61cfa..c4d05f1 100644 --- a/src/app/toronto/volunteer/FormstackForm.tsx +++ b/src/app/toronto/volunteer/FormstackForm.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useEffectEvent, useRef, useState } from "react"; +import posthog from "posthog-js"; const FORMSTACK_ORIGIN = "https://cjpac.formstack.com"; @@ -106,10 +107,22 @@ export function FormstackForm({ JSON.parse(fillKey) as Record, checkKey ? checkKey.split(",") : [], ); - setStatus("ready"); + // The scripts ran, but that doesn't mean Formstack rendered: its + // renderer can fail inside (an old browser, a clash with another + // script) and leave the space blank. Only a form counts as ready. + await waitForForm(container, 15_000); + if (!cancelled) setStatus("ready"); }) - .catch(() => { - if (!cancelled) setStatus("failed"); + .catch((error: unknown) => { + if (cancelled) return; + setStatus("failed"); + // Blocked scripts (ad blockers, work networks) and failed renders + // both land here; recorded so we can see how often people get the + // fallback link instead of the form. + posthog.capture("formstack_form_failed", { + slug, + reason: error instanceof Error ? error.message : String(error), + }); }); return () => { @@ -195,6 +208,25 @@ function captureWrites(src: string): Promise { }); } +/** Resolves once Formstack's is in the container, or rejects after + * `timeout` ms. */ +function waitForForm(container: HTMLElement, timeout: number): Promise { + return new Promise((resolve, reject) => { + if (container.querySelector("form")) return resolve(); + const observer = new MutationObserver(() => { + if (!container.querySelector("form")) return; + observer.disconnect(); + clearTimeout(timer); + resolve(); + }); + const timer = setTimeout(() => { + observer.disconnect(); + reject(new Error("Formstack didn't render the form")); + }, timeout); + observer.observe(container, { childList: true, subtree: true }); + }); +} + /** Shared across mounts so navigating back to a form doesn't execute * Formstack's renderer bundle a second time. */ const loadedScripts = new Map>(); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 86033b2..63799dc 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -115,6 +115,18 @@ export function Button(props: ButtonProps) { ); } + // A jump within the page is left to the browser. next/link treats a click on + // a hash that's already in the URL as a no-op navigation, so the second + // click (or the first, on a page opened at that hash) wouldn't scroll. + if (props.href.startsWith("#")) { + return ( + + {children} + + + ); + } + // Default: internal link return ( From 22c1f42c77ab940b22d43420cc1c98f013b8c6af Mon Sep 17 00:00:00 2001 From: Brendan Samek Date: Fri, 16 Oct 2026 09:52:13 -0600 Subject: [PATCH 2/2] Drop the Formstack render check The volunteer pages are moving off Formstack, so it isn't needed. Co-Authored-By: Claude Opus 5.5 --- src/app/toronto/volunteer/FormstackForm.tsx | 38 ++------------------- 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/src/app/toronto/volunteer/FormstackForm.tsx b/src/app/toronto/volunteer/FormstackForm.tsx index c4d05f1..4e61cfa 100644 --- a/src/app/toronto/volunteer/FormstackForm.tsx +++ b/src/app/toronto/volunteer/FormstackForm.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useEffectEvent, useRef, useState } from "react"; -import posthog from "posthog-js"; const FORMSTACK_ORIGIN = "https://cjpac.formstack.com"; @@ -107,22 +106,10 @@ export function FormstackForm({ JSON.parse(fillKey) as Record, checkKey ? checkKey.split(",") : [], ); - // The scripts ran, but that doesn't mean Formstack rendered: its - // renderer can fail inside (an old browser, a clash with another - // script) and leave the space blank. Only a form counts as ready. - await waitForForm(container, 15_000); - if (!cancelled) setStatus("ready"); + setStatus("ready"); }) - .catch((error: unknown) => { - if (cancelled) return; - setStatus("failed"); - // Blocked scripts (ad blockers, work networks) and failed renders - // both land here; recorded so we can see how often people get the - // fallback link instead of the form. - posthog.capture("formstack_form_failed", { - slug, - reason: error instanceof Error ? error.message : String(error), - }); + .catch(() => { + if (!cancelled) setStatus("failed"); }); return () => { @@ -208,25 +195,6 @@ function captureWrites(src: string): Promise { }); } -/** Resolves once Formstack's is in the container, or rejects after - * `timeout` ms. */ -function waitForForm(container: HTMLElement, timeout: number): Promise { - return new Promise((resolve, reject) => { - if (container.querySelector("form")) return resolve(); - const observer = new MutationObserver(() => { - if (!container.querySelector("form")) return; - observer.disconnect(); - clearTimeout(timer); - resolve(); - }); - const timer = setTimeout(() => { - observer.disconnect(); - reject(new Error("Formstack didn't render the form")); - }, timeout); - observer.observe(container, { childList: true, subtree: true }); - }); -} - /** Shared across mounts so navigating back to a form doesn't execute * Formstack's renderer bundle a second time. */ const loadedScripts = new Map>();