Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance(noscript): better handling of JS-disabled-or-not-working states #3852

Merged
merged 11 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/browser-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,42 @@ We use https://cdnjs.cloudflare.com/polyfill (see `site/SiteConstants.ts`), so u
We have to be careful in increasing the `vite.config.ts` field `build.target`.

Dropping support for older browsers is fine, but it should be a conscious decision.

## Detecting disabled/non-functioning JS

We try our very best to detect when the browser JS is disabled or non-functioning in any way.
There are many such ways:

- (1) JS is disabled at the browser level.
- (2) JS is disabled via an extension like Ghostery or NoScript.
- (3) JS is enabled, but the browser doesn't support `<script type="module">`.
- (4) JS is enabled, but the browser is old and loading our code results in a `SyntaxError` (because of "relatively modern" syntax features like `await`, `import`, `var?.attr` etc.).
- (5) One of our core JS assets (e.g. `owid.mjs`) cannot be loaded, either because of networking issues, an extension, or something else.
- (6) There is a runtime error early on in script execution (e.g. in `runSiteFooterScripts`), e.g. calling an undefined function <-- **_this one we don't handle!_**

For (1) and (2), we can get by with using `<noscript>` elements, containing HTML that is only ever parsed if scripting is disabled. However, to detect the other failure cases, we need more sophisticated error handling, as such:

- Our static renders all start out with `<html class="js-disabled">`, via [Html.tsx](../site/Html.tsx).
- Addresses (1) and (2), where no further scripts are ever executed.
- An inline script called [`<NoJSDetector>`](../site/NoJSDetector.tsx) (contained in `<Head>`) that is executed early checks if `<script type="module">` is supported, and then replaces `js-disabled` with `js-enabled`. It is executed synchronously before any rendering is performed, meaning that CSS styles targeting `js-disabled` are never evaluated, and e.g. fallback images are not downloaded if not needed.
- Addresses (3).
- This same inline script also sets up a global `window.onerror` event handler. If that one catches a global `SyntaxError` _under our own domain_, then we go back to replacing `js-enabled` with `js-disabled`. The domain check disregards failures coming from other scripts (e.g. Google Tag Manager) or browser extensions.
- Addresses (4).
- Another inline script called [`<ScriptLoadErrorDetector>`](../site/NoJSDetector.tsx) (contained in `<SiteFooter>`) sets up a `<script onerror="...">` handler for our core JS assets, which we mark using a `data-attach-owid-error-handler` attribute. If the handler fires (meaning the script couldn't be loaded), we again replace `js-enabled` with `js-disabled`.
- Addresses (5).
- If `owid.mjs` executes successfully, it will additionally add a `js-loaded` class to the `<html>` element.

### CSS classes

This all gives access to the following CSS classes:

- `js-disabled` and `js-enabled`, mutually exclusive and hopefully self-explanatory.
- Note that there can be cases where we temporarily are at `js-enabled` and then go back to `js-disabled`, e.g. if we encounter a syntax error.
- `js-loaded`, which is applied a bit after `js-enabled` and is more technical.
- `js--hide-if-js-disabled` and `.js--hide-if-js-enabled`, defined in [noscript.scss](../site/css/noscript.scss) can be used as global utility classes.
- Likewise, `js--show-warning-block-if-js-disabled` can show a big warning block if necessary.

### Handling runtime errors (6)

We could totally do a better job of handling global runtime errors, and falling back to no-JS is probably a good idea in that case, too.
However, we would need to do a good job communicating the difference to the user - in this case the messaging shouldn't be "You need to enable JavaScript and that's why this isn't interactive", but rather "We screwed up and that's why this isn't interactive".
5 changes: 3 additions & 2 deletions site/BlogIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SiteHeader } from "./SiteHeader.js"
import { SiteFooter } from "./SiteFooter.js"
import { range, IndexPost } from "@ourworldindata/utils"
import PostCard from "./PostCard/PostCard.js"
import { Html } from "./Html.js"

