diff --git a/packages/docusaurus-plugin-google-analytics/src/analytics.ts b/packages/docusaurus-plugin-google-analytics/src/analytics.ts index e0196d77fbf1..82e957701c3a 100644 --- a/packages/docusaurus-plugin-google-analytics/src/analytics.ts +++ b/packages/docusaurus-plugin-google-analytics/src/analytics.ts @@ -5,21 +5,19 @@ * LICENSE file in the root directory of this source tree. */ -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import type {ClientModule} from '@docusaurus/types'; -export default (function analyticsModule() { - if (!ExecutionEnvironment.canUseDOM) { - return null; - } - - return { - onRouteUpdate({location}: {location: Location}) { +const clientModule: ClientModule = { + onRouteDidUpdate({location, previousLocation}) { + if (previousLocation && location.pathname !== previousLocation.pathname) { // Set page so that subsequent hits on this page are attributed // to this page. This is recommended for Single-page Applications. window.ga('set', 'page', location.pathname); // Always refer to the variable on window in-case it gets // overridden elsewhere. window.ga('send', 'pageview'); - }, - }; -})(); + } + }, +}; + +export default clientModule; diff --git a/packages/docusaurus-plugin-google-gtag/src/gtag.ts b/packages/docusaurus-plugin-google-gtag/src/gtag.ts index 77024f7bb5ed..0beaac7f0a07 100644 --- a/packages/docusaurus-plugin-google-gtag/src/gtag.ts +++ b/packages/docusaurus-plugin-google-gtag/src/gtag.ts @@ -5,20 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import globalData from '@generated/globalData'; import type {PluginOptions} from '@docusaurus/plugin-google-gtag'; +import type {ClientModule} from '@docusaurus/types'; -export default (function gtagModule() { - if (!ExecutionEnvironment.canUseDOM) { - return null; - } +const {trackingID} = globalData['docusaurus-plugin-google-gtag']! + .default as PluginOptions; - const {trackingID} = globalData['docusaurus-plugin-google-gtag']! - .default as PluginOptions; - - return { - onRouteUpdate({location}: {location: Location}) { +const clientModule: ClientModule = { + onRouteDidUpdate({location, previousLocation}) { + if (previousLocation && location.pathname !== previousLocation.pathname) { // Always refer to the variable on window in case it gets overridden // elsewhere. window.gtag('config', trackingID, { @@ -27,9 +23,11 @@ export default (function gtagModule() { }); window.gtag('event', 'page_view', { page_title: document.title, - page_location: location.href, + page_location: window.location.href, page_path: location.pathname, }); - }, - }; -})(); + } + }, +}; + +export default clientModule; diff --git a/packages/docusaurus-theme-classic/package.json b/packages/docusaurus-theme-classic/package.json index a41328ecb127..bb63b12fd689 100644 --- a/packages/docusaurus-theme-classic/package.json +++ b/packages/docusaurus-theme-classic/package.json @@ -35,6 +35,7 @@ "copy-text-to-clipboard": "^3.0.1", "infima": "0.2.0-alpha.38", "lodash": "^4.17.21", + "nprogress": "^0.2.0", "postcss": "^8.4.12", "prism-react-renderer": "^1.3.1", "prismjs": "^1.28.0", @@ -48,6 +49,7 @@ "@docusaurus/module-type-aliases": "2.0.0-beta.18", "@docusaurus/types": "2.0.0-beta.18", "@types/mdx-js__react": "^1.5.5", + "@types/nprogress": "^0.2.0", "@types/prismjs": "^1.26.0", "@types/rtlcss": "^3.1.4", "cross-env": "^7.0.3", diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 8aa1c7b805ad..d24c0b3f87e3 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -138,6 +138,7 @@ export default function docusaurusThemeClassic( require.resolve(getInfimaCSSFile(direction)), './prism-include-languages', './admonitions.css', + './nprogress', ]; if (customCss) { diff --git a/packages/docusaurus/src/client/nprogress.css b/packages/docusaurus-theme-classic/src/nprogress.css similarity index 73% rename from packages/docusaurus/src/client/nprogress.css rename to packages/docusaurus-theme-classic/src/nprogress.css index a6a0ffbf1cd6..5e369a9c8b09 100644 --- a/packages/docusaurus/src/client/nprogress.css +++ b/packages/docusaurus-theme-classic/src/nprogress.css @@ -11,12 +11,16 @@ * https://github.com/rstacruz/nprogress/blob/master/nprogress.css */ +:root { + --docusaurus-progress-bar-color: var(--ifm-color-primary); +} + #nprogress { pointer-events: none; } #nprogress .bar { - background: #29d; + background: var(--docusaurus-progress-bar-color); position: fixed; z-index: 1031; top: 0; @@ -30,7 +34,8 @@ right: 0; width: 100px; height: 100%; - box-shadow: 0 0 10px #29d, 0 0 5px #29d; + box-shadow: 0 0 10px var(--docusaurus-progress-bar-color), + 0 0 5px var(--docusaurus-progress-bar-color); opacity: 1; transform: rotate(3deg) translate(0, -4px); } diff --git a/packages/docusaurus-theme-classic/src/nprogress.ts b/packages/docusaurus-theme-classic/src/nprogress.ts new file mode 100644 index 000000000000..7e5103b98ef6 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/nprogress.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import nprogress from 'nprogress'; +import './nprogress.css'; +import type {ClientModule} from '@docusaurus/types'; + +nprogress.configure({showSpinner: false}); + +const delay = 200; + +const clientModule: ClientModule = { + onRouteUpdate({location, previousLocation}) { + if (previousLocation && location.pathname !== previousLocation.pathname) { + const progressBarTimeout = window.setTimeout(() => { + nprogress.start(); + }, delay); + return () => window.clearTimeout(progressBarTimeout); + } + return undefined; + }, + onRouteDidUpdate() { + nprogress.done(); + }, +}; + +export default clientModule; diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index a3663572b9e0..a4c187396207 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -601,11 +601,14 @@ export type TOCItem = { }; export type ClientModule = { + onRouteDidUpdate?: (args: { + previousLocation: Location | null; + location: Location; + }) => (() => void) | void; onRouteUpdate?: (args: { previousLocation: Location | null; location: Location; - }) => void; - onRouteUpdateDelayed?: (args: {location: Location}) => void; + }) => (() => void) | void; }; /** What the user configures. */ diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 7102beef0b66..eba4103a4950 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -77,7 +77,6 @@ "leven": "^3.1.0", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.6.0", - "nprogress": "^0.2.0", "postcss": "^8.4.12", "postcss-loader": "^6.2.1", "prompts": "^2.4.2", @@ -108,7 +107,6 @@ "@docusaurus/module-type-aliases": "2.0.0-beta.18", "@docusaurus/types": "2.0.0-beta.18", "@types/detect-port": "^1.3.2", - "@types/nprogress": "^0.2.0", "@types/react-dom": "^18.0.2", "@types/react-router-config": "^5.0.6", "@types/rtl-detect": "^1.0.0", diff --git a/packages/docusaurus/src/client/App.tsx b/packages/docusaurus/src/client/App.tsx index ca367d6bce3f..c709c2e8515f 100644 --- a/packages/docusaurus/src/client/App.tsx +++ b/packages/docusaurus/src/client/App.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import '@generated/client-modules'; import routes from '@generated/routes'; import {useLocation} from '@docusaurus/router'; @@ -19,8 +20,6 @@ import SiteMetadataDefaults from './SiteMetadataDefaults'; import Root from '@theme/Root'; import SiteMetadata from '@theme/SiteMetadata'; -import './clientLifecyclesDispatcher'; - // TODO, quick fix for CSS insertion order import ErrorBoundary from '@docusaurus/ErrorBoundary'; import Error from '@theme/Error'; @@ -36,9 +35,7 @@ export default function App(): JSX.Element { - + {routeElement} diff --git a/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx new file mode 100644 index 000000000000..7c797af092d7 --- /dev/null +++ b/packages/docusaurus/src/client/ClientLifecyclesDispatcher.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {useLayoutEffect, type ReactElement} from 'react'; +import clientModules from '@generated/client-modules'; +import type {ClientModule} from '@docusaurus/types'; +import type {Location} from 'history'; + +export function dispatchLifecycleAction( + lifecycleAction: K, + ...args: Parameters> +): () => void { + const callbacks = clientModules.map((clientModule) => { + const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ?? + clientModule[lifecycleAction]) as + | (( + ...a: Parameters> + ) => (() => void) | void) + | undefined; + + return lifecycleFunction?.(...args); + }); + return () => callbacks.forEach((cb) => cb?.()); +} + +function ClientLifecyclesDispatcher({ + children, + location, + previousLocation, +}: { + children: ReactElement; + location: Location; + previousLocation: Location | null; +}): JSX.Element { + useLayoutEffect(() => { + if (previousLocation !== location) { + const {hash} = location; + if (!hash) { + window.scrollTo(0, 0); + } else { + const id = decodeURIComponent(hash.substring(1)); + const element = document.getElementById(id); + element?.scrollIntoView(); + } + dispatchLifecycleAction('onRouteDidUpdate', {previousLocation, location}); + } + }, [previousLocation, location]); + return children; +} + +export default ClientLifecyclesDispatcher; diff --git a/packages/docusaurus/src/client/PendingNavigation.tsx b/packages/docusaurus/src/client/PendingNavigation.tsx index 5ebe9217488f..a60825cc5c7e 100644 --- a/packages/docusaurus/src/client/PendingNavigation.tsx +++ b/packages/docusaurus/src/client/PendingNavigation.tsx @@ -7,18 +7,14 @@ import React from 'react'; import {Route} from 'react-router-dom'; -import nprogress from 'nprogress'; - -import clientLifecyclesDispatcher from './clientLifecyclesDispatcher'; +import ClientLifecyclesDispatcher, { + dispatchLifecycleAction, +} from './ClientLifecyclesDispatcher'; +import ExecutionEnvironment from './exports/ExecutionEnvironment'; import preload from './preload'; import type {Location} from 'history'; -import './nprogress.css'; - -nprogress.configure({showSpinner: false}); - type Props = { - readonly delay: number; readonly location: Location; readonly children: JSX.Element; }; @@ -28,14 +24,19 @@ type State = { class PendingNavigation extends React.Component { private previousLocation: Location | null; - private progressBarTimeout: number | null; + private routeUpdateCleanupCb: () => void; constructor(props: Props) { super(props); // previousLocation doesn't affect rendering, hence not stored in state. this.previousLocation = null; - this.progressBarTimeout = null; + this.routeUpdateCleanupCb = ExecutionEnvironment.canUseDOM + ? dispatchLifecycleAction('onRouteUpdate', { + previousLocation: null, + location: this.props.location, + })! + : () => {}; this.state = { nextRouteHasLoaded: true, }; @@ -56,56 +57,32 @@ class PendingNavigation extends React.Component { // Save the location first. this.previousLocation = this.props.location; this.setState({nextRouteHasLoaded: false}); - this.startProgressBar(); + this.routeUpdateCleanupCb = dispatchLifecycleAction('onRouteUpdate', { + previousLocation: this.previousLocation, + location: nextLocation, + })!; // Load data while the old screen remains. preload(nextLocation.pathname) .then(() => { - clientLifecyclesDispatcher.onRouteUpdate({ - previousLocation: this.previousLocation, - location: nextLocation, - }); - this.setState({nextRouteHasLoaded: true}, this.stopProgressBar); - const {hash} = nextLocation; - if (!hash) { - window.scrollTo(0, 0); - } else { - const id = decodeURIComponent(hash.substring(1)); - const element = document.getElementById(id); - element?.scrollIntoView(); - } + this.routeUpdateCleanupCb?.(); + this.setState({nextRouteHasLoaded: true}); }) .catch((e) => console.warn(e)); return false; } - private clearProgressBarTimeout() { - if (this.progressBarTimeout) { - window.clearTimeout(this.progressBarTimeout); - this.progressBarTimeout = null; - } - } - - private startProgressBar() { - this.clearProgressBarTimeout(); - this.progressBarTimeout = window.setTimeout(() => { - clientLifecyclesDispatcher.onRouteUpdateDelayed({ - location: this.props.location, - }); - nprogress.start(); - }, this.props.delay); - } - - private stopProgressBar() { - this.clearProgressBarTimeout(); - nprogress.done(); - } - override render(): JSX.Element { const {children, location} = this.props; // Use a controlled to trick all descendants into rendering the old // location. - return children} />; + return ( + + children} /> + + ); } } diff --git a/packages/docusaurus/src/client/clientLifecyclesDispatcher.ts b/packages/docusaurus/src/client/clientLifecyclesDispatcher.ts deleted file mode 100644 index 57982c394313..000000000000 --- a/packages/docusaurus/src/client/clientLifecyclesDispatcher.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -import clientModules from '@generated/client-modules'; -import type {ClientModule} from '@docusaurus/types'; - -function dispatchLifecycleAction( - lifecycleAction: K, - args: Parameters>, -) { - clientModules.forEach((clientModule) => { - const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ?? - clientModule[lifecycleAction]) as - | ((...a: Parameters>) => void) - | undefined; - - lifecycleFunction?.(...args); - }); -} - -const clientLifecyclesDispatchers: Required = { - onRouteUpdate(...args) { - dispatchLifecycleAction('onRouteUpdate', args); - }, - onRouteUpdateDelayed(...args) { - dispatchLifecycleAction('onRouteUpdateDelayed', args); - }, -}; - -export default clientLifecyclesDispatchers; diff --git a/project-words.txt b/project-words.txt index 5bd78cccc06e..9608214fdde2 100644 --- a/project-words.txt +++ b/project-words.txt @@ -215,6 +215,7 @@ preconfigured preconnect prefetch prefetching +preloads prepended preprocessors prerendered diff --git a/website/_dogfooding/clientModuleExample.ts b/website/_dogfooding/clientModuleExample.ts index e730f3f3a74e..01b2d8250631 100644 --- a/website/_dogfooding/clientModuleExample.ts +++ b/website/_dogfooding/clientModuleExample.ts @@ -5,13 +5,48 @@ * LICENSE file in the root directory of this source tree. */ -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import siteConfig from '@generated/docusaurus.config'; +import type {Location} from 'history'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function onRouteUpdate({location}: {location: Location}): void { - // console.log('onRouteUpdate', {location}); +function logPage( + event: string, + location: Location, + previousLocation: Location | null, +): void { + console.log(`${event} +Previous location: ${previousLocation?.pathname} +Current location: ${location.pathname} +Current heading: ${document.getElementsByTagName('h1')[0]?.innerText}`); } -if (ExecutionEnvironment.canUseDOM) { - // console.log('client module example log'); +export function onRouteUpdate({ + location, + previousLocation, +}: { + location: Location; + previousLocation: Location | null; +}): (() => void) | void { + if ( + process.env.NODE_ENV === 'development' || + siteConfig.customFields!.isDeployPreview + ) { + logPage('onRouteUpdate', location, previousLocation); + return () => logPage('onRouteUpdate cleanup', location, previousLocation); + } + return undefined; +} + +export function onRouteDidUpdate({ + location, + previousLocation, +}: { + location: Location; + previousLocation: Location | null; +}): void { + if ( + process.env.NODE_ENV === 'development' || + siteConfig.customFields!.isDeployPreview + ) { + logPage('onRouteDidUpdate', location, previousLocation); + } } diff --git a/website/docs/advanced/client.md b/website/docs/advanced/client.md index ef4c4d220c60..fe09876169e0 100644 --- a/website/docs/advanced/client.md +++ b/website/docs/advanced/client.md @@ -86,7 +86,7 @@ Client modules are part of your site's bundle, just like theme components. Howev These modules are imported globally before React even renders the initial UI. -```js title="App.tsx" +```js title="@docusaurus/core/App.tsx" // How it works under the hood import '@generated/client-modules'; ``` @@ -117,5 +117,70 @@ CSS stylesheets imported as client modules are [global](../styling-layout.md#glo } ``` - - +### Client module lifecycles {#client-module-lifecycles} + +Besides introducing side-effects, client modules can optionally export two lifecycle functions: `onRouteUpdate` and `onRouteDidUpdate`. + +Because Docusaurus builds a single-page application, `script` tags will only be executed the first time the page loads, but will not re-execute on page transitions. These lifecycles are useful if you have some imperative JS logic that should execute every time a new page has loaded, e.g., to manipulate DOM elements, to send analytics data, etc. + +For every route transition, there will be several important timings: + +1. The user clicks a link, which causes the router to change its current location. +2. Docusaurus preloads the next route's assets, while keeping displaying the current page's content. +3. The next route's assets have loaded. +4. The new location's route component gets rendered to DOM. + +`onRouteUpdate` will be called at event (2), and `onRouteDidUpdate` will be called at (4). They both receive the current location and the previous location (which can be `null`, if this is the first screen). + +`onRouteUpdate` can optionally return a "cleanup" callback, which will be called at (3). For example, if you want to display a progress bar, you can start a timeout in `onRouteUpdate`, and clear the timeout in the callback. (The classic theme already provides an `nprogress` integration this way.) + +Note that the new page's DOM is only available during event (4). If you need to manipulate the new page's DOM, you'll likely want to use `onRouteDidUpdate`, which will be fired as soon as the DOM on the new page has mounted. + +```js title="myClientModule.js" +import type {Location} from 'history'; + +export function onRouteDidUpdate({location, previousLocation}) { + // Don't execute if we are still on the same page; the lifecycle may be fired + // because the hash changes (e.g. when navigating between headings) + if (location.pathname !== previousLocation?.pathname) { + const title = document.getElementsByTagName('h1')[0]; + if (title) { + title.innerText += '❤️'; + } + } +} + +export function onRouteUpdate({location, previousLocation}) { + if (location.pathname !== previousLocation?.pathname) { + const progressBarTimeout = window.setTimeout(() => { + nprogress.start(); + }, delay); + return () => window.clearTimeout(progressBarTimeout); + } + return undefined; +} +``` + +Or, if you are using TypeScript and you want to leverage contextual typing: + +```ts title="myClientModule.js" +import type {ClientModule} from '@docusaurus/types'; + +const module: ClientModule = { + onRouteUpdate({location, previousLocation}) { + // ... + }, + onRouteDidUpdate({location, previousLocation}) { + // ... + }, +}; +export default module; +``` + +Both lifecycles will fire on first render, but they will not fire on server-side, so you can safely access browser globals in them. + +:::tip Prefer using React + +Client module lifecycles are purely imperative, and you can't use React hooks or access React contexts within them. If your operations are state-driven or involve complicated DOM manipulations, you should consider [swizzling components](../swizzling.md) instead. + +::: diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index df57e09ac88c..a5814f17e639 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -113,6 +113,7 @@ const config = { onBrokenMarkdownLinks: 'warn', favicon: 'img/docusaurus.ico', customFields: { + isDeployPreview, description: 'An optimized site generator in React. Docusaurus helps you to move fast and write content. Build documentation websites, blogs, marketing pages, and more.', },