SBN

Progressively loading CSR pages

Progressive enhancement #

Progressive enhancement is a design philosophy that consists in
delivering as much functionality to users as possible, regardless of the manner they may access our system. This ensures that a baseline of content and functionality is available to all users, while a richer experience may be offered under the right
conditions. For instance, the website for a newspaper may make the text content of its articles available to all users through HTML markup, while providing interactive media content for users with a capable browser.

Nowadays,
client-side scripting is used in most websites to provide various levels of functionality. Frameworks like React, Angular and Vue.js allow developers to deliver highly interactive experiences, which in turn has made modern rich web applications feasible,
such as spreadsheet applications that run completely in the browser. Because of many of the conveniences that they provide, these frameworks are also used for all sorts of websites and not just for those with complex interactivity.

The
principles of progressive enhancement can be applied to all websites, no matter how they are built or what they do. For websites that are open to the public, progressive enhancement is essential to ensure the best possible experience for our
users.

Progressively loading web applications #

Server-side rendering vs. client-side rendering #

A website can
be server-side rendered, client-side rendered or use a combination of both approaches.

When an application is server-side rendered, the server delivers HTML markup with content that is ready for a user to consume. In contrast, an
application that is client-side rendered constructs the document presented to users with the help of client-side scripts.

By default, applications built with modern frameworks will be rendered on the client side, whether the content is
static or generated dynamically from external parameters (such as data returned from an API).

For example, a typical React application may have some HTML like this:

<!DOCTYPE html> <html>  <head>  <title>Example Page</title>  <script  crossorigin  src="https://unpkg.com/react@18/umd/react.production.min.js">  </script>  <script  crossorigin  src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js">
 </script> </head> 
<body>  <div id="root"></div>  <script src="basic.js"></script> </body>  </html> 

With the corresponding rendering logic in
basic.js, which could look like this:

const root = ReactDOM.createRoot(document.getElementById('root')) root.render(React.createrrorElementement('div', null, 'Hello, World!')) 

This simple example will show a page with the text Hello, World!.

Screen capture showing a simple client-side rendered page
A modern browser will execute the script to render the application, which in this case displays the text Hello, World!.

Since in this example all
of the rendering code depends on client-side scripting, users with an older browser, one that doesn’t support scripts or one with scripts disabled will see a blank page.

Screen capture showing a blank page in a text-based browser
The Links2 text-based browser doesn’t support scripts and will show a blank page.

Screen capture showing a blank page in an older browser
The now-depreacted Internet Explorer 11 browser doesn’t support modern scripts and will display a blank page.

The situation of having a blank page in certain
browsers can be avoided by using server-side rendering. Depending on the framework in question, the approach can be different. For React, this can be accomplished with ReactDOMServer. Following the principles of progressive enhancement, server-side rendering should be used whenever possible or feasible to give users the
possibility to use and interact with our page. The advantage of this approach is that client-side scripting can still be used to enhance interactivity and functionaliy if the client supports it. However, server-side rendering might not always be a viable
option, depending on what our page is meant to do. For instance, a highly-interactive rich application, like an online image editor or game, may require scripting to provide an adequate experience beyond the capabilities of a statically-generated
document.

Regardless of whether server-side rendering is a viable option for our page, we can follow some principles of progressive enhancement to improve the experience of all users.

<noscript> #

Firstly, there is the <noscript> element, which presents content for browsers that
do not support or process client-side scripts. We could use this tag to tell our users either that scripts are required and that they should use a different browser or to let them know that some functionality might not be available without
scripting.

We could use the <noscript> to display a message like this:

<noscript>  Scripting must be enabled to use this application. </noscript> 

Handling errors #

A second consideration is that a client might support scripts but they could result in an error. For example, an older browser might not understand the syntax used in our scripts, like
using const or let for variables, or it might be that we use some functionality that isn’t implemented, such as promises.

While we could avoid some of these potential errors by
other means (like feature detection), for robustness we should implement an error handler that lets users know that an unexpected error occurred so that they can take appropriate action, such as using a different browser or contacting support.

We can implement this with a separate script that implements an error event handler. The reason for using a separate script is that we can implement this handler with syntax and features that all browsers are likely to
support.

For example, our error handler could look something like this:

