Skip to content

chore: Include viewport visibility in performance marks #3412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pages/app/app-context.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -42,6 +42,10 @@ const AppContext = createContext<AppContextType>(appContextDefaults);

export default AppContext;

export function useAppContext<T extends keyof any>() {
return useContext(AppContext as React.Context<AppContextType<Record<T, string | boolean>>>);
}

export function parseQuery(query: string) {
const queryParams: Record<string, any> = { ...appContextDefaults.urlParams };
const urlParams = new URLSearchParams(query);
Expand Down
19 changes: 19 additions & 0 deletions pages/table/performance-marks.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -30,6 +35,20 @@ export default function TablePerformanceMarkPage() {
</Button>
</label>

{outsideOfViewport && (
<div
style={{
margin: 3,
padding: 10,
border: '1px solid rgba(128 128 128 / 50%)',
background: 'rgba(128 128 128 / 10%)',
height: '100vh',
}}
>
The Table is rendered below the viewport
</div>
)}

<Table
items={[]}
loading={loading}
Expand Down
2 changes: 2 additions & 0 deletions src/button-dropdown/__integ__/performance-marks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function setupTest(
const page = new BasePageObject(browser);
await browser.url(`#/light/button-dropdown/${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');
};
Expand All @@ -37,6 +38,7 @@ describe('ButtonDropdown', () => {
instanceIdentifier: expect.any(String),
loading: false,
disabled: false,
inViewport: true,
text: 'Launch instance',
});

Expand Down
6 changes: 6 additions & 0 deletions src/button/__integ__/performance-marks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
};
Expand All @@ -37,6 +38,7 @@ describe('Button', () => {
instanceIdentifier: expect.any(String),
loading: false,
disabled: false,
inViewport: true,
text: 'Primary button',
});

Expand All @@ -57,6 +59,7 @@ describe('Button', () => {
instanceIdentifier: marks[0].detail.instanceIdentifier,
loading: false,
disabled: false,
inViewport: true,
text: 'Primary button',
});

Expand All @@ -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',
});

Expand Down Expand Up @@ -109,6 +113,7 @@ describe('Button', () => {
instanceIdentifier: marks[0].detail.instanceIdentifier,
loading: false,
disabled: false,
inViewport: true,
text: 'Primary button',
});

Expand All @@ -120,6 +125,7 @@ describe('Button', () => {
instanceIdentifier: marks[1].detail.instanceIdentifier,
loading: false,
disabled: false,
inViewport: true,
text: 'Primary button',
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
46 changes: 32 additions & 14 deletions src/internal/hooks/use-performance-marks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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';

Expand Down Expand Up @@ -50,6 +51,7 @@
const { isInModal } = useModalContext();
const attributes = useDOMAttribute(elementRef, 'data-analytics-performance-mark', id);
const evaluateComponentVisibility = useEvaluateComponentVisibility();

useEffect(() => {
if (!enabled() || !elementRef.current || isInModal) {
return;
Expand All @@ -63,14 +65,22 @@
return;
}

const renderedMarkName = `${name}Rendered`;
performance.mark(renderedMarkName, {
detail: {
source: 'awsui',
instanceIdentifier: id,
...getDetails(),
},
const timestamp = performance.now();

Check warning on line 68 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L68

Added line #L68 was not covered by tests

const cleanup = isInViewport(elementRef.current, inViewport => {
performance.mark(`${name}Rendered`, {

Check warning on line 71 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L70-L71

Added lines #L70 - L71 were not covered by tests
startTime: timestamp,
detail: {
source: 'awsui',
instanceIdentifier: id,
inViewport,
...getDetails(),
},
});
});

return cleanup;

Check warning on line 82 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L82

Added line #L82 was not covered by tests

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

Expand All @@ -86,14 +96,22 @@
if (!elementVisible) {
return;
}
const updatedMarkName = `${name}Updated`;
performance.mark(updatedMarkName, {
detail: {
source: 'awsui',
instanceIdentifier: id,
...getDetails(),
},

const timestamp = performance.now();

Check warning on line 100 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L100

Added line #L100 was not covered by tests

const cleanup = isInViewport(elementRef.current, inViewport => {
performance.mark(`${name}Updated`, {

Check warning on line 103 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L102-L103

Added lines #L102 - L103 were not covered by tests
startTime: timestamp,
detail: {
source: 'awsui',
instanceIdentifier: id,
inViewport,
...getDetails(),
},
});
});
return cleanup;

Check warning on line 113 in src/internal/hooks/use-performance-marks/index.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/index.ts#L113

Added line #L113 was not covered by tests

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [evaluateComponentVisibility, ...dependencies]);

Expand Down
55 changes: 55 additions & 0 deletions src/internal/hooks/use-performance-marks/is-in-viewport.ts
Original file line number Diff line number Diff line change
@@ -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<Element, Callback>();

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: () => {},

Check warning on line 43 in src/internal/hooks/use-performance-marks/is-in-viewport.ts

View check run for this annotation

Codecov / codecov/patch

src/internal/hooks/use-performance-marks/is-in-viewport.ts#L42-L43

Added lines #L42 - L43 were not covered by tests
};
}
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);
}
});
Loading
Loading