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.',
},