diff --git a/pages/app/app-context.tsx b/pages/app/app-context.tsx index 52bedc2f3c..23acee5645 100644 --- a/pages/app/app-context.tsx +++ b/pages/app/app-context.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { createContext } from 'react'; +import React, { createContext, useContext } from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import mapValues from 'lodash/mapValues'; @@ -42,6 +42,10 @@ const AppContext = createContext(appContextDefaults); export default AppContext; +export function useAppContext() { + return useContext(AppContext as React.Context>>); +} + export function parseQuery(query: string) { const queryParams: Record = { ...appContextDefaults.urlParams }; const urlParams = new URLSearchParams(query); diff --git a/pages/table/performance-marks.page.tsx b/pages/table/performance-marks.page.tsx index b5de4a184c..48cf420b43 100644 --- a/pages/table/performance-marks.page.tsx +++ b/pages/table/performance-marks.page.tsx @@ -5,10 +5,15 @@ import React, { useState } from 'react'; import { Button, Header, Link, Modal, SpaceBetween, Table, Tabs } from '~components'; import Box from '~components/box'; +import { useAppContext } from '../app/app-context'; + const EVALUATE_COMPONENT_VISIBILITY_EVENT = 'awsui-evaluate-component-visibility'; export default function TablePerformanceMarkPage() { const [loading, setLoading] = useState(true); + + const { outsideOfViewport } = useAppContext<'outsideOfViewport'>().urlParams; + const dispatchEvaluateVisibilityEvent = () => { const event = new CustomEvent(EVALUATE_COMPONENT_VISIBILITY_EVENT); setTimeout(() => { @@ -30,6 +35,20 @@ export default function TablePerformanceMarkPage() { + {outsideOfViewport && ( +
+ The Table is rendered below the viewport +
+ )} + { + await new Promise(r => setTimeout(r, 200)); const marks = await browser.execute(() => performance.getEntriesByType('mark') as PerformanceMark[]); return marks.filter(m => m.detail?.source === 'awsui'); }; @@ -37,6 +38,7 @@ describe('ButtonDropdown', () => { instanceIdentifier: expect.any(String), loading: false, disabled: false, + inViewport: true, text: 'Launch instance', }); diff --git a/src/button/__integ__/performance-marks.test.ts b/src/button/__integ__/performance-marks.test.ts index d531ce5346..c431156f18 100644 --- a/src/button/__integ__/performance-marks.test.ts +++ b/src/button/__integ__/performance-marks.test.ts @@ -15,6 +15,7 @@ function setupTest( const page = new BasePageObject(browser); await browser.url(`#/light/button/${pageName}`); const getMarks = async () => { + await new Promise(r => setTimeout(r, 200)); const marks = await browser.execute(() => performance.getEntriesByType('mark') as PerformanceMark[]); return marks.filter(m => m.detail?.source === 'awsui'); }; @@ -37,6 +38,7 @@ describe('Button', () => { instanceIdentifier: expect.any(String), loading: false, disabled: false, + inViewport: true, text: 'Primary button', }); @@ -57,6 +59,7 @@ describe('Button', () => { instanceIdentifier: marks[0].detail.instanceIdentifier, loading: false, disabled: false, + inViewport: true, text: 'Primary button', }); @@ -68,6 +71,7 @@ describe('Button', () => { instanceIdentifier: marks[1].detail.instanceIdentifier, loading: false, disabled: false, + inViewport: true, text: 'Primary button with loading and disabled props', }); @@ -109,6 +113,7 @@ describe('Button', () => { instanceIdentifier: marks[0].detail.instanceIdentifier, loading: false, disabled: false, + inViewport: true, text: 'Primary button', }); @@ -120,6 +125,7 @@ describe('Button', () => { instanceIdentifier: marks[1].detail.instanceIdentifier, loading: false, disabled: false, + inViewport: true, text: 'Primary button', }); diff --git a/src/internal/hooks/use-performance-marks/__tests__/is-in-viewport.test.tsx b/src/internal/hooks/use-performance-marks/__tests__/is-in-viewport.test.tsx new file mode 100644 index 0000000000..59c42cbb3f --- /dev/null +++ b/src/internal/hooks/use-performance-marks/__tests__/is-in-viewport.test.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runAllIntersectionObservers } from '../../../utils/__tests__/mock-intersection-observer'; + +jest.useFakeTimers(); + +function isInViewport(element: Element, callback: (inViewport: boolean) => void) { + // We need to import the function dynamically so that it picks up the mocked IntersectionObserver. + + // eslint-disable-next-line @typescript-eslint/no-var-requires + return jest.requireActual('../is-in-viewport').isInViewport(element, callback); +} + +beforeEach(() => jest.resetAllMocks()); + +describe('isInViewport', () => { + it('calls the callback with `true` if the element is visible', () => { + const callback = jest.fn(); + const element = document.createElement('div'); + + isInViewport(element, callback); + + runAllIntersectionObservers([{ target: element, isIntersecting: true }]); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(true); + }); + + it('calls the callback with `false` if the element is not visible', () => { + const callback = jest.fn(); + const element = document.createElement('div'); + + isInViewport(element, callback); + + runAllIntersectionObservers([{ target: element, isIntersecting: false }]); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(false); + }); + + it('calls the callback with `false` if the IntersectionObserver does not fire in reasonable time', () => { + const callback = jest.fn(); + const element = document.createElement('div'); + + isInViewport(element, callback); + + jest.runAllTimers(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(false); + }); + + it('calls different callbacks for different elements', () => { + const callback1 = jest.fn(); + const element1 = document.createElement('div'); + const callback2 = jest.fn(); + const element2 = document.createElement('div'); + + isInViewport(element1, callback1); + isInViewport(element2, callback2); + + runAllIntersectionObservers([ + { target: element2, isIntersecting: false }, + { target: element1, isIntersecting: true }, + ]); + + jest.runAllTimers(); + + expect(callback1).toHaveBeenCalledWith(true); + expect(callback2).toHaveBeenCalledWith(false); + }); + + it('does not call the callback if the cleanup function is run', () => { + const callback = jest.fn(); + const element = document.createElement('div'); + + const cleanup = isInViewport(element, callback); + + cleanup(); + + runAllIntersectionObservers([{ target: element, isIntersecting: false }]); + + jest.runAllTimers(); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/internal/hooks/use-performance-marks/index.ts b/src/internal/hooks/use-performance-marks/index.ts index 4fbfce61d3..0da94097d1 100644 --- a/src/internal/hooks/use-performance-marks/index.ts +++ b/src/internal/hooks/use-performance-marks/index.ts @@ -7,6 +7,7 @@ import { useModalContext } from '../../context/modal-context'; import { useDOMAttribute } from '../use-dom-attribute'; import { useEffectOnUpdate } from '../use-effect-on-update'; import { useRandomId } from '../use-unique-id'; +import { isInViewport } from './is-in-viewport'; const EVALUATE_COMPONENT_VISIBILITY_EVENT = 'awsui-evaluate-component-visibility'; @@ -50,6 +51,7 @@ export function usePerformanceMarks( const { isInModal } = useModalContext(); const attributes = useDOMAttribute(elementRef, 'data-analytics-performance-mark', id); const evaluateComponentVisibility = useEvaluateComponentVisibility(); + useEffect(() => { if (!enabled() || !elementRef.current || isInModal) { return; @@ -63,14 +65,22 @@ export function usePerformanceMarks( return; } - const renderedMarkName = `${name}Rendered`; - performance.mark(renderedMarkName, { - detail: { - source: 'awsui', - instanceIdentifier: id, - ...getDetails(), - }, + const timestamp = performance.now(); + + const cleanup = isInViewport(elementRef.current, inViewport => { + performance.mark(`${name}Rendered`, { + startTime: timestamp, + detail: { + source: 'awsui', + instanceIdentifier: id, + inViewport, + ...getDetails(), + }, + }); }); + + return cleanup; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -86,14 +96,22 @@ export function usePerformanceMarks( if (!elementVisible) { return; } - const updatedMarkName = `${name}Updated`; - performance.mark(updatedMarkName, { - detail: { - source: 'awsui', - instanceIdentifier: id, - ...getDetails(), - }, + + const timestamp = performance.now(); + + const cleanup = isInViewport(elementRef.current, inViewport => { + performance.mark(`${name}Updated`, { + startTime: timestamp, + detail: { + source: 'awsui', + instanceIdentifier: id, + inViewport, + ...getDetails(), + }, + }); }); + return cleanup; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [evaluateComponentVisibility, ...dependencies]); diff --git a/src/internal/hooks/use-performance-marks/is-in-viewport.ts b/src/internal/hooks/use-performance-marks/is-in-viewport.ts new file mode 100644 index 0000000000..0421ec9f27 --- /dev/null +++ b/src/internal/hooks/use-performance-marks/is-in-viewport.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +type Callback = (inViewport: boolean) => void; +const map = new WeakMap(); + +const MANUAL_TRIGGER_DELAY = 150; + +/** + * This function determines whether an element is in the viewport. The callback + * is batched with other elements that also use this function, in order to improve + * performance. + */ +export function isInViewport(element: Element, callback: Callback) { + let resolve = (value: boolean) => { + resolve = () => {}; // Prevent multiple execution + callback(value); + }; + + map.set(element, inViewport => resolve(inViewport)); + observer.observe(element); + + /* + If the IntersectionObserver does not fire in reasonable time (for example + in a background page in Chrome), we need to call the callback manually. + + See https://issues.chromium.org/issues/41383759 + */ + const timeoutHandle = setTimeout(() => resolve(false), MANUAL_TRIGGER_DELAY); + + // Cleanup + return () => { + clearTimeout(timeoutHandle); + map.delete(element); + observer.unobserve(element); + }; +} + +function createIntersectionObserver(callback: IntersectionObserverCallback) { + if (typeof IntersectionObserver === 'undefined') { + return { + observe: () => {}, + unobserve: () => {}, + }; + } + return new IntersectionObserver(callback); +} + +const observer = createIntersectionObserver(function isInViewportObserver(entries) { + for (const entry of entries) { + observer.unobserve(entry.target); // We only want the first run of the observer for each element. + map.get(entry.target)?.(entry.isIntersecting); + map.delete(entry.target); + } +}); diff --git a/src/internal/utils/__tests__/mock-intersection-observer.test.ts b/src/internal/utils/__tests__/mock-intersection-observer.test.ts new file mode 100644 index 0000000000..ef6db56a67 --- /dev/null +++ b/src/internal/utils/__tests__/mock-intersection-observer.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runAllIntersectionObservers } from './mock-intersection-observer'; + +describe('IntersectionObserver mock', () => { + it('runs the callback for observed elements', () => { + const callback = jest.fn(); + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + const element3 = document.createElement('div'); + + const observer = new IntersectionObserver(callback); + observer.observe(element1); + observer.observe(element2); + observer.observe(element3); + observer.unobserve(element3); + + runAllIntersectionObservers([ + { target: element2, isIntersecting: true }, + { target: element1, isIntersecting: false }, + { target: element3, isIntersecting: true }, + { target: document.createElement('div'), isIntersecting: false }, + ]); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + [ + { target: element2, isIntersecting: true }, + { target: element1, isIntersecting: false }, + ], + expect.anything() + ); + }); + + it('supports multiple instances', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + + const observer1 = new IntersectionObserver(callback1); + const observer2 = new IntersectionObserver(callback2); + observer1.observe(element1); + observer2.observe(element2); + + runAllIntersectionObservers([ + { target: element1, isIntersecting: true }, + { target: element2, isIntersecting: true }, + ]); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith([{ target: element1, isIntersecting: true }], expect.anything()); + expect(callback2).toHaveBeenCalledWith([{ target: element2, isIntersecting: true }], expect.anything()); + }); +}); diff --git a/src/internal/utils/__tests__/mock-intersection-observer.ts b/src/internal/utils/__tests__/mock-intersection-observer.ts new file mode 100644 index 0000000000..6dc9d9f34a --- /dev/null +++ b/src/internal/utils/__tests__/mock-intersection-observer.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* + When this file is imported, it automatically mocks the global `IntersectionObserver` class. + The callbacks of all IntersectionObservers are only run when the `runAllIntersectionObservers` + function is called. +*/ + +const allCallbacks = new Set<(entries: IntersectionObserverEntry[]) => void>(); + +global.IntersectionObserver = class IntersectionObserverMock { + private elements = new Set(); + + constructor(callback: IntersectionObserverCallback) { + allCallbacks.add(entries => { + const observedEntries = entries.filter(r => this.elements.has(r.target)); + + callback(observedEntries, this as unknown as IntersectionObserver); + }); + } + + observe(element: Element) { + this.elements.add(element); + } + + unobserve(element: Element) { + this.elements.delete(element); + } +} as unknown as typeof IntersectionObserver; + +/** + * Runs all registered IntersectionObserver callbacks. + * The callback will only contain entries for elements that are currently being observed. + * If an element is not being observed or not included in the `results` parameter, it + * will not be included in the callback. + */ +export function runAllIntersectionObservers(entries: Array) { + allCallbacks.forEach(callback => callback(entries as IntersectionObserverEntry[])); +} + +interface PartialEntry extends Partial { + target: Element; + isIntersecting: boolean; +} diff --git a/src/table/__integ__/performance-marks.test.ts b/src/table/__integ__/performance-marks.test.ts index 37737035e3..3bf1d3040c 100644 --- a/src/table/__integ__/performance-marks.test.ts +++ b/src/table/__integ__/performance-marks.test.ts @@ -5,6 +5,7 @@ import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objec import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; function setupTest( + inViewport: boolean, testFn: (parameters: { page: BasePageObject; getMarks: () => Promise; @@ -13,8 +14,9 @@ function setupTest( ) { return useBrowser(async browser => { const page = new BasePageObject(browser); - await browser.url('#/light/table/performance-marks'); + await browser.url(`#/light/table/performance-marks${!inViewport ? '?outsideOfViewport=true' : ''}`); const getMarks = async () => { + await new Promise(r => setTimeout(r, 200)); const marks = await browser.execute(() => performance.getEntriesByType('mark') as PerformanceMark[]); return marks.filter(m => m.detail?.source === 'awsui'); }; @@ -25,10 +27,10 @@ function setupTest( }); } -describe('Table', () => { +describe.each([true, false])(`Table (in viewport: %p)`, inViewport => { test( 'Emits a mark only for visible tables which are loaded completely', - setupTest(async ({ getMarks, isElementPerformanceMarkExisting }) => { + setupTest(inViewport, async ({ getMarks, isElementPerformanceMarkExisting }) => { const marks = await getMarks(); expect(marks).toHaveLength(1); @@ -37,6 +39,7 @@ describe('Table', () => { source: 'awsui', instanceIdentifier: expect.any(String), loading: false, + inViewport, header: 'A table without the Header component', }); await expect(isElementPerformanceMarkExisting(marks[0].detail.instanceIdentifier)).resolves.toBeTruthy(); @@ -45,7 +48,7 @@ describe('Table', () => { test( 'Emits a mark when properties change', - setupTest(async ({ page, getMarks, isElementPerformanceMarkExisting }) => { + setupTest(inViewport, async ({ page, getMarks, isElementPerformanceMarkExisting }) => { await page.click('#loading'); const marks = await getMarks(); @@ -55,6 +58,7 @@ describe('Table', () => { source: 'awsui', instanceIdentifier: expect.any(String), loading: false, + inViewport, header: 'This is my table', }); await expect(isElementPerformanceMarkExisting(marks[1].detail.instanceIdentifier)).resolves.toBeTruthy(); @@ -63,7 +67,7 @@ describe('Table', () => { test( 'Emits a mark for loaded table components when evaluateComponentVisibility event is emitted', - setupTest(async ({ page, getMarks, isElementPerformanceMarkExisting }) => { + setupTest(inViewport, async ({ page, getMarks, isElementPerformanceMarkExisting }) => { let marks = await getMarks(); expect(marks).toHaveLength(1); expect(marks[0].name).toBe('tableRendered'); @@ -71,6 +75,7 @@ describe('Table', () => { source: 'awsui', instanceIdentifier: expect.any(String), loading: false, + inViewport, header: 'A table without the Header component', }); @@ -84,6 +89,7 @@ describe('Table', () => { source: 'awsui', instanceIdentifier: expect.any(String), loading: false, + inViewport, header: 'A table without the Header component', }); })