Server Side Rendering fallback for old browsers

Description

According to our design principles, we prioritize for modern browsers and look only for usability in older browsers, not feature parity. This means that if you visit a Frontity site in a really old browser where Proxy isn’t supported, the load and the navigation will work, but some functionalities, like opening a menu for example, won’t.

Right now, if Proxy isn’t supported, Frontity throws a warning. However, sometimes users will want to add some more logic in this case. We would like to offer an easy-to-use solution to allow users do whatever they want in old browsers. They could just show a message in the web to update the browser or create an alternative logic supported by old browsers.

Functionalities

  • Packages should be able to create alternative logic for old browsers where Proxy is not supported.

Requirements

  • Packages should be able to access defined the state, actions and libraries in the SSR from the fallback.

Possible solution

Our initial idea was to create an specific action to be used when Proxy is not supported, similar to afterCSR(), where packages can hook into and add any logic they want for old browsers.

Another idea we had in mind was to allow a fallback to the AMP version when the AMP package is ready.

Thank you @SantosGuillamot for opening this up.

Disclaimer: This is by no means concluded. There are still venues to explore, so if you know one, please share it here.

General requirements

  1. The website should provide the same functionality.
  2. The actions should trigger state changes that drive UI changes.

Reading material

There are a few concept that first we need to get familiarised with.

  1. How frontity manages the state
  2. What actually happens in the browser at load time
  3. Proxy browser support

Explored ideas

1. Actions always trigger a full <App/> rerender

In a browser that supports Proxy, an action triggers a state update, and based on the mutation happening on the derived state, the components that are using that particular state are re-rendered. This is the ideal scenario where Proxy is used to “observe” the used state and based on the registered use of that state, run a reaction queue, that in turn triggers a re-render.

For the browsers that do not support Proxy we could practically trigger a full app re-render for each action.

Pros

  • Actions will work as before without the user need to do anything

Cons

  • A full app re-render might be really bad in terms of performance
  • Multiple actions might lead to multiple re-renders
  • @frontity/connect will need to have a different code-path for cases where there is no Proxy support

2. connect() should provide a way to compute when a component should update

Just like there is with memo() and other state selectors, instead of making the component reactive we could instead use this second selector function to detect when it should update.

const Menu = ({ state }) => {
  return (
    <nav>
      {state.theme.isMenuOpened && "Menu"}
    </nav>
  );
}

export default connect(Menu, (nextState, prevState) => {
  // Should re-render only when the menu is opened.
  return nextState.theme.isMenuOpened !== prevState.theme.isMenuOpened;
});

Another variance would be having a static property on the functional component that it’s being wrapped. This one might be better in terms of clearly separating the behaviours.

const Menu = ({ state }) => {
  return (
    <nav>
      {state.theme.isMenuOpened && "Menu"}
    </nav>
  );
}

Menu.selectorForLegacyBrowser = (nextState, prevState) => {
  // Should re-render only when the menu is opened.
  return nextState.theme.isMenuOpened !== prevState.theme.isMenuOpened;
};

export default connect(Menu);

This will require changes inside the observer function to trigger the scheduler on each action update.

Pros

  • Easy on the app performance as it’s not gonna trigger a full <App /> rerender
  • Easy to sprinkle on top of current implementations

Cons

  • It needs to be clear explained that this is not needed for default behaviour.
  • It’s gonna be shipping with the default code, hence adding more size.

Still thinking about other ways and exploring alternatives but I submitted this WIP so I don’t loose it.

Thanks for sharing your initial ideas Cristian :slightly_smiling_face:

Just one comment so far:

I wouldn’t consider a requirement that old browsers should provide the same functionality. As explained in the Design Principles, we look only for usability in older browsers, not feature parity. Currently, browsers not supporting proxy are less than 5%, and this number decreases each year. For this reason, we think providing usability should be enough. Actually, even the AMP-framework is following the same approach.

Hey @SantosGuillamot, that’s a good point about feature parity. Now, with this in mind, maybe that requirement should be something like, " 1. The website should provide the same functionality in a way that it does no affect the other 95% of the browsers"?

Or we are basically looking for a way to just trigger changes based on those actions?

Gonna post soon some more ideas and a video, hopefully :smiley:

I agree with Mario about this one.

The initial idea for this is:

  • The website should provide some functionality (so it is not broken) but not the same functionality.
  • state changes do not need to trigger a new UI render. We are not trying to make React and the state manager work on those browsers.

