fix: optimize PageSpeed Insights performance (#115)
* fix: optimize PageSpeed Insights performance This commit addresses several PageSpeed Insights issues to improve the performance score from 56 to a target of 90+. Changes: - Add SWC config to output modern ES2022 JavaScript, eliminating unnecessary polyfills (~12 KiB savings) - Convert vocalinux.png to WebP format (17KB vs 207KB - 92% reduction) - Add OptimizedImage component with WebP/PNG fallback support - Add _headers file for proper static asset caching (1 year cache for immutable assets) - Configure Next.js with optimizePackageImports for framer-motion and lucide-react - Fix heading hierarchy issue (Quick Install h3 -> h2) - Add console.log removal in production builds Expected improvements: - Image delivery: ~41 KiB savings - Legacy JavaScript: ~12 KiB savings - Cache lifetime: 754 KiB proper caching - Accessibility score: 98 -> 100 (heading order fix) * test: add coverage for PageSpeed optimization Add comprehensive test suite for PageSpeed Insights optimization changes: - WebP image loading tests (optimized-image.tsx) - next/image component configuration tests (next.config.js) - _headers file generation and cache header tests - Lazy loading behavior tests Includes: - Jest configuration with TypeScript support - React Testing Library setup - 54 test cases covering all PageSpeed optimization features - Build verification All tests pass: npm run test Build passes: npm run build * ci: trigger workflow * ci: trigger workflow run * ci: trigger workflow
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { OptimizedImage, VocalinuxLogo } from '../optimized-image';
|
||||
|
||||
// Mock Next.js Image component
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { priority, ...imgProps } = props;
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img {...imgProps} alt={props.alt} />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('OptimizedImage', () => {
|
||||
const defaultProps = {
|
||||
src: '/test-image.png',
|
||||
alt: 'Test Image',
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
|
||||
it('renders without crashing', () => {
|
||||
render(<OptimizedImage {...defaultProps} />);
|
||||
const img = screen.getByAltText('Test Image');
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a picture element with WebP source', () => {
|
||||
const { container } = render(<OptimizedImage {...defaultProps} />);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toBeInTheDocument();
|
||||
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toBeInTheDocument();
|
||||
expect(source).toHaveAttribute('srcset', '/test-image.webp');
|
||||
expect(source).toHaveAttribute('type', 'image/webp');
|
||||
});
|
||||
|
||||
it('provides PNG fallback for browsers without WebP support', () => {
|
||||
render(<OptimizedImage {...defaultProps} />);
|
||||
const img = screen.getByAltText('Test Image');
|
||||
expect(img).toHaveAttribute('src', '/test-image.png');
|
||||
});
|
||||
|
||||
it('converts jpg to webp in source srcset', () => {
|
||||
const { container } = render(
|
||||
<OptimizedImage {...defaultProps} src="/image.jpg" />
|
||||
);
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toHaveAttribute('srcset', '/image.webp');
|
||||
});
|
||||
|
||||
it('converts jpeg to webp in source srcset', () => {
|
||||
const { container } = render(
|
||||
<OptimizedImage {...defaultProps} src="/image.jpeg" />
|
||||
);
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toHaveAttribute('srcset', '/image.webp');
|
||||
});
|
||||
|
||||
it('handles case-insensitive extensions', () => {
|
||||
const { container } = render(
|
||||
<OptimizedImage {...defaultProps} src="/image.PNG" />
|
||||
);
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toHaveAttribute('srcset', '/image.webp');
|
||||
});
|
||||
|
||||
it('applies custom className to picture element', () => {
|
||||
const { container } = render(
|
||||
<OptimizedImage {...defaultProps} className="custom-class" />
|
||||
);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('passes priority prop to Image component', () => {
|
||||
render(<OptimizedImage {...defaultProps} priority />);
|
||||
const img = screen.getByAltText('Test Image');
|
||||
// Priority images should not have loading="lazy"
|
||||
expect(img).not.toHaveAttribute('loading', 'lazy');
|
||||
});
|
||||
|
||||
it('sets correct dimensions on the image', () => {
|
||||
render(<OptimizedImage {...defaultProps} width={200} height={150} />);
|
||||
const img = screen.getByAltText('Test Image');
|
||||
expect(img).toHaveAttribute('width', '200');
|
||||
expect(img).toHaveAttribute('height', '150');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VocalinuxLogo', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<VocalinuxLogo />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a picture element with WebP source for logo', () => {
|
||||
const { container } = render(<VocalinuxLogo />);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toBeInTheDocument();
|
||||
|
||||
const source = container.querySelector('source');
|
||||
expect(source).toBeInTheDocument();
|
||||
expect(source).toHaveAttribute('srcset', '/vocalinux.webp');
|
||||
expect(source).toHaveAttribute('type', 'image/webp');
|
||||
});
|
||||
|
||||
it('provides PNG fallback for the logo', () => {
|
||||
render(<VocalinuxLogo />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('src', '/vocalinux.png');
|
||||
});
|
||||
|
||||
it('uses default dimensions when not specified', () => {
|
||||
render(<VocalinuxLogo />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('width', '32');
|
||||
expect(img).toHaveAttribute('height', '32');
|
||||
});
|
||||
|
||||
it('accepts custom dimensions', () => {
|
||||
render(<VocalinuxLogo width={64} height={64} />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).toHaveAttribute('width', '64');
|
||||
expect(img).toHaveAttribute('height', '64');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<VocalinuxLogo className="logo-class" />);
|
||||
const picture = container.querySelector('picture');
|
||||
expect(picture).toHaveClass('logo-class');
|
||||
});
|
||||
|
||||
it('passes priority prop for eager loading', () => {
|
||||
render(<VocalinuxLogo priority />);
|
||||
const img = screen.getByAltText('Vocalinux');
|
||||
expect(img).not.toHaveAttribute('loading', 'lazy');
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,11 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
// Logo component using the actual application icon
|
||||
export const VocalinuxLogo: React.FC<{ className?: string }> = ({ className = "" }) => {
|
||||
// Re-export from optimized-image component for backward compatibility
|
||||
export { VocalinuxLogo } from "./optimized-image";
|
||||
|
||||
// Legacy export - use VocalinuxLogo from optimized-image instead
|
||||
export const VocalinuxLogoLegacy: React.FC<{ className?: string }> = ({ className = "" }) => {
|
||||
return (
|
||||
<Image
|
||||
src="/vocalinux.png"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface OptimizedImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized image component that uses WebP format with PNG fallback.
|
||||
* Browsers that support WebP will load the smaller WebP file, while older
|
||||
* browsers will fallback to PNG format.
|
||||
*/
|
||||
export const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
src,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
className = "",
|
||||
priority = false,
|
||||
}) => {
|
||||
// Get WebP version of the image
|
||||
const webpSrc = src.replace(/\.(png|jpg|jpeg)$/i, '.webp');
|
||||
|
||||
// For Next.js Image component with priority and proper optimization
|
||||
// we use the Next.js Image with WebP source
|
||||
return (
|
||||
<picture className={className}>
|
||||
<source srcSet={webpSrc} type="image/webp" />
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority={priority}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Specialized Vocalinux logo component with WebP support
|
||||
*/
|
||||
export const VocalinuxLogo: React.FC<{
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
priority?: boolean;
|
||||
}> = ({ className = "", width = 32, height = 32, priority = false }) => {
|
||||
return (
|
||||
<picture className={className}>
|
||||
<source srcSet="/vocalinux.webp" type="image/webp" />
|
||||
<Image
|
||||
src="/vocalinux.png"
|
||||
alt="Vocalinux"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
priority={priority}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user