diff --git a/packages/color-area/stories/memory-usage.stories.ts b/packages/color-area/stories/memory-usage.stories.ts new file mode 100644 index 0000000000..9fec7cd8c2 --- /dev/null +++ b/packages/color-area/stories/memory-usage.stories.ts @@ -0,0 +1,235 @@ +/* +Copyright 2023 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { html, TemplateResult } from '@spectrum-web-components/base'; + +export default { + title: 'Color/Area/Memory Usage', + component: 'sp-color-area', + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +// Story to demonstrate memory usage of ColorArea +export const MemoryUsage = (): TemplateResult => { + return html` +
+

Color Area Memory Usage Analysis

+

+ This story demonstrates the memory usage of the Color Area + component compared to other components. The Color Area component + uses canvas operations for color selection, which inherently + requires more memory than standard DOM elements. +

+ +
+ Run Memory Test + + Clear Results + +
+ +
+

Test Results

+
+
+ +
+

Technical Explanation

+

+ The Color Area component has a higher memory footprint due + to: +

+ +

+ This is expected behavior and not a memory leak. The + component properly cleans up resources in its + disconnectedCallback method, ensuring that memory usage + remains consistent over multiple create/destroy cycles. +

+
+
+ + + `; +}; diff --git a/packages/color-area/test/color-area-memory.test.ts b/packages/color-area/test/color-area-memory.test.ts index fe5b807046..7e3334a489 100644 --- a/packages/color-area/test/color-area-memory.test.ts +++ b/packages/color-area/test/color-area-memory.test.ts @@ -11,8 +11,21 @@ governing permissions and limitations under the License. import { html } from '@open-wc/testing'; import '@spectrum-web-components/color-area/sp-color-area.js'; -import { testForMemoryLeaks } from '../../../test/testing-helpers.js'; +import { testCanvasComponentMemory } from '../../../test/testing-helpers.js'; +import type { ColorArea } from '@spectrum-web-components/color-area'; -testForMemoryLeaks(html` - -`); +// Test ColorArea memory patterns +testCanvasComponentMemory( + html` + + `, + { + componentName: 'ColorArea', + // Test color changes which trigger canvas redraws + manipulate: async (component) => { + const colorArea = component as ColorArea; + colorArea.color = { space: 'hsv', coords: [250, 90, 80] }; + await colorArea.updateComplete; + }, + } +); diff --git a/test/testing-helpers.ts b/test/testing-helpers.ts index 34ac3b0dd9..8760e2a5e4 100644 --- a/test/testing-helpers.ts +++ b/test/testing-helpers.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /* Copyright 2020 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -372,3 +373,174 @@ export function detectOS(): string | null { return null; } + +export async function forceGarbageCollection() { + // Run multiple GC attempts with delays + for (let i = 0; i < 3; i++) { + if (window.gc) { + window.gc(); + } // Only works in dev mode with --expose-gc + + // Create pressure + const pressure = new Array(1000000).fill(0); + pressure.length = 0; + + await new Promise((r) => setTimeout(r, 100)); + } +} + +/** + * Format bytes to a readable format + */ +export function formatBytes(bytes: number) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +/** + * Tests a canvas-based component for acceptable memory usage patterns + * + * @param {import('@open-wc/testing').TemplateResult} template - The component template to test + * @param {Object} options - Test configuration options + * @param {string} options.componentName - Component name for logging purposes + * @param {number} [options.maxRetentionRatio=0.3] - Maximum acceptable retention ratio (0-1) + * @param {number} [options.maxGrowthRate=0.05] - Maximum acceptable average growth rate (0-1) + * @param {number} [options.cycles=3] - Number of cycles to test + * @param {Function} [options.manipulate] - Custom manipulation function + */ +export function testCanvasComponentMemory( + template: TemplateResult, + options: { + componentName: string; + maxRetentionRatio?: number; + maxGrowthRate?: number; + cycles?: number; + manipulate?: (component: HTMLElement) => Promise; + } +) { + const { + componentName, + maxRetentionRatio = 0.3, + maxGrowthRate = 0.05, + cycles = 3, + manipulate, + } = options; + + describe(`${componentName} Memory Usage`, () => { + it('shows acceptable memory patterns for canvas operations', async function () { + // Check if the API is available + if ( + !window.gc || + !('measureUserAgentSpecificMemory' in performance) + ) { + this.skip(); + } + + // Initial memory baseline + await forceGarbageCollection(); + // @ts-expect-error - expect typescript error + const baseline = await performance.measureUserAgentSpecificMemory(); + + // Create component with canvas + const component = await fixture(template); + // @ts-expect-error - expect typescript error + await component.updateComplete; + + // Run custom manipulation if provided + if (manipulate) { + await manipulate(component); + } + + // First usage measurement + // @ts-expect-error - expect typescript error + // eslint-disable-next-line prettier/prettier + const firstUsage = + await performance.measureUserAgentSpecificMemory(); + + // Calculate initial allocation + const initialAllocation = firstUsage.bytes - baseline.bytes; + console.log( + `Initial ${componentName} allocation: ${formatBytes(initialAllocation)}` + ); + + // Remove component + component.remove(); + await forceGarbageCollection(); + + // Measure after destruction + // @ts-expect-error - expect typescript error + // eslint-disable-next-line prettier/prettier + const afterDestroy = + await performance.measureUserAgentSpecificMemory(); + + // Calculate retained memory + const retainedMemory = afterDestroy.bytes - baseline.bytes; + const retentionRatio = retainedMemory / initialAllocation; + console.log(`Retained memory: ${formatBytes(retainedMemory)}`); + console.log( + `Retention ratio: ${(retentionRatio * 100).toFixed(2)}%` + ); + + // Check if retention is within acceptable limits + expect(retentionRatio).to.be.lessThan( + maxRetentionRatio, + `${componentName} should not retain more than ${maxRetentionRatio * 100}% of memory after destruction` + ); + + // Now test for unbounded growth with multiple cycles + let previousRetained = retainedMemory; + const growthRate: number[] = []; + + // Run multiple cycles + for (let i = 0; i < cycles; i++) { + // Create new component + const newComponent = await fixture(template); + // @ts-expect-error - performance.measureUserAgentSpecificMemory() is not in the TypeScript types yet + + await newComponent.updateComplete; + + // Run custom manipulation if provided + if (manipulate) { + await manipulate(newComponent); + } + + // Destroy component + newComponent.remove(); + await forceGarbageCollection(); + + // Measure memory + // @ts-expect-error - performance.measureUserAgentSpecificMemory() is not in the TypeScript types yet + + // eslint-disable-next-line prettier/prettier + const currentMemory = + await performance.measureUserAgentSpecificMemory(); + const currentRetained = currentMemory.bytes - baseline.bytes; + + // Calculate growth rate + if (previousRetained > 0) { + const cycle_growth = + (currentRetained - previousRetained) / previousRetained; + growthRate.push(cycle_growth as never); + console.log( + `Cycle ${i + 1} growth rate: ${(cycle_growth * 100).toFixed(2)}%` + ); + } + + previousRetained = currentRetained; + } + + // Calculate average growth rate + const avgGrowthRate = + growthRate.reduce((sum, rate) => sum + rate, 0) / + growthRate.length; + console.log( + `Average growth rate per cycle: ${(avgGrowthRate * 100).toFixed(2)}%` + ); + + // Average growth rate should be small + expect(avgGrowthRate).to.be.lessThan( + maxGrowthRate, + `${componentName} should not grow more than ${maxGrowthRate * 100}% per cycle` + ); + }); + }); +}