onerror = function (e)
{  var errorElement = document.getElementById('error')
 var loadingElement = document.getElementById('loading')  if (errorElement) {
 // Show some information about the error  if (typeof e === 'string' && e.length) {  errorElement.appendChild(document.createTextNode(': ' + e))  }  // Make the error element visible  errorElement.style['display'] = 'block'  }  // Hide 'loading' message if an error occurred
 if (loadingElement) {  loadingElement.style['display'] = 'none'  }  return false }

For a corresponding HTML body with error and loading elements:

<div id="root">  <noscript>  Scripting must be enabled to use this application.  </noscript>  <div id="loading">
 Loading...  </div>  <div id="error" style="display:none">An error occurred</div> </div> 

This way, browsers without client-side
scripting will display the message in the <noscript> element, while browsers with scripting will show a ’loading’ message indicating that the application is not yet ready (alternatively, this can
be the page contents when using server-side rendering) or an error message should some error occur.

Screen capture showing a browser with scripting disabled
A browser without scripting enabled displays a message indicating that scripting is required.

Screen capture showing an error message
If an error occurs during script execution, an error message is presented.

Screen capture showing an error message on a legacy browser
If an error occurs during script execution, an error message is presented, even on a legacy browser.

<script> attributes #

Generally, <script> elements block rendering. This means that the page
won’t be interactive or display content until scripts have loaded. Depending on factors like network speed, this can result in poor user experience because the page will appear slow without indication of what is happening.

HTML5 introduced the async and defer attributes to the <script> tag to address this issue. The async attribute specifies that the
script should be fetched in parallel and executed as soon as its contents are available. The defer attribute is similar, except that it specifies that the script should be executed right after the document has been parsed, but
before the DOMContentLoaded event is fired.

For scripts that should render the initial contents of the page, as we are discussing, the defer attribute is the most appropriate, since
it’ll allow us to progressively enhance the page when possible while still being able to provide some fallback content while scripts are being downloaded and executed.

We could use a helper function like this in a
<script defer> element:

// Exceptions to throw var InvalidOrUnsupportedStateError = function () {}  // Entry point var browserOnLoad = function (handler) {  if (['interactive', 'complete'].includes(document.readyState)) {  // The page has already loaded and the 'DOMContentLoaded'  // event has already fired 
// Call handler directly  setTimeout(handler, 0) 
} else if (typeof document.addEventListener === 'function') {
 // 'DOMContentLoaded' has not yet fired  // This is what we expect with <script defer>  var listener = function () {
 if (typeof document.removeEventListener === 'function') {  // Remove the event listener to avoid double firing  document.removeEventListener('DOMContentLoaded', listener) 
}   // Call handler on
'DOMContentLoaded'  handler()
 }  // Set an event listener on
'DOMContentLoaded'  document.addEventListener('DOMContentLoaded', listener)  } else {  // The page has not fully loaded but addEventListener isn't 
// available. This shouldn't happen.  throw new InvalidOrUnsupportedStateError()  }
}  browserOnLoad(function () {  // code that does client side rendering }) 

Putting it all together #

We can
combine all of the techniques presented to load scripts in a way that progressively enhances our page load on each step: first, by presenting users with a message that scripting is necessary, then by displaying a loading message while the application gets
ready (and an error message if something goes wrong), followed by a fully loaded application once all scripts have been executed.

First, we set up the HTML document:

<!DOCTYPE html> <html>  <head>  <title>Example Page</title>  <script>
 onerror = function (e) {  var errorElement = document.getElementById('error')  var loadingElement
= document.getElementById('loading')  if (errorElement) {  // Show some information about the error
 if (typeof e === 'string' && e.length) {  errorElement.appendChild(  document.createTextNode(': ' +
e)  )  }  // Make the error element visible  errorElement.style['display'] = 'block'  }  // Hide 'loading' message if an error occurred
 if (loadingElement) {  loadingElement.style['display'] = 'none'  }  return false  }  </script>  <script defer src="app.js"></script> </head>  <body style="font-size:24pt;background-color:white;color:black">  <div id="root">
 <noscript>  Scripting must be enabled to use this  application.  </noscript>  </div>  <div id="loading" style="color:blue">  Loading...  </div>  <div id="error" style="display:none;color:teal">  An error occurred<!--  --></div>  </div> </body>
 </html> 

Followed by the app.js script:

!function () {
 // Exceptions to throw  var InvalidOrUnsupportedStateError = function () {}
  // Entry point  var browserOnLoad = function
(handler) {  if (['interactive', 'complete'].includes(document.readyState)) {  // The page has already loaded and the 'DOMContentLoaded'
 // event has already fired  // Call handler directly  setTimeout(handler, 0)
 } else if (typeof document.addEventListener === 'function') {  //
'DOMContentLoaded' has not yet fired  var listener = function () {  if (typeof document.removeEventListener === 'function') {
 // Remove the event listener to avoid double firing  document.removeEventListener('DOMContentLoaded', listener) 
}   // Call handler on
'DOMContentLoaded'  handler()
 }  // Set an event listener on
'DOMContentLoaded'  document.addEventListener('DOMContentLoaded', listener)  } else {  // The page has not fully loaded but addEventListener isn't 
// available. This shouldn't happen.  throw new InvalidOrUnsupportedStateError()  }
 }   // Function to load dependency scripts  // For simplicity, this assumes
all scripts are independent  // from each other  var loadScripts = function (scripts) {  return Promise.all(scripts.map(function (src) {  return new Promise(  function (resolve, reject) {  var el = document.createElement('script') 
el.addEventListener('load', resolve)  el.addEventListener('error', reject)  el.src = src  el.crossOrigin = 'anonymous' 
document.head.appendChild(el)  }) 
}))  } 
 browserOnLoad(function () {  loadScripts([  'https://unpkg.com/react@18/umd/react.production.min.js',  'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'  ]).then(  function () {  var root = ReactDOM.createRoot(document.getElementById('root')) 
root.render(React.createElement('div', null, 'Hello, World!'))  onerror =
null  }).catch(function (e) { onerror(e.message || e)
})  }) }() 

*** This is a Security Bloggers Network syndicated blog from Apeleg Blog authored by Ricardo Iván Vieitez Parra. Read the original post at: https://apeleg.com/blog/posts/2023/01/12/progressive-enhancement-script-load/