From 95a53527467e4bf0156ec18649cb1fe8d372a40b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 May 2022 15:14:08 +0200 Subject: [PATCH] fix(techdocs): prevent content re-renders Signed-off-by: Camila Belo --- plugins/techdocs-react/api-report.md | 20 ++ plugins/techdocs-react/package.json | 1 + plugins/techdocs-react/src/component.test.tsx | 113 ++++++++ plugins/techdocs-react/src/component.tsx | 258 ++++++++++++++++++ plugins/techdocs-react/src/index.ts | 16 +- .../src/setupTests.ts} | 20 +- .../TechDocsReaderPageContent.test.tsx | 22 -- .../TechDocsReaderPageContent.tsx | 91 +----- .../TechDocsReaderPageContentAddons.tsx | 77 ++++++ .../TechDocsReaderPageContent/dom.tsx | 170 ++++++------ .../components/TechDocsStateIndicator.tsx | 10 +- .../reader/transformers/onCssReady.test.ts | 31 +-- .../src/reader/transformers/onCssReady.ts | 36 +-- plugins/techdocs/src/test-utils/index.ts | 1 - plugins/techdocs/src/test-utils/shadowDom.ts | 4 + 15 files changed, 602 insertions(+), 268 deletions(-) create mode 100644 plugins/techdocs-react/src/component.test.tsx create mode 100644 plugins/techdocs-react/src/component.tsx rename plugins/{techdocs/src/test-utils/stylesheets.ts => techdocs-react/src/setupTests.ts} (54%) create mode 100644 plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons.tsx diff --git a/plugins/techdocs-react/api-report.md b/plugins/techdocs-react/api-report.md index b3ee9168a6..336481e922 100644 --- a/plugins/techdocs-react/api-report.md +++ b/plugins/techdocs-react/api-report.md @@ -10,6 +10,7 @@ import { CompoundEntityRef } from '@backstage/catalog-model'; import { Dispatch } from 'react'; import { Entity } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; +import { PropsWithChildren } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; import { SetStateAction } from 'react'; @@ -24,6 +25,9 @@ export function createTechDocsAddonExtension( options: TechDocsAddonOptions, ): Extension<(props: TComponentProps) => JSX.Element | null>; +// @public +export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD'; + // @public export type SyncResult = 'cached' | 'updated'; @@ -109,6 +113,19 @@ export type TechDocsReaderPageValue = { onReady?: () => void; }; +// @public +export const TechDocsShadowDom: ({ + element, + onAppend, + children, +}: TechDocsShadowDomProps) => JSX.Element; + +// @public +export type TechDocsShadowDomProps = PropsWithChildren<{ + element: Element; + onAppend?: (shadowRoot: ShadowRoot) => void; +}>; + // @public export interface TechDocsStorageApi { // (undocumented) @@ -135,6 +152,9 @@ export interface TechDocsStorageApi { // @public export const techdocsStorageApiRef: ApiRef; +// @public +export const useShadowDomStylesLoading: (element: Element | null) => boolean; + // @public export const useShadowRoot: () => ShadowRoot | undefined; diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json index 195c30ba86..2d315b523c 100644 --- a/plugins/techdocs-react/package.json +++ b/plugins/techdocs-react/package.json @@ -53,6 +53,7 @@ "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^8.0.0", "@backstage/test-utils": "^1.1.0", diff --git a/plugins/techdocs-react/src/component.test.tsx b/plugins/techdocs-react/src/component.test.tsx new file mode 100644 index 0000000000..819f8fdaad --- /dev/null +++ b/plugins/techdocs-react/src/component.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import { + SHADOW_DOM_STYLE_LOAD_EVENT, + TechDocsShadowDom, + TechDocsShadowDomProps, +} from './component'; + +const createDom = (innerHTML: string) => { + const newDom = document.createElement('html'); + newDom.innerHTML = innerHTML; + return newDom; +}; + +describe('TechDocsShadowDom', () => { + it('Should render children', () => { + const dom = createDom('

Title

'); + const onAppend = jest.fn(); + render( + + Children + , + ); + expect(screen.getByText('Children')).toBeInTheDocument(); + }); + + it('Should re-render if props changes', async () => { + const Component = ({ + onAppend, + }: Pick) => { + const [dom, setDom] = useState(createDom('

Title1

')); + + useEffect(() => { + setDom(createDom('

Title2

