feat(forms): add Form A iframe fallback safeguards

This commit is contained in:
DeCentN2Madness committed 2026-10-28 18:29:32 -07:00
1 parent 7cdf209032
commit 1a56cd7603
10 files changed
+299 -59

No files matched your search

+62 -13
View File
@@ -13,18 +13,6 @@ const { config, heading, description, variant = 'standalone' } = Astro.props;
if (!config.enabled || !config.url || !config.title || !config.fallbackText || !config.fallbackLinkText) {
throw new Error('JotformIframe requires an enabled, fully validated Form A iframe configuration.');
}
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] ?? character);
// Astro treats iframe descendants as raw text. set:html emits standards-based
// iframe fallback markup while preserving the fixed, server-validated URL.
const fallbackMarkup = `<p class="jotform-iframe__fallback">${escapeHtml(config.fallbackText)} <a href="${escapeHtml(config.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(config.fallbackLinkText)}</a></p>`;
---
<section
@@ -47,10 +35,67 @@ const fallbackMarkup = `<p class="jotform-iframe__fallback">${escapeHtml(config.
loading="eager"
referrerpolicy="no-referrer"
sandbox="allow-forms allow-scripts allow-same-origin"
set:html={fallbackMarkup}
data-jotform-iframe-frame
></iframe>
<p class="jotform-iframe__fallback" data-jotform-iframe-fallback hidden aria-live="polite" role="status">
{config.fallbackText} <a href={config.url} target="_blank" rel="noopener noreferrer">{config.fallbackLinkText}</a>
</p>
<noscript>
<p class="jotform-iframe__fallback jotform-iframe__fallback--noscript">
{config.fallbackText} <a href={config.url} target="_blank" rel="noopener noreferrer">{config.fallbackLinkText}</a>
</p>
</noscript>
</section>
<script is:inline>
// A frame `load` only proves that the browser completed a document load. It
// cannot prove that cross-origin Jotform initialized correctly or accepted
// a submission, so this controls the hosted-form fallback only.
(() => {
const fallbackTimeoutMs = 15_000;
const initialize = (container) => {
if (container.dataset.jotformFallbackInitialized === 'true') return;
const frame = container.querySelector('[data-jotform-iframe-frame]');
const fallback = container.querySelector('[data-jotform-iframe-fallback]');
if (!(frame instanceof HTMLIFrameElement) || !(fallback instanceof HTMLElement)) return;
container.dataset.jotformFallbackInitialized = 'true';
fallback.hidden = true;
let disposed = false;
const hideFallback = () => {
fallback.hidden = true;
container.dataset.jotformFallback = 'hidden';
};
const showFallback = () => {
if (disposed) return;
fallback.hidden = false;
container.dataset.jotformFallback = 'visible';
};
const timer = window.setTimeout(showFallback, fallbackTimeoutMs);
const onLoad = () => {
window.clearTimeout(timer);
// A late frame load supersedes the transport timeout. It is still not
// treated as a success, conversion, or cross-origin health signal.
hideFallback();
};
const dispose = () => {
disposed = true;
window.clearTimeout(timer);
frame.removeEventListener('load', onLoad);
};
frame.addEventListener('load', onLoad, { once: true });
window.addEventListener('pagehide', dispose, { once: true });
document.addEventListener('astro:before-swap', dispose, { once: true });
};
document.querySelectorAll('[data-jotform-form-a]').forEach(initialize);
})();
</script>
<!-- Keep adapter styling route-local: the campaign and main-site bundles must not share campaign CSS. -->
<style is:inline>
.jotform-iframe {
@@ -99,6 +144,10 @@ const fallbackMarkup = `<p class="jotform-iframe__fallback">${escapeHtml(config.
margin: 0 0 1rem;
}
.jotform-iframe__fallback[hidden] {
display: none;
}
.jotform-iframe__description {
color: var(--color-muted);
font-size: .9rem;
+2
View File
@@ -27,6 +27,7 @@ export type FormAIframeReason =
| 'enabled'
| 'mode-not-isolated-test'
| 'isolated-test-not-approved'
| 'live-approval-not-disabled'
| 'invalid-environment-role'
| 'indexing-enabled'
| 'route-not-authorized'
@@ -132,6 +133,7 @@ export function resolveFormAIframe(options: ResolveFormAIframeOptions): FormAIfr
});
const config = resolveFormMode(options.rawMode, options.liveApproved);
if (config.mode !== 'isolated-test') return disabled('mode-not-isolated-test');
if (options.liveApproved?.trim().toLowerCase() !== 'false') return disabled('live-approval-not-disabled');
if (!isTrue(options.isolatedTestApproved)) return disabled('isolated-test-not-approved');
if (options.environmentRole?.trim().toLowerCase() !== isolatedTestEnvironmentRole) return disabled('invalid-environment-role');
if (isTrue(options.allowIndexing)) return disabled('indexing-enabled');