export const BlogIndexPage = (props: {
posts: IndexPost[]
Expand All @@ -16,7 +17,7 @@ export const BlogIndexPage = (props: {
const pageTitle = "Latest"

return (
<html>
<Html>
<Head
canonicalUrl={
`${baseUrl}/latest` +
Expand Down Expand Up @@ -72,6 +73,6 @@ export const BlogIndexPage = (props: {
</main>
<SiteFooter baseUrl={baseUrl} />
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/ChartsIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { slugify } from "@ourworldindata/utils"
import { ExplorerProgram } from "../explorer/ExplorerProgram.js"
import { BAKED_BASE_URL } from "../settings/serverSettings.js"
import { EXPLORERS_ROUTE_FOLDER } from "../explorer/ExplorerConstants.js"
import { Html } from "./Html.js"

export interface ChartIndexItem {
id: number
Expand Down Expand Up @@ -81,7 +82,7 @@ export const ChartsIndexPage = (props: {
)

return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/charts`}
pageTitle="Charts"
Expand Down Expand Up @@ -167,6 +168,6 @@ export const ChartsIndexPage = (props: {
}}
/>
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/CountriesIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Head } from "./Head.js"
import { SiteHeader } from "./SiteHeader.js"
import { SiteFooter } from "./SiteFooter.js"
import { Country, sortBy } from "@ourworldindata/utils"
import { Html } from "./Html.js"

export const CountriesIndexPage = (props: {
countries: Country[]
Expand All @@ -13,7 +14,7 @@ export const CountriesIndexPage = (props: {
const sortedCountries = sortBy(countries, (country) => country.name)

return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/countries`}
pageTitle="Countries"
Expand Down Expand Up @@ -42,6 +43,6 @@ export const CountriesIndexPage = (props: {
<SiteFooter baseUrl={baseUrl} />
{/* <script>{`window.runChartsIndexPage()`}</script> */}
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/CountryProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SiteHeader } from "./SiteHeader.js"
import { SiteFooter } from "./SiteFooter.js"
import urljoin from "url-join"
import { Country } from "@ourworldindata/utils"
import { Html } from "./Html.js"

export interface CountryProfileIndicator {
name: string
Expand Down Expand Up @@ -35,7 +36,7 @@ export const CountryProfilePage = (props: CountryProfilePageProps) => {
const script = `window.runCountryProfilePage()`

return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/country/${country.slug}`}
pageTitle={`${country.name}`}
Expand Down Expand Up @@ -101,6 +102,6 @@ export const CountryProfilePage = (props: CountryProfilePageProps) => {
dangerouslySetInnerHTML={{ __html: script }}
/>
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/DataInsightsIndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "./DataInsightsIndexPageContent.js"
import { DATA_INSIGHT_ATOM_FEED_PROPS } from "./gdocs/utils.js"
import { DebugProvider } from "./gdocs/DebugContext.js"
import { Html } from "./Html.js"

export interface DataInsightsIndexPageProps {
dataInsights: OwidGdocDataInsightInterface[]
Expand All @@ -25,7 +26,7 @@ export interface DataInsightsIndexPageProps {
export const DataInsightsIndexPage = (props: DataInsightsIndexPageProps) => {
const { baseUrl, isPreviewing } = props
return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/data-insights`}
pageTitle="Daily Data Insights"
Expand Down Expand Up @@ -56,6 +57,6 @@ export const DataInsightsIndexPage = (props: DataInsightsIndexPageProps) => {
}}
/>
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/DataPageV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SiteFooter } from "./SiteFooter.js"
import { SiteHeader } from "./SiteHeader.js"
import { IFrameDetector } from "./IframeDetector.js"
import { DebugProvider } from "./gdocs/DebugContext.js"
import { Html } from "./Html.js"

export const DataPageV2 = (props: {
grapher: GrapherInterface | undefined
Expand Down Expand Up @@ -109,7 +110,7 @@ export const DataPageV2 = (props: {
)

return (
<html>
<Html>
<Head
canonicalUrl={canonicalUrl}
pageTitle={pageTitle}
Expand Down Expand Up @@ -186,6 +187,6 @@ export const DataPageV2 = (props: {
}}
/>
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/DonatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { IMAGES_DIRECTORY, OwidGdocPostInterface } from "@ourworldindata/utils"
import { ArticleBlocks } from "./gdocs/components/ArticleBlocks.js"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js"
import { faArrowDown } from "@fortawesome/free-solid-svg-icons"
import { Html } from "./Html.js"

export const DonatePage = (props: {
baseUrl: string
faqsGdoc: OwidGdocPostInterface
recaptchaKey: string
}) => (
<html>
<Html>
<Head
canonicalUrl={`${props.baseUrl}/donate`}
pageTitle="Donate"
Expand Down Expand Up @@ -97,5 +98,5 @@ export const DonatePage = (props: {
}}
/>
</body>
</html>
</Html>
)
6 changes: 4 additions & 2 deletions site/ExplorerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { IFrameDetector } from "../site/IframeDetector.js"
import { SiteFooter } from "../site/SiteFooter.js"
import { SiteHeader } from "../site/SiteHeader.js"
import { SiteSubnavigation } from "../site/SiteSubnavigation.js"
import { Html } from "./Html.js"

interface ExplorerPageSettings {
program: ExplorerProgram
Expand Down Expand Up @@ -97,7 +98,7 @@ const urlMigrationSpec = ${
window.Explorer.renderSingleExplorerOnExplorerPage(explorerProgram, grapherConfigs, partialGrapherConfigs, urlMigrationSpec);`

return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/${EXPLORERS_ROUTE_FOLDER}/${slug}`}
hideCanonicalUrl // explorers set their canonical url dynamically
Expand All @@ -115,6 +116,7 @@ window.Explorer.renderSingleExplorerOnExplorerPage(explorerProgram, grapherConfi
/>
{subNav}
<main id={ExplorerContainerId}>
<div className="js--show-warning-block-if-js-disabled" />
<LoadingIndicator />
</main>
{wpContent && <ExplorerContent content={wpContent} />}
Expand All @@ -127,6 +129,6 @@ window.Explorer.renderSingleExplorerOnExplorerPage(explorerProgram, grapherConfi
dangerouslySetInnerHTML={{ __html: inlineJs }}
/>
</body>
</html>
</Html>
)
}
5 changes: 3 additions & 2 deletions site/FeedbackPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { Head } from "./Head.js"
import { SiteHeader } from "./SiteHeader.js"
import { SiteFooter } from "./SiteFooter.js"
import { FeedbackForm } from "../site/Feedback.js"
import { Html } from "./Html.js"

export class FeedbackPage extends React.Component<{ baseUrl: string }> {
render() {
const { baseUrl } = this.props
return (
<html>
<Html>
<Head
canonicalUrl={`${baseUrl}/feedback`}
pageTitle="Feedback"
Expand All @@ -23,7 +24,7 @@ export class FeedbackPage extends React.Component<{ baseUrl: string }> {
<SiteFooter hideDonate={true} baseUrl={baseUrl} />
</body>
<script type="module">{`window.runFeedbackPage()`}</script>
</html>
</Html>
)
}
}
19 changes: 9 additions & 10 deletions site/GrapherPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { RelatedArticles } from "./RelatedArticles/RelatedArticles.js"
import { SiteFooter } from "./SiteFooter.js"
import { SiteHeader } from "./SiteHeader.js"
import GrapherImage from "./GrapherImage.js"
import { Html } from "./Html.js"

export const GrapherPage = (props: {
grapher: GrapherInterface
Expand Down Expand Up @@ -74,7 +75,7 @@ window.Grapher.renderSingleGrapherOnGrapherPage(jsonConfig)`
const variableIds = uniq(grapher.dimensions!.map((d) => d.variableId))

return (
<html>
<Html>
<Head
canonicalUrl={canonicalUrl}
pageTitle={pageTitle}
Expand All @@ -85,11 +86,6 @@ window.Grapher.renderSingleGrapherOnGrapherPage(jsonConfig)`
<meta property="og:image:width" content={imageWidth} />
<meta property="og:image:height" content={imageHeight} />
<IFrameDetector />
<noscript>
<style>{`
figure { display: none !important; }
`}</style>
</noscript>
<link rel="preconnect" href={dataApiOrigin} />
{variableIds.flatMap((variableId) =>
[
Expand All @@ -116,18 +112,21 @@ window.Grapher.renderSingleGrapherOnGrapherPage(jsonConfig)`
<body className={GRAPHER_PAGE_BODY_CLASS}>
<SiteHeader baseUrl={baseUrl} />
<main>
<figure data-grapher-src={`/grapher/${grapher.slug}`}>
<figure
className="js--hide-if-js-disabled"
data-grapher-src={`/grapher/${grapher.slug}`}
>
<LoadingIndicator />
</figure>
<noscript id="fallback">
<div className="js--hide-if-js-enabled" id="fallback">
{grapher.slug && (
<GrapherImage
slug={grapher.slug}
alt={grapher.title}
/>
)}
<p>Interactive visualization requires JavaScript</p>
</noscript>
</div>

{((relatedArticles && relatedArticles.length !== 0) ||
(relatedCharts && relatedCharts.length !== 0)) && (
Expand Down Expand Up @@ -170,6 +169,6 @@ window.Grapher.renderSingleGrapherOnGrapherPage(jsonConfig)`
dangerouslySetInnerHTML={{ __html: script }}
/>
</body>
</html>
</Html>
)
}
2 changes: 2 additions & 0 deletions site/Head.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react"
import { viteAssetsForSite } from "./viteUtils.js"
import { GOOGLE_TAG_MANAGER_ID } from "../settings/clientSettings.js"
import { NoJSDetector } from "./NoJSDetector.js"

export const GTMScriptTags = ({ gtmId }: { gtmId: string }) => {
if (!gtmId || /["']/.test(gtmId)) return null
Expand Down Expand Up @@ -99,6 +100,7 @@ export const Head = (props: {
<meta name="twitter:image" content={encodeURI(imageUrl)} />
{stylesheets}
{props.children}
<NoJSDetector baseUrl={baseUrl} />
<GTMScriptTags gtmId={GOOGLE_TAG_MANAGER_ID} />
</head>
)
Expand Down
11 changes: 11 additions & 0 deletions site/Html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import cx from "classnames"
import React, { HtmlHTMLAttributes } from "react"

/**
* Renders a <html> element with the class "js-disabled" to indicate that JavaScript is disabled.
* This is then removed *synchronously* by the client-side JavaScript, once we detect that JavaScript is enabled.
* See the <script> tag in Head.tsx for the client-side JavaScript that removes this class.
*/
export const Html = (props: HtmlHTMLAttributes<Element>) => {
return <html {...props} className={cx("js-disabled", props.className)} />
}
5 changes: 3 additions & 2 deletions site/LongFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { BackToTopic } from "./BackToTopic.js"
import StickyNav from "./blocks/StickyNav.js"
import { CodeSnippet } from "@ourworldindata/components"
import { formatAuthors } from "./clientFormatting.js"
import { Html } from "./Html.js"

export interface PageOverrides {
pageTitle?: string
Expand Down Expand Up @@ -109,7 +110,7 @@ export const LongFormPage = (props: {
note = {${citationCanonicalUrl}}
}`
return (
<html>
<Html>
<Head
pageTitle={pageTitleSEO}
pageDesc={pageDesc}
Expand Down Expand Up @@ -426,6 +427,6 @@ export const LongFormPage = (props: {
}}
/>
</body>
</html>
</Html>
)
}
Loading