fix(techdocs): prevent content re-renders

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-05-13 15:14:08 +02:00
parent 459471a623
commit 95a5352746
15 changed files with 602 additions and 268 deletions
+20
View File
@@ -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<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): 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<TechDocsStorageApi>;
// @public
export const useShadowDomStylesLoading: (element: Element | null) => boolean;
// @public
export const useShadowRoot: () => ShadowRoot | undefined;
+1
View File
@@ -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",
@@ -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('<body><h1>Title</h1></body>');
const onAppend = jest.fn();
render(
<TechDocsShadowDom element={dom} onAppend={onAppend}>
Children
</TechDocsShadowDom>,
);
expect(screen.getByText('Children')).toBeInTheDocument();
});
it('Should re-render if props changes', async () => {
const Component = ({
onAppend,
}: Pick<TechDocsShadowDomProps, 'onAppend'>) => {
const [dom, setDom] = useState(createDom('<body><h1>Title1</h1></body>'));
useEffect(() => {
setDom(createDom('<body><h1>Title2</h1></body>'));
}, []);
return <TechDocsShadowDom element={dom} onAppend={onAppend} />;
};
const onAppend = jest.fn();
render(<Component onAppend={onAppend} />);
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(
'<head><link rel="stylesheet" src="styles.css"/></head><body><h1>Title</h1></body>',
);
const onAppend = jest.fn();
dom.querySelector('link[rel="stylesheet"]')!.addEventListener = () => {};
render(
<TechDocsShadowDom element={dom} onAppend={onAppend}>
Children
</TechDocsShadowDom>,
);
await await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});
it('Should dispatch an event after all styles are loaded', async () => {
const dom = createDom(
'<head><link rel="stylesheet" src="styles.css"/></head><body><h1>Title</h1></body>',
);
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(<TechDocsShadowDom element={dom}>Children</TechDocsShadowDom>);
await await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
listener({} as Event);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
expect(handleStylesLoad).toHaveBeenCalledTimes(1);
});
});
+258
View File
@@ -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<HTMLElement>(
'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 <TechDocsShadowDom element={dom} onAppend={handleDomAppend} />;
* };
* ```
*
* @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 <TechDocsShadowDom element={dom} onAppend={handleDomAppend} />;
* };
* ```
*
* @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 && <Progress />}
{/* The sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */}
<StylesProvider jss={jss} sheetsManager={new Map()}>
<div ref={ref} data-testid="techdocs-native-shadowroot" />
{children}
</StylesProvider>
</>
);
};
+11 -5
View File
@@ -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';
@@ -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';
@@ -110,28 +110,6 @@ describe('<TechDocsReaderPageContent />', () => {
});
});
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(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
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'));
@@ -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 <ErrorPage status="404" statusMessage="PAGE NOT FOUND" />;
@@ -174,19 +118,10 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
</Grid>
)}
<Grid xs={12} item>
{/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */}
<StylesProvider jss={jss} sheetsManager={new Map()}>
<div ref={ref} data-testid="techdocs-native-shadowroot" />
<Portal container={primarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.PrimarySidebar)}
</Portal>
<Portal container={contentElement}>
{addons.renderComponentsByLocation(locations.Content)}
</Portal>
<Portal container={secondarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.SecondarySidebar)}
</Portal>
</StylesProvider>
{/* Centers the styles loaded event to avoid having multiple locations setting the opacity style in Shadow Dom causing the screen to flash multiple times */}
<TechDocsShadowDom element={dom} onAppend={handleAppend}>
<TechDocsReaderPageContentAddons />
</TechDocsShadowDom>
</Grid>
</Grid>
</Content>
@@ -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 (
<>
<Portal container={primarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.PrimarySidebar)}
</Portal>
<Portal container={contentElement}>
{addons.renderComponentsByLocation(locations.Content)}
</Portal>
<Portal container={secondarySidebarAddonLocation}>
{addons.renderComponentsByLocation(locations.SecondarySidebar)}
</Portal>
</>
);
};
@@ -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<BackstageTheme>();
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<HTMLElement[]>();
const [dom, setDom] = useState<HTMLElement | null>(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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.md-sidebar'),
);
sidebars.forEach(element => {
element.style.setProperty('opacity', '0');
});
},
onLoaded: () => {},
}),
]),
[theme, navigate, techdocsStorageApi],
[theme, navigate],
);
useEffect(() => {
@@ -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' ? <Progress /> : null;
if (state === 'INITIAL_BUILD') {
StateAlert = (
<Alert
@@ -130,10 +127,5 @@ export const TechDocsStateIndicator = () => {
);
}
return (
<>
{ReaderProgress}
{StateAlert}
</>
);
return StateAlert;
};
@@ -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));
});
});
@@ -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;
};
};
-1
View File
@@ -20,5 +20,4 @@ export const FIXTURES = {
FIXTURE_STANDARD_PAGE,
};
export * from './stylesheets';
export * from './shadowDom';
@@ -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!;
};