feat(core): rework client modules lifecycles, officially make API public (#6732)

This commit is contained in:
Joshua Chen 2022-04-29 21:11:20 +08:00 committed by GitHub
parent 2429bfbd59
commit ae788c536f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 259 additions and 126 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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",

View file

@ -138,6 +138,7 @@ export default function docusaurusThemeClassic(
require.resolve(getInfimaCSSFile(direction)),
'./prism-include-languages',
'./admonitions.css',
'./nprogress',
];
if (customCss) {

View file

@ -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);
}

View file

@ -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;

View file

@ -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. */

View file

@ -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",

View file

@ -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 {
<SiteMetadataDefaults />
<SiteMetadata />
<BaseUrlIssueBanner />
<PendingNavigation
location={normalizeLocation(location)}
delay={200}>
<PendingNavigation location={normalizeLocation(location)}>
{routeElement}
</PendingNavigation>
</Root>

View file

@ -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<K extends keyof ClientModule>(
lifecycleAction: K,
...args: Parameters<NonNullable<ClientModule[K]>>
): () => void {
const callbacks = clientModules.map((clientModule) => {
const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ??
clientModule[lifecycleAction]) as
| ((
...a: Parameters<NonNullable<ClientModule[K]>>
) => (() => 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;

View file

@ -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<Props, State> {
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<Props, State> {
// 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 <Route> to trick all descendants into rendering the old
// location.
return <Route location={location} render={() => children} />;
return (
<ClientLifecyclesDispatcher
previousLocation={this.previousLocation}
location={location}>
<Route location={location} render={() => children} />
</ClientLifecyclesDispatcher>
);
}
}

View file

@ -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<K extends keyof ClientModule>(
lifecycleAction: K,
args: Parameters<NonNullable<ClientModule[K]>>,
) {
clientModules.forEach((clientModule) => {
const lifecycleFunction = (clientModule?.default?.[lifecycleAction] ??
clientModule[lifecycleAction]) as
| ((...a: Parameters<NonNullable<ClientModule[K]>>) => void)
| undefined;
lifecycleFunction?.(...args);
});
}
const clientLifecyclesDispatchers: Required<ClientModule> = {
onRouteUpdate(...args) {
dispatchLifecycleAction('onRouteUpdate', args);
},
onRouteUpdateDelayed(...args) {
dispatchLifecycleAction('onRouteUpdateDelayed', args);
},
};
export default clientLifecyclesDispatchers;

View file

@ -215,6 +215,7 @@ preconfigured
preconnect
prefetch
prefetching
preloads
prepended
preprocessors
prerendered

View file

@ -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);
}
}

View file

@ -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
}
```
<!-- TODO client module lifecycles -->
<!-- https://github.com/facebook/docusaurus/issues/3399 -->
### 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.
:::

View file

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