For example, imagine an e-commerce site:

  • The site should render properly.
  • The images should appear.
  • The links should work.
  • But maybe the pinch-zoom or the swipe in the image gallery doesn’t work.
  • And if you click on an “Add to cart” button, it displays a message saying “Please update your browser to make a purchase in this shop” message, because the cart and the checkout don’t work.

But your approaches of making React and the state manager work in pre-Proxy browsers are very interesting though!

I wouldn’t like to close the door to those ideas because obviously making Frontity work as it is in old browsers would be the ideal solution, but there are more implications of our ES6 Proxy usage than what it may seem at first, especially with the upcoming Frontity Hooks.

So why don’t we have a call to discuss those ideas? See how far we could go? What do you think? :slightly_smiling_face:

Sure thing! Sounds great.

The thing about the requirements is that I don’t think there’s a way to differentiate between, an action that should work, and another one that should show the message. Meaning, our whole state, is pretty blackboxed for frontity in terms of what it represents. A state that shows a product in a cart is the same as the menu is open, for us.

Having a different API for the actions that are important, is one way to do it. And that’s what I’m exploring at the moment. A way to flag an action that would trigger a state update in an non-connected app.

3. Have a dispatch function with whom to call the actions

Prerequisite

For browsers that do not support Proxy we should hydrate with a read only version of the state.

const stateElement = document.getElementById("__FRONTITY_CONNECT_STATE__");
// createStore here will mostly merge the state and prepare the actions.
const store = createStore({
  state: JSON.parse(stateElement.innerHTML),
  packages,
});

hydrate(<MainApp />, window.document.getElementById("root"));

Using the dispatch

Let’ talk more precisely about the isMobileMenuOpen flag within our mars theme project. frontity/index.js at dev · frontity/frontity · GitHub. Opening and closing the menu is done via this flag, and rendered on demand based on it’s value. frontity/menu.js at dev · frontity/frontity · GitHub

With the dispatch function, I imagine something like this could be achieved:

import { styled, connect, dispatch, Global } from "frontity";
import { CloseIcon, HamburgerIcon } from "./menu-icon";
import MenuModal from "./menu-modal";

function MobileMenu({ state, actions }) {
  const { isMobileMenuOpen } = state.theme;
  return (
    <>
      <div onClick={() => dispatch(actions.theme.toggleMobileMenu)}>
        {isMobileMenuOpen ? (
          <>
            {/* Add some style to the body when menu is open,
            to prevent body scroll */}
            <Global styles={{ body: { overflowY: "hidden" } }} />
            <CloseIcon color="white" size="20px" />
          </>
        ) : (
          <HamburgerIcon color="white" size="24px" />
        )}
      </div>
      {/* If the menu is open, render the menu modal */}
      {isMobileMenuOpen && <MenuModal />}
    </>
  );
}

export default connect(MobileMenu);

This is pretty tied with the Flux architecture, so it adds the benefit of what it is expected from it. Meaning, the dispatch will call the action, with a copy of the current store state, and the result it’s going to be committed to the store and trigger updates to the connect(ed) components.

Pros

  • Completely opt-in

Cons

  • Could lead to the same perf issues since unnecessary re-renderings might happen

Great. Schedule something for tomorrow or Wednesday :slightly_smiling_face::+1:

@luisherranz and I had a lengthy meeting discussing the concepts involved in handling state in a non-Proxy browser and what would the fallback look like on those browsers.

Intermediary conclusion

It’s not trivial to have a streamlined solution that sets the proper expectation for the developer of what parts of state are working or not. No API offers that visibility without additional confusion of the side-effects. So, with this in mind, we concluded that a solution that offers the ability to have a ssrFallback action would be the best option. This action would be called after the state, libraries and actions would be already bootstrap and this would give the developer a chance to handle the needed interactivity on the client.

Example:

export default {
  actions: {
    theme: {
      ssrFallback({ state }) {
        const menuButton = document.getElementById('menu-button');
        // Handle the interactivity at will.
      }
    }
  }
}

This was a bit lengthier than I anticipated, but overall I do believe that I managed to research the proper way of handling this.

The Challenge Ahead

Frontity mission is to deliver top-notch and best in class quality for websites, running in what is considered modern browsers, the line was drawn at the presence of the Proxy class. That means, the browsers that do not have this exposed on window can not benefit from Frontity’s functionalities at large, because of the tight relationship between how internally Frontity works.
But we do not stop there, we serve modern bundled code available as JavaScript modules or ESModules. That means that at each build time, we generate two sets of scripts that need to be sent to the browser. Depending on the browser, it will pick-up either the script or the module, to hydrate the already rendered application.

