Almost 3 years ago, I wrote about recovering from runtime JavaScript errors thanks to a carefully crafted server-side rendering solution. This is something I was very proud of, and I think a testament of the quality of work that went into the N26 web platform.
The idea is to intercept runtime JavaScript errors, and reload the page with a query parameter which causes the JavaScript bundles not to be rendered, thus simulating a no-JavaScript experience. This way, the user can browse the no-JS version instead of being stuck on a broken page.
I recently announced Gorillasâ new website built with Next, which almost fully support JavaScript being disabled. So I was eager to try add a similar error recovery feature.
The problem
While we do use Next, we do not use the runtime. We essentially use Next as a static site generator. When deploying the site, we build all pages statically (with Nextâ static HTML export), and serve them via Netlify. There is no Node server or anything like that. Itâs just a bunch of HTML files eventually enhanced by a client-side React application.
This means that the HTML files do contain <script> tags at the bottom of the body element to execute our bundles. We canât decide not to render them because, once again, this is all just static files â there is no running server that can modify the response.
So thatâs not even really Nextâs fault per se. Any static site generator would have the same problem. Once the browser receives the HTML response, itâs done, we canât modify it. It will read the <script> tags, download the files, parse them and execute them. So⌠rough one to solve I guess.
Hacking a solution
If we canât do anything about the script tags being rendered in the HTML response, maybe we can prevent the browser from executing them? Well, again, not really. Browsers do not offer a fine-grained API into their resourcesâ system to tell them to ignore or prioritize certain assets.
Did you know about window.stop() though? âCause I didnât until today. Thatâs a method on the window object that essentially does what the âStopâ button from the browser does. Quoting MDN:
The
window.stop()[function] stops further resource loading in the current browsing context, equivalent to the stop button in the browser. Because of how scripts are executed, this method cannot interrupt its parent documentâs loading, but it will stop its images, new windows, and other still-loading objects.
What if we called window.stop() before the browser reaches the <script> tags rendered by <NextScript />? Letâs try that by updating ./pages/_document.js (see Custom Document in Nextâs documentation):
class MyDocument extends Document {
static getInitialProps(ctx) {
return Document.getInitialProps(ctx)
}
render() {
return (
<Html>
<Head />
<body>
<Main />
{/* Trying to prevent <script> elements rendered by
<NextScript /> from being executed. The proper
condition will be covered in the next section. */
<script dangerouslySetInnerHTML={{ __html: </span><span class="token string"> if (true) window.stop() </span><span class="token template-punctuation string"> }} />
<NextScript />
</body>
</Html>
)
}
}
Performing a Next export and serving the output folder before loading any page yields positive results: not only are the <script> tags not executed, but theyâre not even rendered in the dev tools. Thatâs because window.stop() literally killed the page at this point, preventing the rest of the document from being rendered.
<script>if (true) window.stop()</script>
</body>
</html>
Building the thing
Of course, we do not want to always prevent the scriptsâ execution. Only when weâve captured a JavaScript error and reloaded the page with a certain query parameter. To do that, we need an error boundary.
class ErrorBoundary extends React.Component {
componentDidCatch(error, info) {
const { pathname, search } = window.location
window.location.href =
pathname + search + (search.length ? '&' : '?') + 'no_script'
}
render() {
return this.props.children
}
}
We can render that component around our content in ./pages/_app.js (see Custom App in Nextâs documentation).
function MyApp({ Component, pageProps }) {
return (
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
)
}
Finally, in our ./pages/_document.js, we can check for the presence of this URL parameter. If it is present, we need to stop the execution of scripts.
class MyDocument extends Document {
static getInitialProps(ctx) {
return Document.getInitialProps(ctx)
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<script dangerouslySetInnerHTML={{ __html: </span><span class="token string"> if (window.location.search.includes('no_script')) { window.stop() } </span><span class="token template-punctuation string"> }} />
<NextScript />
</body>
</Html>
)
}
}
Thatâs it, job done. Hacky as hell, but heh. It seems to work okay. For the most part at least, as it has some potentially negative side-effects: any ongoing request, such as for lazy loaded images, will be interrupted. That can cause some images not to render. Still better than a broken page due to a JavaScript error in my opinion, but I guess the choice is yours.
Alright people, lay it on me. How bad is this, and how ashamed shall I be?
[Update] Clean solution
Maximilian Fellner was kind enough to take the time to build a demo of a way to inject Next scripts dynamically. The solution is a little complicated so I wonât go into the details in this article â feel free to check Maximilianâs proof of concept. Thanks for the hint Max!
Building on top of his work, I figured out a rather elegant way forward. Instead of rendering <script> tags and then trying to remove or not to execute them when the no_script parameter is present, letâs turn it around. Letâs not render the <script> tags, and only dynamically inject them at runtime when the no_script URL parameter is absent.
However, Next does not provide a built-in way to know what scripts should be rendered on a given page, or what are their paths. There is no exposed asset manifest or anything like this. So what we can do is render them within a template. If you are not familiar with the <template> HTML element, allow me to quote MDN:
The HTML Content Template (
<template>) element is a mechanism for holding HTML that is not to be rendered immediately when a page is loaded but may be instantiated subsequently during runtime using JavaScript.
class MyDocument extends Document {
static getInitialProps(ctx) {
return Document.getInitialProps(ctx)
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<template id='next-scripts'>
<NextScript />
</template>
</body>
</Html>
)
}
}
Perfect. Now, all we need is a little JavaScript snippet to effectively properly render these <script> tags if the no_script URL parameter is not present.
const scriptInjector = `
if (!window.location.search.includes('no_script')) {
var template = document.querySelector("#next-scripts")
var fragment = template.content.cloneNode(true)
var scripts = fragment.querySelectorAll("script")
Array.from(scripts).forEach(function (script) {
document.body.appendChild(script)
})
}
`.trim()
class MyDocument extends Document {
static getInitialProps(ctx) {
return Document.getInitialProps(ctx)
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<template id='next-scripts'>
<NextScript />
</template>
<script dangerouslySetInnerHTML={{ __html: scriptInjector }} />
</body>
</Html>
)
}
}
Boom, job done. If the no_script URL query parameter is present, the script will do nothing, effectively mimicking a no-JavaScript expperience. If it is not, it will load Next bundles, just like normal.