Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
cacedbd03a fix: prevent custom scripts from accessing document.body before React hydration
- Add ensureBodyExists() to wait for document.body availability
- Wrap inline script content to defer execution until DOM is ready
- Add defensive checks to prevent race conditions
- Skip wrapping for scripts that already have DOM-ready checks
- Only wrap inline scripts, not external scripts with src attribute

Fixes FORMBRICKS-RW
2026-03-12 20:59:00 +00:00

View File

@@ -41,41 +41,113 @@ export const CustomScriptsInjector = ({
if (!scriptsToInject.trim()) return;
try {
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content
if (script.textContent) {
newScript.textContent = script.textContent;
/**
* Ensures document.body exists before executing the injection.
* This prevents race conditions where custom scripts try to access document.body
* before React hydration completes, which would cause:
* - React error #454 (missing document.body)
* - TypeError: can't access property "removeChild" of null
*/
const ensureBodyExists = (): Promise<void> => {
return new Promise((resolve) => {
// If body already exists, resolve immediately
if (document.body) {
resolve();
return;
}
document.head.appendChild(newScript);
// Wait for DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
} else {
// Document is already loaded but body doesn't exist yet (edge case)
// Use setTimeout to defer until next tick
setTimeout(() => resolve(), 0);
}
});
};
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
/**
* Wraps inline script content to ensure safe execution after DOM is ready.
* This prevents scripts from executing before document.body is available.
*/
const wrapScriptContent = (content: string): string => {
// Don't wrap if the script already has DOM-ready checks
if (
content.includes("DOMContentLoaded") ||
content.includes("document.readyState") ||
content.includes("window.addEventListener('load'")
) {
return content;
}
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
// Wrap the script to ensure it runs after DOM is ready
return `
(function() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
${content}
});
} else {
${content}
}
})();
`;
};
const injectScripts = async () => {
try {
// Wait for document.body to exist before injecting any scripts
await ensureBodyExists();
// Defensive check: ensure body still exists
if (!document.body) {
console.warn("[Formbricks] document.body is not available, skipping script injection");
return;
}
// Create a temporary container to parse the HTML
const container = document.createElement("div");
container.innerHTML = scriptsToInject;
// Process and inject script elements
const scripts = container.querySelectorAll("script");
scripts.forEach((script) => {
const newScript = document.createElement("script");
// Copy all attributes (src, async, defer, type, etc.)
Array.from(script.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy inline script content with safety wrapper
if (script.textContent) {
// Only wrap inline scripts (not external scripts with src attribute)
if (!script.hasAttribute("src")) {
newScript.textContent = wrapScriptContent(script.textContent);
} else {
newScript.textContent = script.textContent;
}
}
document.head.appendChild(newScript);
});
// Process and inject non-script elements (noscript, meta, link, style, etc.)
const nonScripts = container.querySelectorAll(":not(script)");
nonScripts.forEach((el) => {
const clonedEl = el.cloneNode(true) as Element;
document.head.appendChild(clonedEl);
});
injectedRef.current = true;
} catch (error) {
// Log error but don't break the survey - self-hosted admins can check console
console.warn("[Formbricks] Error injecting custom scripts:", error);
}
};
injectScripts();
}, [projectScripts, surveyScripts, scriptsMode]);
return null;