That means that navigating through links, pages or urls on browsers that do not support the Proxy class, works seamlessly. What doesn’t work, are the framework and theme scripts. And this is the challenge in providing a fallback for older browsers.

Prerequisites and common ground

Before we dive in in the changes needed to frontity we need to settled some grounds.

Add the needed compatibility <meta> tag

In order for older Internet Explorer version to render the websites in compatibility mode, we need to instruct them as such. We can achieve that by adding this <meta> tag to the template.

<meta http-equiv="X-UA-Compatible" content="IE=edge" />

This will ensure the rendering of the server rendered content is rendered correctly, on Internet Explorer 11. This is the least painful change that we can to do in frontity to properly render the pages.

The current runtime throws

Currently, in Internet Explorer 11, the generated scripts cannot run, since we use module by default. That means we need to find another way or a proper way of making sure Frontity can run some scripts.

Understanding the current bundling target

Frontity bundles the JavaScript in two formats for the browsers:

  • module
    This one is aimed at the browsers that know how to parse the newer JavaScript syntax. This is the main targeted browser market.
  • es5
    This is an acronym referring to EcmaScript5 version of JavaScript. This version is the earliest version that we’d like to support, since our state management architecture it’s built on top of Proxy functionality.

These two bundles are served differently with the module/nomodule pattern. You can read more about it here.

What about the previously defined solutions

Most of them are not enough to get the SSR Fallback to have some sort of functionality. So, they are out of the scope of the following researched ideas – since technically they would need to be instrumented in a way to be compatible with legacy browsers.

Researched avenues

Tackling this particular issue it’s not that straightforward, since the architecture of frontity is targeted to treat as first class citizen the modern browser or better yet the modern JavaScript syntax. With this in mind, I’ve tried to not steer far away from it and remain true to it.

A. Lowering the modern browser target

Proposal

Lower the browserlist to include ie >= 11.

Results

This would basically mean, that the es5 format to include polyfills for the missing APIs including Proxy. The latter is not perfect as it can not offer the same functionality, on which the state manager works, and it’ll not work seamless – since Proxy it’s impossible to get a perfect polyfill.

For example we use the .has trap. That it’s not implemented in any polyfill.

Verdict

The generated sources would be compiled with "use strict";.
Since we use sourceType: "unambiguous" for our babel options, and generate the entry point as a module, we’ll have to generate different entry points for each target only to make sure that Internet Explorer can run the scripts without throwing.

B. Create a fallback entry point

Proposal

If we can not use the client entry point, we could offer a fallback client side entry, that will basically only call the actions defined on the theme namespace.

Results

This took way longer to figure out if it’s achievable or not. Mainly trying to come up with the least invasive method. And I believe I found it.

1. Create a fallback.ts entry

Right now, we have a dynamically generated entry-point for the client that based on the packages, imports the needed things and bootstraps the store and hydrates the App. This is all good, but this is where the problem starts cause the bootstrap picks-up the whole state management based on Proxy and its all down from here. By having a separate fallback entry point, we can omit those packages and let the user deal as they wish with the client. This fallback is defined as a relative path to a file to be included in the compilation pipeline.

// frontity.settings.js
export default {
  name: 'my-site',
  fallback: './fallback.ts'
}
// ./fallback.ts
const stateElement = document.getElementById("__FRONTITY_CONNECT_STATE__");
const state = JSON.parse(stateElement.innerHTML);

// Here you can do whatever you'd like with access to state.

2. Define a on-the-fly entry point for the fallback

I imagine this would be based on a state flag or something that we can surface over from the settings. But let’s say we have the green light to generate a fallback entry point. This will be done in two places.

Firstly, based on the flag, we’ll have to create the dynamic entry point, inside webpack/entry.ts. While there, also, push a new frontity-fallback entry mapping, so webpack will pick-it up.

Second, we need to dynamically include it in the scripts collections of the ChunkExtractor:

// ./server-side-rendering.ts
const extractor = new ChunkExtractor({
  stats,
  entrypoints: [settings.fallback && "frontity-fallback", settings.name],
});

This will make it so that we will end-up with 3 scripts in the dom:

