-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
perf: lazy-load Algolia DocSearch to reduce initial bundle size and eliminate render-blocking CSS #5673
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
base: master
Are you sure you want to change the base?
perf: lazy-load Algolia DocSearch to reduce initial bundle size and eliminate render-blocking CSS #5673
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,30 @@ | ||
| /* eslint-disable no-underscore-dangle */ | ||
| import type { DocSearchHit, InternalDocSearchHit, StoredDocSearchHit } from '@docsearch/react'; | ||
| import { DocSearchModal } from '@docsearch/react'; | ||
| import type { | ||
| DocSearchHit, | ||
| InternalDocSearchHit, | ||
| StoredDocSearchHit, | ||
| } from '@docsearch/react'; | ||
| import clsx from 'clsx'; | ||
| import dynamic from 'next/dynamic'; | ||
| import Head from 'next/head'; | ||
| import Link from 'next/link'; | ||
| import { useRouter } from 'next/router'; | ||
| import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; | ||
| import React, { | ||
| createContext, | ||
| useCallback, | ||
| useContext, | ||
| useEffect, | ||
| useRef, | ||
| useState, | ||
| } from 'react'; | ||
| import { createPortal } from 'react-dom'; | ||
|
|
||
| const DocSearchModal = dynamic( | ||
| () => | ||
| import('@docsearch/react').then((mod) => ({ default: mod.DocSearchModal })), | ||
| { ssr: false }, | ||
| ); | ||
|
|
||
| export const INDEX_NAME = 'asyncapi'; | ||
| export const DOCS_INDEX_NAME = 'asyncapi-docs'; | ||
| const APP_ID = 'Z621OGRI9Y'; | ||
|
|
@@ -46,8 +63,17 @@ | |
| onInput?: (e: React.KeyboardEvent) => void; | ||
| } | ||
|
|
||
| type ISearchButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & { | ||
| children?: React.ReactNode | (({ actionKey }: { actionKey: { shortKey: string; key: string } }) => React.ReactNode); | ||
| type ISearchButtonProps = Omit< | ||
| React.ButtonHTMLAttributes<HTMLButtonElement>, | ||
| 'children' | ||
| > & { | ||
| children?: | ||
| | React.ReactNode | ||
| | (({ | ||
| actionKey, | ||
| }: { | ||
| actionKey: { shortKey: string; key: string }; | ||
| }) => React.ReactNode); | ||
| indexName?: string; | ||
| }; | ||
|
|
||
|
|
@@ -64,7 +90,8 @@ | |
|
|
||
| a.href = item.url; | ||
|
|
||
| const hash = a.hash === '#content-wrapper' || a.hash === '#header' ? '' : a.hash; | ||
| const hash = | ||
| a.hash === '#content-wrapper' || a.hash === '#header' ? '' : a.hash; | ||
|
|
||
| if (item.hierarchy?.lvl0) { | ||
| // eslint-disable-next-line no-param-reassign | ||
|
|
@@ -75,10 +102,15 @@ | |
| ...item, | ||
| url: `${a.pathname}${hash}`, | ||
| __is_result: () => true, | ||
| __is_parent: () => item.type === 'lvl1' && items.length > 1 && index === 0, | ||
| __is_child: () => item.type !== 'lvl1' && items.length > 1 && items[0].type === 'lvl1' && index !== 0, | ||
| __is_parent: () => | ||
| item.type === 'lvl1' && items.length > 1 && index === 0, | ||
| __is_child: () => | ||
| item.type !== 'lvl1' && | ||
| items.length > 1 && | ||
| items[0].type === 'lvl1' && | ||
| index !== 0, | ||
| __is_first: () => index === 1, | ||
| __is_last: () => index === items.length - 1 && index !== 0 | ||
| __is_last: () => index === items.length - 1 && index !== 0, | ||
| }; | ||
| }); | ||
| } | ||
|
|
@@ -97,7 +129,7 @@ | |
| 'DocSearch-Hit--Parent': hit.__is_parent?.(), | ||
| 'DocSearch-Hit--FirstChild': hit.__is_first?.(), | ||
| 'DocSearch-Hit--LastChild': hit.__is_last?.(), | ||
| 'DocSearch-Hit--Child': hit.__is_child?.() | ||
| 'DocSearch-Hit--Child': hit.__is_child?.(), | ||
| })} | ||
| > | ||
| {children} | ||
|
|
@@ -114,14 +146,18 @@ | |
| const router = useRouter(); | ||
|
|
||
| return createPortal( | ||
| <div className='dark:text-dark-text'> | ||
| <div className="dark:text-dark-text"> | ||
| <DocSearchModal | ||
| initialQuery={initialQuery} | ||
| initialScrollY={window.scrollY} | ||
| searchParameters={{ | ||
| distinct: 1 | ||
| distinct: 1, | ||
| }} | ||
| placeholder={indexName === DOCS_INDEX_NAME ? 'Search documentation' : 'Search resources'} | ||
| placeholder={ | ||
| indexName === DOCS_INDEX_NAME | ||
| ? 'Search documentation' | ||
| : 'Search resources' | ||
| } | ||
| onClose={onClose} | ||
| indexName={indexName} | ||
| apiKey={API_KEY} | ||
|
|
@@ -130,7 +166,7 @@ | |
| navigate({ itemUrl }) { | ||
| onClose(); | ||
| router.push(itemUrl); | ||
| } | ||
| }, | ||
| }} | ||
| hitComponent={Hit} | ||
| transformItems={transformItems} | ||
|
|
@@ -139,7 +175,7 @@ | |
| }} | ||
| /> | ||
| </div>, | ||
| document.body | ||
| document.body, | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -153,7 +189,10 @@ | |
| const { tagName } = element as HTMLElement; | ||
|
|
||
| return ( | ||
| (element as HTMLElement).isContentEditable || tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA' | ||
| (element as HTMLElement).isContentEditable || | ||
| tagName === 'INPUT' || | ||
| tagName === 'SELECT' || | ||
| tagName === 'TEXTAREA' | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -163,22 +202,24 @@ | |
| */ | ||
| function getActionKey() { | ||
| if (typeof navigator !== 'undefined') { | ||
| if (/(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform)) { | ||
| if ( | ||
| /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent || navigator.platform) | ||
| ) { | ||
| return { | ||
| shortKey: '⌘', | ||
| key: 'Command' | ||
| key: 'Command', | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| shortKey: 'Ctrl', | ||
| key: 'Control' | ||
| key: 'Control', | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| shortKey: 'Ctrl', | ||
| key: 'Control' | ||
| key: 'Control', | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -187,7 +228,11 @@ | |
| * @description The hook used for the Algolia search keyboard events | ||
| * @param {IUseDocSearchKeyboardEvents} props - The props of the useDocSearchKeyboardEvents hook | ||
| */ | ||
| function useDocSearchKeyboardEvents({ isOpen, onOpen, onClose }: IUseDocSearchKeyboardEvents) { | ||
| function useDocSearchKeyboardEvents({ | ||
| isOpen, | ||
| onOpen, | ||
| onClose, | ||
| }: IUseDocSearchKeyboardEvents) { | ||
| useEffect(() => { | ||
| /** | ||
| * @description The function used to handle the keyboard event. | ||
|
|
@@ -213,7 +258,9 @@ | |
| if (typeof document !== 'undefined') { | ||
| const loc = document.location; | ||
|
|
||
| indexName = loc.pathname.startsWith('/docs') ? DOCS_INDEX_NAME : INDEX_NAME; | ||
| indexName = loc.pathname.startsWith('/docs') | ||
| ? DOCS_INDEX_NAME | ||
| : INDEX_NAME; | ||
| } | ||
| onOpen(indexName); | ||
| } | ||
|
|
@@ -233,7 +280,11 @@ | |
| * @description The Algolia search component used for searching the website | ||
| * @param {React.ReactNode} children - The content of the page | ||
| */ | ||
| export default function AlgoliaSearch({ children }: { children: React.ReactNode }) { | ||
| export default function AlgoliaSearch({ | ||
| children, | ||
| }: { | ||
| children: React.ReactNode; | ||
| }) { | ||
|
Check warning on line 287 in components/AlgoliaSearch.tsx
|
||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [indexName, setIndexName] = useState<string>(INDEX_NAME); | ||
| const [initialQuery, setInitialQuery] = useState<string>(); | ||
|
|
@@ -245,7 +296,7 @@ | |
| } | ||
| setIsOpen(true); | ||
| }, | ||
| [setIsOpen, setIndexName] | ||
| [setIsOpen, setIndexName], | ||
| ); | ||
|
|
||
| const onClose = useCallback(() => { | ||
|
|
@@ -257,23 +308,51 @@ | |
| setIsOpen(true); | ||
| setInitialQuery(e.key); | ||
| }, | ||
| [setIsOpen, setInitialQuery] | ||
| [setIsOpen, setInitialQuery], | ||
| ); | ||
|
|
||
| useDocSearchKeyboardEvents({ | ||
| isOpen, | ||
| onOpen, | ||
| onClose, | ||
| onInput | ||
| onInput, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (isOpen) { | ||
| // Load Algolia CSS on demand when modal first opens | ||
| const linkId = 'docsearch-css'; | ||
|
|
||
| if (!document.getElementById(linkId)) { | ||
| const link = document.createElement('link'); | ||
|
|
||
| link.id = linkId; | ||
| link.rel = 'stylesheet'; | ||
| link.href = 'https://cdn.jsdelivr.net/npm/@docsearch/css@3'; | ||
| document.head.appendChild(link); | ||
| } | ||
| } | ||
| }, [isOpen]); | ||
|
|
||
| return ( | ||
| <> | ||
| <Head> | ||
| <link rel='preconnect' href={`https://${APP_ID}-dsn.algolia.net`} crossOrigin='anonymous' /> | ||
| <link | ||
| rel="preconnect" | ||
| href={`https://${APP_ID}-dsn.algolia.net`} | ||
| crossOrigin="anonymous" | ||
| /> | ||
| </Head> | ||
| <SearchContext.Provider value={{ isOpen, onOpen, onClose, onInput }}>{children}</SearchContext.Provider> | ||
| {isOpen && <AlgoliaModal initialQuery={initialQuery ?? ''} onClose={onClose} indexName={indexName} />} | ||
| <SearchContext.Provider value={{ isOpen, onOpen, onClose, onInput }}> | ||
|
Check warning on line 346 in components/AlgoliaSearch.tsx
|
||
| {children} | ||
| </SearchContext.Provider> | ||
| {isOpen && ( | ||
| <AlgoliaModal | ||
| initialQuery={initialQuery ?? ''} | ||
| onClose={onClose} | ||
| indexName={indexName} | ||
| /> | ||
| )} | ||
|
Comment on lines
+321
to
+355
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Wait for the DocSearch stylesheet before mounting the modal. When 🧰 Tools🪛 ESLint[error] 340-344: Replace (prettier/prettier) [error] 346-348: Replace (prettier/prettier) [error] 349-355: Replace (prettier/prettier) 🪛 GitHub Check: SonarCloud Code Analysis[warning] 346-346: The object passed as the value prop to the Context provider changes every render. To fix this consider wrapping it in a useMemo hook. 🤖 Prompt for AI Agents |
||
| </> | ||
| ); | ||
| } | ||
|
|
@@ -283,7 +362,11 @@ | |
| * @description The search button component used for opening the Algolia search | ||
| * @param {ISearchButtonProps} props - The props of the search button | ||
| */ | ||
| export function SearchButton({ children, indexName = INDEX_NAME, ...props }: ISearchButtonProps) { | ||
| export function SearchButton({ | ||
| children, | ||
| indexName = INDEX_NAME, | ||
| ...props | ||
| }: ISearchButtonProps) { | ||
| const { onOpen, onInput } = useContext(SearchContext); | ||
| const searchButtonRef = useRef<HTMLButtonElement>(null); | ||
| const actionKey = getActionKey(); | ||
|
|
@@ -296,7 +379,11 @@ | |
| * @returns {void} | ||
| */ | ||
| function onKeyDown(event: KeyboardEvent) { | ||
| if (searchButtonRef && searchButtonRef.current === document.activeElement && onInput) { | ||
| if ( | ||
| searchButtonRef && | ||
| searchButtonRef.current === document.activeElement && | ||
|
Check warning on line 384 in components/AlgoliaSearch.tsx
|
||
| onInput | ||
| ) { | ||
| if (/[a-zA-Z0-9]/.test(event.key)) { | ||
| onInput(event as unknown as React.KeyboardEvent); | ||
| } | ||
|
|
@@ -310,17 +397,18 @@ | |
| }; | ||
| }, [onInput, searchButtonRef]); | ||
|
|
||
| const childContent = typeof children === 'function' ? children({ actionKey }) : children; | ||
| const childContent = | ||
| typeof children === 'function' ? children({ actionKey }) : children; | ||
|
|
||
| return ( | ||
| <button | ||
| type='button' | ||
| type="button" | ||
| ref={searchButtonRef} | ||
| onClick={() => { | ||
| onOpen(indexName); | ||
| }} | ||
| {...props} | ||
| data-testid='Search-Button' | ||
| data-testid="Search-Button" | ||
| > | ||
| {childContent} | ||
| </button> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Run the configured formatter before merge.
prettier/prettierreports errors in this import and dynamic-import block, with the same issue in many later changed ranges. Formatcomponents/AlgoliaSearch.tsxwith the repository configuration so lint can pass.🧰 Tools
🪛 ESLint
[error] 2-6: Replace
⏎··DocSearchHit,⏎··InternalDocSearchHit,⏎··StoredDocSearchHit,⏎with·DocSearchHit,·InternalDocSearchHit,·StoredDocSearchHit·(prettier/prettier)
[error] 12-19: Replace
⏎··createContext,⏎··useCallback,⏎··useContext,⏎··useEffect,⏎··useRef,⏎··useState,⏎with·createContext,·useCallback,·useContext,·useEffect,·useRef,·useState·(prettier/prettier)
[error] 22-24: Replace
⏎··()·=>⏎····import('@docsearch/react').then((mod)·=>·({·default:·mod.DocSearchModal·})),with()·=>·import('@docsearch/react').then((mod)·=>·({·default:·mod.DocSearchModal·})),·{(prettier/prettier)
[error] 25-25: Replace
·{·ssr:·false·},with·ssr:·false(prettier/prettier)
[error] 26-26: Insert
}(prettier/prettier)
🤖 Prompt for AI Agents
Source: Linters/SAST tools