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
@@ -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';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 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 '@testing-library/jest-dom';