<script async data-chunk="frontity-fallback" src="/static/frontity-fallback.es5.js"></script>
<script async data-chunk="mars-theme" src="/static/mars-theme.es5.js"></script>
<script async data-chunk="list" src="/static/list.es5.js"></script>

Verdict

I believe this is the most streamlined solution that I could come up with. Doesn’t offer the interaction between packages and client, but at least one can read the state, and fire-up some functionality for it.

Recorded a video as well:

C. Use the libraries.frontity.scripts

Proposal

For minimal functionality, one can push scripts through libraries.frontity.scripts.

Results

These scripts are not transpiled, compiled or amended in any way. With that being sad something like this:

{
  actions: {
    theme: {
      beforeSSR({ libraries }) {
        libraries.frontity.scripts.push(`
          <script>
            window.gtag('pageview');
          </script>
        `);
      }
    }
  }
}

Verdict

This is highly error prone. I can see one getting nowhere for wanting to reuse pieces of code, sharing functionality or simply wanting to track custom events.

1 Like

Description

Frontity puts modern browsers first and with that as a side-effect the legacy ones are left without a way to interact on the client. With this IP my aim is to allow folks to add interactivity for the legacy browsers in a way that’s not invasive on the output but achieves the desired functionality.

Glossary

  • module
    Refers to JavaScript modules.
  • bundling
    The action of transforming JavaScript code syntax into another code syntax that’s understandable by modern browsers.

Context / Background

Currently Frontity, serves the scripts into two formats: module and es5. The primary format is module but for the browsers that do not support module but does support the Proxy class, there’s a es5 variant. This is working really well, but stops working for browsers that do not have support for Proxy class. Therefore, for legacy browsers, even though the navigation of the website does function the client-side functionalities do not. So this is the problem that I aim to solve with this IP.

Goals

  • A way of gaining execution context for legacy browsers with access to frontity state.

Out of Scope

  • Actual application code getting executed due to incompatible JavaScript execution contexts.

Implementation Proposal

Allow a fallback file to be defined and included for the legacy browsers.

  • Pros:
    • Integrated into the build/dev process
    • Part of the website settings
    • Isolated in terms of accidentally including application code that won’t run.
  • Cons:
    *

Acceptance Criteria / Requirements

  • A frontity website can run client side code on legacy browsers to enable some interactivity.

Alternate Solutions

More about them here: Server Side Rendering fallback for old browsers - #11 by cristianbote

Dependencies

None

Individual Tasks

  • Add the <meta> tag for compatibility mode.
  • Define a new entry point based on the availability of the site.fallback file reference and include it in the appended scripts.

Documentation

Wrote about it more here: Server Side Rendering fallback for old browsers - #11 by cristianbote

1 Like

Thanks for the detailed explanation @cristianbote :slightly_smiling_face:

With the approach you suggest, could each package define its own fallback or does it have to be defined in a project level?

1 Like

Hey @SantosGuillamot it’s a project level fallback. It’s not functionally possible to have a package level one, since the code for those packages can’t run in a non-Proxy browser.

Hey Cris, great analysis thanks :slightly_smiling_face:

I have a question: What prevents us from lowering the es5 bundle to compile for ie11-like browsers and create a different createStore when Proxy is not present?

Something like this:

const { innerHTML: state } = document.getElementById(
  "__FRONTITY_CONNECT_STATE__"
);

const runActions = async (name, actions) => {
  await Promise.all(
    Object.values(actions).map((namespace) => {
      if (namespace[name]) return namespace[name]();
    })
  );
}

if (window["Proxy"]) {
  // Use normal `createStore`.
  const store = createStore({ state, packages });
  // Run `init` actions.
  await runActions("init", store.actions);
  // Run `beforeCSR` actions.
  await runActions("beforeCSR", store.actions);
  );
  // Hydrate App.
  hydrate(<MainApp />, window.document.getElementById("root"))
  // Run `afterCSR` actions.
  await runActions("afterCSR", store.actions);
} else {
  // Use a different version of `createStore` that doesn't use Proxy.
  const store = createStaticStore({ state, packages });
  // Run the fallback action.
  await runActions("fallback", store.actions);
}

Okay, thanks :slightly_smiling_face: To clarify, that would mean that we coudln’t create a fallback for the analytics or the ads packages, we would have to ask the users to add it to their projects right? Moreover, theme developers couldn’t add support for old browsers directly, they would have to ask users to add some pieces of code as well?

Hey @luisherranz, thanks for checking it out.

Reading your reply, I think I did a poor job explaining the problems. So I’ll try again with a different approach.

