No way to dumb this down: when an HTML document requests additional HTML content over Ajax (XMLHttpRequest), the browser doesn’t run the code in <script>
elements present in the response.[1]
This is so even when the response is an otherwise-living HTML document.[2] The innards of <script>
tags are still downloaded, of course, but they’re neutralized as raw text (e.g. source code) and not executed.
Why this matters for SPAs
Some classic multi-page apps can be (relatively) easily upgraded to (theoretically) faster single-page apps (SPA) just by changing how links work.
Instead of reloading a whole new page on link click, the initial page stays put, while the next page’s URL is fetched via a background Ajax call. Then part of the main page is replaced by part of the HTML response. It’s far easier on the network, ideally easier on CPU, and faster for the end user.
But this change comes at a price. Previously working code that set up form behaviors, dynamically changed styles, etc. won’t run when a page serves merely as a passive source of HTML markup.
If an Ajax framework so chooses, it might select all <script>
elements in the response, copy out the inner text of each, and reinject it inside new <script>
elements.[3] Then and only then will the code be executed.
But the strictest frameworks don’t allow you to “resuscitate” scripts in this way, even when you control both the outer page and the inner Ajax-fetched page so there’s no worry about security. Squarespace’s proprietary framework is one of these strict ones.
New Squarespace templates are Ajax-powered SPAs
In the newest gen of Squarespace templates (note: my sole SS site is on a very old version and still looks fine, thank you very much!) such as Brine, your entire site is actually a single page.
Once that initial page is loaded, other would-be “pages” are actually HTML fragments dropped inside a placeholder “dynamic content” element. (A full HTML page is fetched under the hood using Ajax, then the interesting parts are clipped out and injected into the placeholder, replacing its previous contents.)
As you follow nav links like Shop or Blog and click further into Shop products and Blog posts, you’re doing all that within the same main document:
If you refresh your browser, the main document does reload once, then the process continues: new “pages” are again Ajax-injected as you click, keeping the same main doc. (The browser URL does change, so to those unfamiliar with the History API, it might look like a new page loaded. But nope, watch the F12 Network tab and you’ll see it’s all Ajax.)
So Squarespace pages serve in two capacities:
- They can serve as an active main document — if you link to them from a Marketo email, hit Refresh, paste them into your browser and hit Enter, etc. — and in these cases they will run your header or footer JavaScript one time, on page load.
- But they can also serve merely as passive sources of HTML content, fetched dynamically into the main document. In this case, they’ll replace the page’s dynamic content, including
<div>
s and<span>
s and<img>
s and all that stuff, but won’t run any code.
Therefore, if you have JavaScript on your page, you need to make sure that it’s ready for both cases.
You can’t skip the more traditional case (1). Synchronous <script>
or <script src>
, external <script src>
that’s async
hronous and/or defer
red, code that adds listeners for load
or DOMContentLoaded
— all must run on a standard page load, because there’s no special event for non-Ajax-created content.[4]
But your code must also account for case (2). It’ll run only once during main document load, so during that run it must add an event listener function that’ll be triggered every time a new “page” is loaded via Ajax. The listener won’t always find something to do, but it needs to always check for possible tasks, like adding a Marketo form if newly injected content matches a certain pattern.
Squarespace insiders apparently use the jargon “Ajax-enabled code” to describe JavaScript built to handle both cases. Yes, sure it’s “Ajax-enabled,” but such a generic term doesn’t help a developer. A clearer way to put it is: your <script>
may run only once per browser session, so it also needs to set up listeners for ongoing changes to the HTML DOM after that first run.
Adapting the Marketo form embed
The standard Forms 2.0 embed code is like so:
<script src="//pages.example.com/js/forms2/js/forms2.min.js"></script>
<form id="mktoForm_9999"></form>
<script>
MktoForms2.loadForm("//pages.example.com", "123-ABC-456", 9999);
</script>
Straightforwardly, the embed:
- loads the common Forms 2.0 JS library
- creates an easily findable empty
<form>
element - runs
loadForm
, which downloads the form descriptor from Marketo, creates the initial contents of the<form>
, and sets up all the JS event handlers for validation and Visibility Rules and all that jazz
Understandably, the embed code expects to run synchronously, as part of a main document’s (script-enabled!) page load. Its goal is to get the form into the page as quickly as possible (the form being the most important part of a landing page) so it doesn’t include any extra logic. It rightly remains neutral, as it couldn’t fit all manner of unpredictable embedding situations.
But clearly the default embed code won’t function on a Squarespace page when that page falls into case (2) above. When the “page” is merely an Ajax-injected block of HTML, the empty <form>
element would be rendered (for what that’s worth) but neither top nor bottom <script>
would be executed. So there would be no Marketo form.
So we need to get smarter. We need JS that:
- Loads the Forms 2.0 JS library — as there’s no reason to redundantly load this static JS — to create the global
MktoForms2
object and hold it in reserve, even if the current view isn’t supposed to have a form. - Seeks in the current HTML for a recognized parent element. It could be anything, just wherever you want to place the form. For example, if you want every blog post to have a newsletter signup form at the top, see if the page is in “Single Blog Item” mode and find the
<article>
element on that page.- If there’s no matching parent element, don’t do anything for now.
- If there’s a form-worthy parent element, check to see if it already has a
<form>
inside (from earlier navigation). If it’s already there, don’t do anything for now. - If there’s a parent, but no form yet, inject the
<form>
using standard DOMcreateElement
, then runloadForm()
.
- Adds an event listener to run itself again when there’s new page content. This is the key to being Squarespace-aware. By listening for new “pages” to be loaded via Ajax, we automatically re-check if the parent element has come into view, and inject the form inside it if applicable.
The code
For this demo, I wanted to display the form at the top of every Blog item (not trying to win any design awards here!):
So first I determined the CSS selector for the parent HTML element, the first match for "article.BlogItem"
in Squarespace’s single-Blog-item view:
Then added this code to the global Footer (the <script>
tags are required) under Settings » Advanced » Code Injection:
<script>
(function(){
var instanceURL = "//pages.example.com",
munchkinId = "ABC-123-456",
formid = 9999,
parentContainer = "article.BlogItem", // where the form goes
formPositionBefore = true, // put <form> :before other content in parent
pathInfoField = "SquarespaceSubPage"; // hidden field to pass SS sub-page
/* -- NO NEED TO TOUCH BELOW THIS LINE! -- */
var mktoFormsJS = document.createElement("script");
mktoFormsJS.src = instanceURL + "/js/forms2/js/forms2.min.js";
mktoFormsJS.addEventListener("load", function(e){
// check on initial full page load
injectMktoForm();
// recheck on every Ajax "page" load
window.addEventListener("mercury:load", injectMktoForm);
MktoForms2.whenReady(function(form){
var fieldsObj = {};
fieldsObj[subPageInfoField] = document.location.pathname;
form.addHiddenFields(fieldsObj);
});
});
document.head.appendChild(mktoFormsJS);
function injectMktoForm(e){
var parentsVisible = document.querySelectorAll(parentContainer),
formVisible = document.querySelector("form#mktoForm_" + formid + ":not(:empty)");
// expect only one eligible parent for simplicity
if ( parentsVisible.length != 1 || formVisible ) {
return;
}
var mktoFormParent = parentsVisible[0],
mktoFormEl = document.createElement("form");
mktoFormEl.id = "mktoForm_" + formid;
mktoFormParent.insertBefore( mktoFormEl, formPositionBefore ? mktoFormParent.firstChild : null );
MktoForms2.loadForm(instanceURL,munchkinId,formid);
}
})();
</script>
As you can see, the code assumes you have a single Marketo form that goes in a consistent page location in Squarespace. You can extend the code greatly, to deal with multiple possible parents and multiple form IDs, but the core logic remains the same.
One extra frill
You may wonder about the JS variable pathInfoField
. It’s optional, but you can set it to a Marketo form field name. The code will add a corresponding hidden field, set to the URL path at the time of form submission:
This aids in building Smart Campaign filters. You see, because of the SPA architecture, the Referrer property of each Filled Out Form activity is the last main document — that is, the last real Squarespace page, not the Ajax-injected “page” they’re currently viewing — so it’s not a fine-grained constraint.
It’s possible to hack harder so the sub-page appears as the Referrer, but that’s complex enough that it’d have to have another post of its own.
Notes
[1] As I’m ever curious (and hope you are too) this is the exact section of the XMLHttpRequest standard that specifies that scripts must be neutralized:
[2] That is, if you pass contentType = "document"
, you get a fully parsed HTML Document object, but the <script>
tags are still disabled.
[3] Or, less securely but still effectively, eval()
it.
[4] A “content loaded via any method” custom event could always fire, for consistency – it would be equivalent to DOMContentLoaded
in the case of the initial pageview – but in Squarespace’s case it doesn’t.