')); + }, []); + + return ; + }; + + const onAppend = jest.fn(); + render(); + + await waitFor(() => { + const shadowHost = screen.getByTestId('techdocs-native-shadowroot'); + const h1 = shadowHost.shadowRoot?.querySelector('h1'); + expect(h1).toHaveTextContent('Title2'); + }); + expect(onAppend).toHaveBeenCalledTimes(2); + }); + + it('Should show progress bar while styles are being loaded', async () => { + const dom = createDom( + '

Title

', + ); + const onAppend = jest.fn(); + dom.querySelector('link[rel="stylesheet"]')!.addEventListener = () => {}; + + render( + + Children + , + ); + + await await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); + + it('Should dispatch an event after all styles are loaded', async () => { + const dom = createDom( + '

Title

', + ); + let listener: EventListenerOrEventListenerObject = () => {}; + dom.querySelector('link')!.addEventListener = ( + _type: string, + _listener: EventListenerOrEventListenerObject, + ) => { + listener = _listener; + }; + const handleStylesLoad = jest.fn(); + dom.addEventListener(SHADOW_DOM_STYLE_LOAD_EVENT, handleStylesLoad); + + render(Children); + + await await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + listener({} as Event); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + expect(handleStylesLoad).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/techdocs-react/src/component.tsx b/plugins/techdocs-react/src/component.tsx new file mode 100644 index 0000000000..cdc4042662 --- /dev/null +++ b/plugins/techdocs-react/src/component.tsx @@ -0,0 +1,258 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + PropsWithChildren, + useState, + useEffect, + useCallback, +} from 'react'; + +import { create } from 'jss'; +import { StylesProvider, jssPreset } from '@material-ui/styles'; + +import { Progress } from '@backstage/core-components'; + +/** + * Name for the event dispatched when ShadowRoot styles are loaded. + * @public + */ +export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD'; + +/** + * Dispatch style load event after all styles are loaded. + * @param element - the ShadowRoot tree. + */ +const useShadowDomStylesEvents = (element: Element | null) => { + useEffect(() => { + if (!element) { + return () => {}; + } + + const styles = element.querySelectorAll( + 'head > link[rel="stylesheet"]', + ); + + let count = styles?.length ?? 0; + const event = new CustomEvent(SHADOW_DOM_STYLE_LOAD_EVENT); + + if (!count) { + element.dispatchEvent(event); + return () => {}; + } + + const handleLoad = () => { + if (--count === 0) { + element.dispatchEvent(event); + } + }; + + styles?.forEach(style => { + style.addEventListener('load', handleLoad); + }); + + return () => { + styles?.forEach(style => { + style.removeEventListener('load', handleLoad); + }); + }; + }, [element]); +}; + +/** + * Returns the style's loading state. + * + * @example + * Here's an example that updates the sidebar position only after styles are calculated: + * ```jsx + * import { + * TechDocsShadowDom, + * useShadowDomStylesLoading, + * } from '@backstage/plugin-techdocs-react'; + * + * export const TechDocsReaderPageContent = () => { + * // ... + * const dom = useTechDocsReaderDom(entity); + * const isStyleLoading = useShadowDomStylesLoading(dom); + * + * const updateSidebarPosition = useCallback(() => { + * //... + * }, [dom]); + * + * useEffect(() => { + * if (!isStyleLoading) { + * updateSidebarPosition(); + * } + * }, [isStyleLoading, updateSidebarPosition]); + * + * const handleDomAppend = useCallback( + * (newShadowRoot: ShadowRoot) => { + * setShadowRoot(newShadowRoot); + * }, + * [setShadowRoot], + * ); + * + * return ; + * }; + * ``` + * + * @param element - which is the ShadowRoot tree. + * @returns a boolean value, true if styles are being loaded. + * @public + */ +export const useShadowDomStylesLoading = (element: Element | null) => { + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!element) return () => {}; + + setLoading(true); + + const style = (element as HTMLElement).style; + + style.setProperty('opacity', '0'); + + const handleLoad = () => { + setLoading(false); + style.setProperty('opacity', '1'); + }; + + element.addEventListener(SHADOW_DOM_STYLE_LOAD_EVENT, handleLoad); + + return () => { + element.removeEventListener(SHADOW_DOM_STYLE_LOAD_EVENT, handleLoad); + }; + }, [element]); + + return loading; +}; + +/** + * Props for {@link TechDocsShadowDom}. + * + * @remarks + * If you want to use portals to render Material UI components in the Shadow DOM, + * you must render these portals as children because this component wraps its children in a Material UI StylesProvider + * to ensure that Material UI styles are applied. + * + * @public + */ +export type TechDocsShadowDomProps = PropsWithChildren<{ + /** + * Element tree that is appended to ShadowRoot. + */ + element: Element; + /** + * Callback called when the element tree is appended in ShadowRoot. + */ + onAppend?: (shadowRoot: ShadowRoot) => void; +}>; + +/** + * Renders a tree of elements in a Shadow DOM. + * + * @remarks + * Centers the styles loaded event to avoid having multiple locations setting the opacity style in Shadow DOM causing the screen to flash multiple times, + * so if you want to know when Shadow DOM styles are computed, you can listen for the "TECH_DOCS_SHADOW_DOM_STYLE_LOAD" event dispatched by the element tree. + * + * @example + * Here is an example using this component and also listening for styles loaded event: + *```jsx + * import { + * TechDocsShadowDom, + * SHADOW_DOM_STYLE_LOAD_EVENT, + * } from '@backstage/plugin-techdocs-react'; + * + * export const TechDocsReaderPageContent = ({ entity }: TechDocsReaderPageContentProps) => { + * // ... + * const dom = useTechDocsReaderDom(entity); + * + * useEffect(() => { + * const updateSidebarPosition = () => { + * // ... + * }; + * dom?.addEventListener(SHADOW_DOM_STYLE_LOAD_EVENT, updateSidebarPosition); + * return () => { + * dom?.removeEventListener(SHADOW_DOM_STYLE_LOAD_EVENT, updateSidebarPosition); + * }; + * }, [dom]); + * + * const handleDomAppend = useCallback( + * (newShadowRoot: ShadowRoot) => { + * setShadowRoot(newShadowRoot); + * }, + * [setShadowRoot], + * ); + * + * return ; + * }; + * ``` + * + * @param props - see {@link TechDocsShadowDomProps}. + * @public + */ +export const TechDocsShadowDom = ({ + element, + onAppend, + children, +}: TechDocsShadowDomProps) => { + const [jss, setJss] = useState( + create({ + ...jssPreset(), + insertionPoint: undefined, + }), + ); + + useShadowDomStylesEvents(element); + const loading = useShadowDomStylesLoading(element); + + const ref = useCallback( + (shadowHost: HTMLDivElement) => { + if (!element || !shadowHost) return; + + setJss( + create({ + ...jssPreset(), + insertionPoint: element.querySelector('head') || undefined, + }), + ); + + let shadowRoot = shadowHost.shadowRoot; + + if (!shadowRoot) { + shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + } + + shadowRoot.replaceChildren(element); + + if (typeof onAppend === 'function') { + onAppend(shadowRoot); + } + }, + [element, onAppend], + ); + + return ( + <> + {loading && } + {/* The sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} + +
+ {children} + + + ); +}; diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts index d4de5f0d29..bd2edda763 100644 --- a/plugins/techdocs-react/src/index.ts +++ b/plugins/techdocs-react/src/index.ts @@ -34,14 +34,20 @@ export type { TechDocsReaderPageProviderRenderFunction, TechDocsReaderPageValue, } from './context'; -export { - useShadowRoot, - useShadowRootElements, - useShadowRootSelection, -} from './hooks'; export { TechDocsAddonLocations } from './types'; export type { TechDocsEntityMetadata, TechDocsMetadata, TechDocsAddonOptions, } from './types'; +export type { TechDocsShadowDomProps } from './component'; +export { + TechDocsShadowDom, + useShadowDomStylesLoading, + SHADOW_DOM_STYLE_LOAD_EVENT, +} from './component'; +export { + useShadowRoot, + useShadowRootElements, + useShadowRootSelection, +} from './hooks'; diff --git a/plugins/techdocs/src/test-utils/stylesheets.ts b/plugins/techdocs-react/src/setupTests.ts similarity index 54% rename from plugins/techdocs/src/test-utils/stylesheets.ts rename to plugins/techdocs-react/src/setupTests.ts index e334ffac36..963c0f188b 100644 --- a/plugins/techdocs/src/test-utils/stylesheets.ts +++ b/plugins/techdocs-react/src/setupTests.ts @@ -14,22 +14,4 @@ * limitations under the License. */ -export const mockStylesheetEventListener = (timeToCallbackMs: number): void => { - HTMLLinkElement.prototype.addEventListener = ( - _eventName: string, - eventCallback: any, - ) => { - setTimeout(() => { - eventCallback(); - }, timeToCallbackMs); - }; -}; - -export const executeStylesheetEventListeners = (): void => { - jest.runOnlyPendingTimers(); -}; - -export const clearStylesheetEventListeners = (): void => { - HTMLLinkElement.prototype.addEventListener = - Element.prototype.addEventListener; -}; +import '@testing-library/jest-dom'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx index 975a58f332..def15abf8d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx @@ -110,28 +110,6 @@ describe('', () => { }); }); - it('should render progress if there is no dom and reader state is checking', async () => { - getEntityMetadata.mockResolvedValue(mockEntityMetadata); - getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); - useTechDocsReaderDom.mockReturnValue(undefined); - useReaderState.mockReturnValue({ state: 'CHECKING' }); - - await act(async () => { - const rendered = await renderInTestApp( - - - , - ); - - await waitFor(() => { - expect( - rendered.queryByTestId('techdocs-native-shadowroot'), - ).not.toBeInTheDocument(); - expect(rendered.getByRole('progressbar')).toBeInTheDocument(); - }); - }); - }); - it('should not render techdocs content if entity metadata is missing', async () => { getEntityMetadata.mockResolvedValue(undefined); useTechDocsReaderDom.mockReturnValue(document.createElement('html')); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index fb6878a281..c31979b5f9 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -14,15 +14,12 @@ * limitations under the License. */ -import React, { useState, useCallback } from 'react'; -import { create } from 'jss'; +import React, { useCallback } from 'react'; -import { makeStyles, Grid, Portal } from '@material-ui/core'; -import { StylesProvider, jssPreset } from '@material-ui/styles'; +import { makeStyles, Grid } from '@material-ui/core'; import { - useTechDocsAddons, - TechDocsAddonLocations as locations, + TechDocsShadowDom, useTechDocsReaderPage, } from '@backstage/plugin-techdocs-react'; import { CompoundEntityRef } from '@backstage/catalog-model'; @@ -33,6 +30,7 @@ import { TechDocsStateIndicator } from '../TechDocsStateIndicator'; import { useTechDocsReaderDom } from './dom'; import { withTechDocsReaderProvider } from '../TechDocsReaderProvider'; +import { TechDocsReaderPageContentAddons } from './TechDocsReaderPageContentAddons'; const useStyles = makeStyles({ search: { @@ -71,79 +69,25 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( (props: TechDocsReaderPageContentProps) => { const { withSearch = true, onReady } = props; const classes = useStyles(); - const addons = useTechDocsAddons(); + const { entityMetadata: { value: entityMetadata, loading: entityMetadataLoading }, entityRef, - shadowRoot, setShadowRoot, } = useTechDocsReaderPage(); + const dom = useTechDocsReaderDom(entityRef); - const [jss, setJss] = useState( - create({ - ...jssPreset(), - insertionPoint: undefined, - }), - ); - - const ref = useCallback( - (shadowHost: HTMLDivElement) => { - if (!dom || !shadowHost) return; - - setJss( - create({ - ...jssPreset(), - insertionPoint: dom.querySelector('head') || undefined, - }), - ); - - const newShadowRoot = - shadowHost.shadowRoot ?? shadowHost.attachShadow({ mode: 'open' }); - newShadowRoot.innerHTML = ''; - newShadowRoot.appendChild(dom); + const handleAppend = useCallback( + (newShadowRoot: ShadowRoot) => { setShadowRoot(newShadowRoot); if (onReady instanceof Function) { onReady(); } }, - [dom, setShadowRoot, onReady], + [setShadowRoot, onReady], ); - const contentElement = shadowRoot?.querySelector( - '[data-md-component="content"]', - ); - - const primarySidebarElement = shadowRoot?.querySelector( - 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', - ); - let primarySidebarAddonLocation = primarySidebarElement?.querySelector( - '[data-techdocs-addons-location="primary sidebar"]', - ); - if (!primarySidebarAddonLocation) { - primarySidebarAddonLocation = document.createElement('div'); - primarySidebarAddonLocation.setAttribute( - 'data-techdocs-addons-location', - 'primary sidebar', - ); - primarySidebarElement?.prepend(primarySidebarAddonLocation); - } - - const secondarySidebarElement = shadowRoot?.querySelector( - 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', - ); - let secondarySidebarAddonLocation = secondarySidebarElement?.querySelector( - '[data-techdocs-addons-location="secondary sidebar"]', - ); - if (!secondarySidebarAddonLocation) { - secondarySidebarAddonLocation = document.createElement('div'); - secondarySidebarAddonLocation.setAttribute( - 'data-techdocs-addons-location', - 'secondary sidebar', - ); - secondarySidebarElement?.prepend(secondarySidebarAddonLocation); - } - // No entity metadata = 404. Don't render content at all. if (entityMetadataLoading === false && !entityMetadata) return ; @@ -174,19 +118,10 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( )} - {/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */} - -
- - {addons.renderComponentsByLocation(locations.PrimarySidebar)} - - - {addons.renderComponentsByLocation(locations.Content)} - - - {addons.renderComponentsByLocation(locations.SecondarySidebar)} - - + {/* Centers the styles loaded event to avoid having multiple locations setting the opacity style in Shadow Dom causing the screen to flash multiple times */} + + + diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons.tsx new file mode 100644 index 0000000000..744fd8c732 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Portal } from '@material-ui/core'; +import { + useTechDocsAddons, + TechDocsAddonLocations as locations, + useTechDocsReaderPage, +} from '@backstage/plugin-techdocs-react'; + +export const TechDocsReaderPageContentAddons = () => { + const addons = useTechDocsAddons(); + + const { shadowRoot } = useTechDocsReaderPage(); + + const contentElement = shadowRoot?.querySelector( + '[data-md-component="content"]', + ); + + const primarySidebarElement = shadowRoot?.querySelector( + 'div[data-md-component="sidebar"][data-md-type="navigation"], div[data-md-component="navigation"]', + ); + let primarySidebarAddonLocation = primarySidebarElement?.querySelector( + '[data-techdocs-addons-location="primary sidebar"]', + ); + if (!primarySidebarAddonLocation) { + primarySidebarAddonLocation = document.createElement('div'); + primarySidebarAddonLocation.setAttribute( + 'data-techdocs-addons-location', + 'primary sidebar', + ); + primarySidebarElement?.prepend(primarySidebarAddonLocation); + } + + const secondarySidebarElement = shadowRoot?.querySelector( + 'div[data-md-component="sidebar"][data-md-type="toc"], div[data-md-component="toc"]', + ); + let secondarySidebarAddonLocation = secondarySidebarElement?.querySelector( + '[data-techdocs-addons-location="secondary sidebar"]', + ); + if (!secondarySidebarAddonLocation) { + secondarySidebarAddonLocation = document.createElement('div'); + secondarySidebarAddonLocation.setAttribute( + 'data-techdocs-addons-location', + 'secondary sidebar', + ); + secondarySidebarElement?.prepend(secondarySidebarAddonLocation); + } + + return ( + <> + + {addons.renderComponentsByLocation(locations.PrimarySidebar)} + + + {addons.renderComponentsByLocation(locations.Content)} + + + {addons.renderComponentsByLocation(locations.SecondarySidebar)} + + + ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index 07bb481107..87331c2e46 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -17,7 +17,7 @@ import { useContext, useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useTheme, Theme } from '@material-ui/core'; +import { Theme, useTheme, useMediaQuery } from '@material-ui/core'; import { lighten, alpha } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; @@ -26,7 +26,10 @@ import { useApi, configApiRef } from '@backstage/core-plugin-api'; import { SidebarPinStateContext } from '@backstage/core-components'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; -import { techdocsStorageApiRef } from '@backstage/plugin-techdocs-react'; +import { + techdocsStorageApiRef, + useShadowDomStylesLoading, +} from '@backstage/plugin-techdocs-react'; import { useTechDocsReader } from '../TechDocsReaderProvider'; @@ -46,14 +49,22 @@ import { copyToClipboard, } from '../../transformers'; +const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; + type TypographyHeadings = Pick< Theme['typography'], 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' >; + type TypographyHeadingsKeys = keyof TypographyHeadings; const headings: TypographyHeadingsKeys[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; +/** + * Sidebar pinned status to be used in computing CSS style injections + */ +const useSidebar = () => useContext(SidebarPinStateContext); + /** * Hook that encapsulates the behavior of getting raw HTML and applying * transforms to it in order to make it function at a basic level in the @@ -63,83 +74,82 @@ export const useTechDocsReaderDom = ( entityRef: CompoundEntityRef, ): Element | null => { const navigate = useNavigate(); + const sidebar = useSidebar(); const theme = useTheme(); + const isMobileMedia = useMediaQuery(MOBILE_MEDIA_QUERY); + + const configApi = useApi(configApiRef); const techdocsStorageApi = useApi(techdocsStorageApiRef); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const techdocsSanitizer = useApi(configApiRef); - const { namespace, kind, name } = entityRef; + const { state, path, content: rawPage } = useTechDocsReader(); - const isDarkTheme = theme.palette.type === 'dark'; - const [sidebars, setSidebars] = useState(); const [dom, setDom] = useState(null); - - // sidebar pinned status to be used in computing CSS style injections - const { isPinned } = useContext(SidebarPinStateContext); + const isStyleLoading = useShadowDomStylesLoading(dom); const updateSidebarPosition = useCallback(() => { - if (!dom || !sidebars) return; - // set sidebar height so they don't initially render in wrong position - const mdTabs = dom.querySelector('.md-container > .md-tabs'); - const sidebarsCollapsed = window.matchMedia( - 'screen and (max-width: 76.1875em)', - ).matches; - const newTop = Math.max(dom.getBoundingClientRect().top, 0); - sidebars.forEach(sidebar => { - if (sidebarsCollapsed) { - sidebar.style.top = '0px'; - } else if (mdTabs) { - sidebar.style.top = `${ - newTop + mdTabs.getBoundingClientRect().height - }px`; + if (!dom) return; + + const sidebars = dom.querySelectorAll('.md-sidebar'); + + sidebars.forEach(element => { + // set sidebar position to render in correct position + if (isMobileMedia) { + element.style.top = '0px'; } else { - sidebar.style.top = `${newTop}px`; + const domTop = dom.getBoundingClientRect().top ?? 0; + const tabs = dom.querySelector('.md-container > .md-tabs'); + const tabsHeight = tabs?.getBoundingClientRect().height ?? 0; + element.style.top = `${Math.max(domTop, 0) + tabsHeight}px`; } - // Show the sidebar only after updating its position - sidebar.style.removeProperty('opacity'); + + // show the sidebar only after updating its position + element.style.setProperty('opacity', '1'); }); - }, [dom, sidebars]); + }, [dom, isMobileMedia]); useEffect(() => { - updateSidebarPosition(); - window.addEventListener('scroll', updateSidebarPosition, true); window.addEventListener('resize', updateSidebarPosition); + window.addEventListener('scroll', updateSidebarPosition, true); return () => { - window.removeEventListener('scroll', updateSidebarPosition, true); window.removeEventListener('resize', updateSidebarPosition); + window.removeEventListener('scroll', updateSidebarPosition, true); }; - // an update to "state" might lead to an updated UI so we include it as a trigger - }, [updateSidebarPosition, state]); + }, [dom, updateSidebarPosition]); // dynamically set width of footer to accommodate for pinning of the sidebar const updateFooterWidth = useCallback(() => { if (!dom) return; - const footer = dom.querySelector('.md-footer') as HTMLElement; + const footer = dom.querySelector('.md-footer'); if (footer) { footer.style.width = `${dom.getBoundingClientRect().width}px`; } }, [dom]); useEffect(() => { - updateFooterWidth(); window.addEventListener('resize', updateFooterWidth); return () => { window.removeEventListener('resize', updateFooterWidth); }; - }); + }, [dom, updateFooterWidth]); + + // an update to "state" might lead to an updated UI so we include it as a trigger + useEffect(() => { + if (!isStyleLoading) { + updateFooterWidth(); + updateSidebarPosition(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state, isStyleLoading, updateFooterWidth, updateSidebarPosition]); // a function that performs transformations that are executed prior to adding it to the DOM const preRender = useCallback( (rawContent: string, contentPath: string) => transformer(rawContent, [ - sanitizeDOM(techdocsSanitizer.getOptionalConfig('techdocs.sanitizer')), + sanitizeDOM(configApi.getOptionalConfig('techdocs.sanitizer')), addBaseUrl({ techdocsStorageApi, - entityId: { - kind, - name, - namespace, - }, + entityId: entityRef, path: contentPath, }), rewriteDocLinks(), @@ -235,22 +245,22 @@ export const useTechDocsReaderDom = ( --md-code-bg-color: ${theme.palette.background.paper}; --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; --md-code-hl-keyword-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? theme.palette.primary.light : theme.palette.primary.dark }; --md-code-hl-function-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? theme.palette.secondary.light : theme.palette.secondary.dark }; --md-code-hl-string-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? theme.palette.success.light : theme.palette.success.dark }; --md-code-hl-number-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? theme.palette.error.light : theme.palette.error.dark }; @@ -269,17 +279,17 @@ export const useTechDocsReaderDom = ( --md-typeset-a-color: var(--md-accent-fg-color); --md-typeset-table-color: ${theme.palette.text.primary}; --md-typeset-del-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? alpha(theme.palette.error.dark, 0.5) : alpha(theme.palette.error.light, 0.5) }; --md-typeset-ins-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? alpha(theme.palette.success.dark, 0.5) : alpha(theme.palette.success.light, 0.5) }; --md-typeset-mark-color: ${ - isDarkTheme + theme.palette.type === 'dark' ? alpha(theme.palette.warning.dark, 0.5) : alpha(theme.palette.warning.light, 0.5) }; @@ -465,7 +475,7 @@ export const useTechDocsReaderDom = ( width: 12.1rem !important; z-index: 200; left: ${ - isPinned + sidebar.isPinned ? 'calc(-12.1rem + 242px)' : 'calc(-12.1rem + 72px)' } !important; @@ -611,19 +621,23 @@ export const useTechDocsReaderDom = ( } .highlight .nx { - color: ${isDarkTheme ? '#ff53a3' : '#ec407a'}; + color: ${theme.palette.type === 'dark' ? '#ff53a3' : '#ec407a'}; } /* CODE HILITE */ .codehilite .gd { background-color: ${ - isDarkTheme ? 'rgba(248,81,73,0.65)' : '#fdd' + theme.palette.type === 'dark' + ? 'rgba(248,81,73,0.65)' + : '#fdd' }; } .codehilite .gi { background-color: ${ - isDarkTheme ? 'rgba(46,160,67,0.65)' : '#dfd' + theme.palette.type === 'dark' + ? 'rgba(46,160,67,0.65)' + : '#dfd' }; } @@ -678,15 +692,13 @@ export const useTechDocsReaderDom = ( }), ]), [ - kind, - name, - namespace, - scmIntegrationsApi, - techdocsSanitizer, - techdocsStorageApi, + // only add dependencies that are in state or memorized variables to avoid unnecessary calls between re-renders + entityRef, theme, - isDarkTheme, - isPinned, + sidebar, + configApi, + scmIntegrationsApi, + techdocsStorageApi, ], ); @@ -723,33 +735,29 @@ export const useTechDocsReaderDom = ( } }, }), + // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) onCssReady({ - docStorageUrl: await techdocsStorageApi.getApiOrigin(), - onLoading: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.setProperty('opacity', '0'); - const renderedSidebars = Array.from( - renderedElement.querySelectorAll('.md-sidebar'), - ); - // Hide sidebars until your position is updated - for (const sidebar of renderedSidebars) { - sidebar.style.setProperty('opacity', '0'); - } - }, - onLoaded: (renderedElement: Element) => { - (renderedElement as HTMLElement).style.removeProperty('opacity'); - // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) - renderedElement + onLoading: () => {}, + onLoaded: () => { + transformedElement .querySelector('.md-nav__title') ?.removeAttribute('for'); - const renderedSidebars = Array.from( - renderedElement.querySelectorAll('.md-sidebar'), - ); - // Wait for styles to be calculated to update sidebar position - setSidebars(renderedSidebars); }, }), + // hide sidebars until their positions are updated + onCssReady({ + onLoading: () => { + const sidebars = Array.from( + transformedElement.querySelectorAll('.md-sidebar'), + ); + sidebars.forEach(element => { + element.style.setProperty('opacity', '0'); + }); + }, + onLoaded: () => {}, + }), ]), - [theme, navigate, techdocsStorageApi], + [theme, navigate], ); useEffect(() => { diff --git a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx index 6967356194..772c92aeff 100644 --- a/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsStateIndicator.tsx @@ -15,7 +15,6 @@ */ import React from 'react'; -import { Progress } from '@backstage/core-components'; import { CircularProgress, Button, makeStyles } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; @@ -47,8 +46,6 @@ export const TechDocsStateIndicator = () => { buildLog, } = useTechDocsReader(); - const ReaderProgress = state === 'CHECKING' ? : null; - if (state === 'INITIAL_BUILD') { StateAlert = ( { ); } - return ( - <> - {ReaderProgress} - {StateAlert} - - ); + return StateAlert; }; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index b2a5aa9760..11bc84ed2a 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -import { - clearStylesheetEventListeners, - createTestShadowDom, - executeStylesheetEventListeners, - mockStylesheetEventListener, -} from '../../test-utils'; +import { createTestShadowDom } from '../../test-utils'; import { onCssReady } from './onCssReady'; const docStorageUrl: string = @@ -31,22 +26,6 @@ const fixture = ` `; describe('onCssReady', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - beforeEach(() => { - mockStylesheetEventListener(100); - }); - - afterEach(() => { - clearStylesheetEventListeners(); - }); - it('does not call onLoading and onLoaded without the onCssReady transformer', async () => { const onLoading = jest.fn(); const onLoaded = jest.fn(); @@ -57,7 +36,6 @@ describe('onCssReady', () => { }); expect(onLoading).not.toHaveBeenCalled(); - executeStylesheetEventListeners(); expect(onLoaded).not.toHaveBeenCalled(); }); @@ -69,7 +47,6 @@ describe('onCssReady', () => { preTransformers: [], postTransformers: [ onCssReady({ - docStorageUrl, onLoading, onLoaded, }), @@ -77,12 +54,6 @@ describe('onCssReady', () => { }); expect(onLoading).toHaveBeenCalledTimes(1); - expect(onLoading).toHaveBeenCalledWith(expect.any(Element)); - expect(onLoaded).not.toHaveBeenCalled(); - - executeStylesheetEventListeners(); - expect(onLoaded).toHaveBeenCalledTimes(1); - expect(onLoaded).toHaveBeenCalledWith(expect.any(Element)); }); }); diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index e9406ba632..eff57911e5 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -14,40 +14,30 @@ * limitations under the License. */ +import { SHADOW_DOM_STYLE_LOAD_EVENT } from '@backstage/plugin-techdocs-react'; import type { Transformer } from './transformer'; type OnCssReadyOptions = { - docStorageUrl: string; - onLoading: (dom: Element) => void; - onLoaded: (dom: Element) => void; + onLoading: () => void; + onLoaded: () => void; }; export const onCssReady = ({ - docStorageUrl, onLoading, onLoaded, }: OnCssReadyOptions): Transformer => { return dom => { - const cssPages = Array.from( - dom.querySelectorAll('head > link[rel="stylesheet"]'), - ).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl)); - - let count = cssPages.length; - - if (count > 0) { - onLoading(dom); - } - - cssPages.forEach(cssPage => - cssPage.addEventListener('load', () => { - count -= 1; - - if (count === 0) { - onLoaded(dom); - } - }), + onLoading(); + dom.addEventListener( + SHADOW_DOM_STYLE_LOAD_EVENT, + function handleShadowDomStyleLoad() { + onLoaded(); + dom.removeEventListener( + SHADOW_DOM_STYLE_LOAD_EVENT, + handleShadowDomStyleLoad, + ); + }, ); - return dom; }; }; diff --git a/plugins/techdocs/src/test-utils/index.ts b/plugins/techdocs/src/test-utils/index.ts index 6a8698caf9..584934f732 100644 --- a/plugins/techdocs/src/test-utils/index.ts +++ b/plugins/techdocs/src/test-utils/index.ts @@ -20,5 +20,4 @@ export const FIXTURES = { FIXTURE_STANDARD_PAGE, }; -export * from './stylesheets'; export * from './shadowDom'; diff --git a/plugins/techdocs/src/test-utils/shadowDom.ts b/plugins/techdocs/src/test-utils/shadowDom.ts index f8289bf6d9..0517c29099 100644 --- a/plugins/techdocs/src/test-utils/shadowDom.ts +++ b/plugins/techdocs/src/test-utils/shadowDom.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { SHADOW_DOM_STYLE_LOAD_EVENT } from '@backstage/plugin-techdocs-react'; import type { Transformer } from '../reader/transformers'; import { transform as transformer } from '../reader/transformers'; @@ -50,6 +51,9 @@ export const createTestShadowDom = async ( await transformer(dom, opts.postTransformers); } + // Simulate event dispatched after all styles are loaded + dom.dispatchEvent(new CustomEvent(SHADOW_DOM_STYLE_LOAD_EVENT)); + return divElement.shadowRoot!; };