There are a few problems with having a read/static only store, problems that would either change how frontity is architected or the codebase.

Problem 1: Generated compiled code is using "use strict";

Even though this might be something trivial, IE11 at least can’t run our current bundle because the es5 target is using "use strict"; or strictMode. This means, that we don’t even get the code to run for detecting Proxy.

Now, we can disable this, but the configuration to disable it, sourceType, also changes the way babel will read up the source code. Meaning it will treat our files as commonjs not esmodules. That means this is a no go and we would have to find other solutions.

Problem 2: There’s no guarantee that the list of packages are not using Proxy.

The entry point for the client, currently, is something like this:

import client from "@frontity/core/src/client";
import frontity__tiny_router_default from "@frontity/tiny-router/src/index";
import frontity__html2react_default from "@frontity/html2react/src/index";
import frontity__mars_theme_default from "@frontity/mars-theme/src/index";
import frontity__wp_source_default from "@frontity/wp-source/src/index";

const packages = {
  frontity__tiny_router_default,
  frontity__html2react_default,
  frontity__mars_theme_default,
  frontity__wp_source_default,
};

export default client({ packages });

The moment the import block gets executed we lose the ability to detect the Proxy availability on the client. Now, we could, change this into a dynamically imported file, based on the Proxy availability, like this:

if (typeof window.Proxy === 'undefined') {
  console.log('woot no Proxy?');
} else {
  // `bootstrap` is the initial file from above.
  import('./bootstrap');
}

This works. IE11 runtime it’s now unblocked but we still can’t run the packages imports because of the "use strict" usage and compilation. And we need to import the packages in order to get the namespaces and the code for actions and all.

(maybe?) Problem 3: Allow packages to define a fallback entry

This sounds promising, until we get into the thick of it.

Having a different entry point for the legacy browsers it’s technically possible, but functionally provides almost zero value to the end user, since the <App /> it’s not running and can’t run in that environment. So, what does this mean? That a package could define a fallback file, to be imported for the legacy browsers, but has no execution context of where is running, besides the state.

// ./foo-theme/config.js
export default {
  name: 'foo-theme'
}
// ./foo-theme/index.js
import config from './config';

export default {
  ...config
}
// ./foo-theme/fallback.js
import config from './config';

export default {
  ...config,
  actions: {
    theme: {
      fallback(readOnlyState) {
        // do something here?
      }
    }
  }
}

With the above setup our entry point will become something like this:

// Import only the packages that have a `fallback` entry
import frontity__tiny_router_default from "@frontity/tiny-router/src/fallback";
import frontity__mars_theme_default from "@frontity/mars-theme/src/fallback";

const packages = {
  frontity__tiny_router_default,
  frontity__mars_theme_default,
};

if (typeof window.Proxy === 'undefined') {
  // Read the state
  const state = JSON.parse( document.getElementById("__FRONTITY_CONNECT_STATE__").innerHTML);
  
  // Run through the packages and call the `fallback` action.
  for (let name in packages) {
    // Get the namespace
    const actions = packages[name].actions;
    for (let ns in actions) {
      // If there's a fallback action defined, run it with the read only state
      if (actions[ns].fallback) {
        actions[ns].fallback(state);
      }
    }
  } 
} else {
  // `bootstrap` is the initial file from above.
  import('./bootstrap');
}

This does seem appealing, but not sure how valuable it is, since the idea of offering functionality for legacy browsers it’s not the in the principles of Frontity. So, instead of having the ecosystem change the way the packages are defined, might be better to isolate that functionality, as much as possible into something more easy to remove.

  • Would a package be better handling the fallback with the read only state as they wish?
  • Would that be enough to sprinkle some interactivity?
  • Is this a better pattern for package authors?
  • Would it be better to have the needed logic for fallback functionality for tracking, ads, or whatnot centralised into a single file?

I see. Thanks for the explanation Cris!

A follow-up question: If we could find a way to solve this problem and sucessfully compile the ES5 bundle for IE11, what solution would you propose?

Taking into account this other point, of course:

Still the same? A different approach?

Sorry @luisherranz, got sidetracked when I’ve tried to reply.

I would still only allow one fallback entry per frontity configuration. I keep think about it and we keep it simple in terms of choices, this will avoid a lot of other overcomplicating the packages ecosystem and will also make it more imperative what sort of fallback one will want to allow.

Ok, thanks for your answers Cris :+1::slightly_smiling_face: