fix(techdocs): prevent content re-renders
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user