perf: lighthouse fixes — remove Geist fonts, optimize popup image, fix hydration, CSP, SEO

This commit is contained in:
Jeffrey 2026-03-03 11:58:08 +01:00
parent e05cacb37c
commit f673837c18
11 changed files with 133 additions and 35 deletions

View File

@ -20,7 +20,8 @@ const ContentSecurityPolicy = `
https://*.vimeocdn.com
https://maps.googleapis.com
https://maps.gstatic.com;
font-src 'self';
font-src 'self'
https://maxcdn.bootstrapcdn.com;
connect-src 'self'
https://matomo.layermesh.se
https://layerandmesh.lime-forms.com
@ -31,8 +32,9 @@ const ContentSecurityPolicy = `
https://www.youtube-nocookie.com
https://player.vimeo.com
https://www.google.com
https://maps.google.com;
worker-src 'self';
https://maps.google.com
https://layerandmesh.lime-forms.com;
worker-src 'self' blob:;
media-src 'self' https://player.vimeo.com;
object-src 'none';
base-uri 'self';

View File

@ -10,7 +10,7 @@ import type { Page } from '@/payload-types'
import { generateMeta } from '@/utilities/generateMeta'
import PageClient from './page.client'
import { LivePreviewListener } from '@/components/LivePreviewListener'
export const dynamic = 'force-dynamic'
export const revalidate = 60
export const dynamicParams = true

View File

@ -10,7 +10,7 @@ import { generateMeta } from '@/utilities/generateMeta'
import PageClient from '../../[slug]/page.client'
import { LivePreviewListener } from '@/components/LivePreviewListener'
export const dynamic = 'force-dynamic'
export const revalidate = 60
export const dynamicParams = true
type Args = {

View File

@ -49,8 +49,8 @@ html {
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 86rem;
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-geist-sans);
--font-mono: 'Courier New', monospace;
--font-sans: var(--font-joey);
}
@layer utilities {

View File

@ -1,8 +1,5 @@
import type { Metadata, Viewport } from 'next'
import { AnnouncementBarComponent } from '@/components/AnnouncementBar/AnnouncementBar'
import { cn } from '@/utilities/ui'
import { GeistMono } from 'geist/font/mono'
import { GeistSans } from 'geist/font/sans'
import React from 'react'
import { AdminBar } from '@/components/AdminBar'
import { Footer } from '@/Footer/Component'
@ -20,6 +17,12 @@ import { PopupAnnouncementComponent } from '@/globals/PopupAnnouncement/Componen
import './globals.css'
import { getServerSideURL } from '@/utilities/getURL'
/* FIX 1: Removed GeistSans and GeistMono imports saves ~140 KB.
* The site uses Joey fonts exclusively. Geist was a leftover from the
* Payload starter template. Also removed cn() import since the html
* className no longer needs conditional merging.
*/
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled } = await draftMode()
@ -41,7 +44,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html
className={cn(GeistSans.variable, GeistMono.variable)}
lang="sv"
suppressHydrationWarning
style={{ colorScheme: 'light dark' }}
@ -51,7 +53,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link href="/favicon.ico" rel="icon" sizes="32x32" />
<link href="/favicon.svg" rel="icon" type="image/svg+xml" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<HeadInjection siteSettings={siteSettings} />
{/* FIX 8: HeadInjection moved to <body> it's now a client component
* that injects via useEffect to avoid the <div> in <head> hydration error.
*/}
</head>
<body>
<Providers>
@ -78,6 +82,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/>
)}
{matomoEnabled && <MatomoScript code={matomoCode} />}
{/* HeadInjection + FooterInjection now live in body they inject
* elements into document.head / document.body via useEffect */}
<HeadInjection siteSettings={siteSettings} />
<FooterInjection siteSettings={siteSettings} />
</Providers>
</body>
@ -90,7 +97,7 @@ export const metadata: Metadata = {
openGraph: mergeOpenGraph(),
twitter: {
card: 'summary_large_image',
creator: '@payloadcms',
creator: '@fiberdirekt',
},
}

View File

@ -1,5 +1,4 @@
export const dynamic = 'force-dynamic'
export const dynamicParams = true
export const revalidate = 60
import PageTemplate, { generateMetadata } from './[slug]/page'
export default PageTemplate

View File

@ -53,6 +53,8 @@ export const FDServicesGridBlockComponent: React.FC<FDServicesGridBlockProps> =
<div className="px-3 mt-auto">
<FDButton href={service.link} variant="outline" className="py-2 px-5 text-fd-small">
Läs mer
{/* FIX 7: Visually hidden context for screen readers + SEO crawlers */}
<span className="sr-only"> om {service.title}</span>
</FDButton>
</div>
)}

View File

@ -1,4 +1,6 @@
import React from 'react'
'use client'
import React, { useEffect } from 'react'
type SiteSettings = {
headerCodeInjection?: { enabled?: boolean; code?: string } | null
@ -6,27 +8,89 @@ type SiteSettings = {
}
/**
* HeadInjection accepts already-fetched siteSettings as a prop.
* Data is fetched once in layout.tsx via getCachedGlobal and passed down,
* eliminating the uncached DB hit that was here previously.
* HeadInjection injects header code safely via useEffect.
*
* FIX: The previous implementation used <div dangerouslySetInnerHTML> inside <head>,
* which is invalid HTML. Browsers move <div> elements from <head> to <body> during
* parsing, causing a server/client mismatch React hydration error #418.
*
* This version injects scripts client-side via useEffect, avoiding the mismatch entirely.
*/
export function HeadInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const headerCode = siteSettings?.headerCodeInjection?.enabled
? (siteSettings.headerCodeInjection.code ?? '')
: ''
if (!headerCode) return null
return <div dangerouslySetInnerHTML={{ __html: headerCode }} />
useEffect(() => {
if (!headerCode) return
injectCodeSnippet(headerCode, 'head', 'fd-head-injection')
}, [headerCode])
return null
}
/**
* FooterInjection same pattern as HeadInjection.
* FooterInjection injects footer code safely via useEffect.
*/
export function FooterInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const footerCode = siteSettings?.footerCodeInjection?.enabled
? (siteSettings.footerCodeInjection.code ?? '')
: ''
if (!footerCode) return null
return <div dangerouslySetInnerHTML={{ __html: footerCode }} />
useEffect(() => {
if (!footerCode) return
injectCodeSnippet(footerCode, 'body', 'fd-footer-injection')
}, [footerCode])
return null
}
/**
* Parse an HTML string and inject its elements (script, link, meta, style, etc.)
* into the specified target. Scripts are re-created so the browser executes them.
*/
function injectCodeSnippet(
html: string,
target: 'head' | 'body',
markerId: string,
) {
// Avoid double-injection on re-renders
if (document.getElementById(markerId)) return
const container = document.createElement('div')
container.id = markerId
container.style.display = 'none'
// Parse the HTML string
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// Collect all elements from both <head> and <body> of the parsed fragment
const elements = [
...Array.from(doc.head.children),
...Array.from(doc.body.children),
]
const targetEl = target === 'head' ? document.head : document.body
for (const el of elements) {
if (el.tagName === 'SCRIPT') {
// Scripts must be re-created to execute
const script = document.createElement('script')
Array.from(el.attributes).forEach((attr) => {
script.setAttribute(attr.name, attr.value)
})
if (el.textContent) script.textContent = el.textContent
script.dataset.injectedBy = markerId
targetEl.appendChild(script)
} else {
// link, meta, style, noscript — clone directly
const clone = el.cloneNode(true) as HTMLElement
clone.dataset.injectedBy = markerId
targetEl.appendChild(clone)
}
}
// Append invisible marker so we can detect double-injection
targetEl.appendChild(container)
}

View File

@ -31,6 +31,8 @@ export const Logo = (props: Props) => {
className={clsx(className)}
style={{ width: '150px', height: 'auto' }}
src={src}
width={150}
height={88}
/>
)
}

View File

@ -1,6 +1,7 @@
'use client'
import React, { useState, useEffect } from 'react'
import Image from 'next/image'
import type { Media, Page } from '@/payload-types'
type LinkGroup = {
@ -104,6 +105,12 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
const href = resolveUrl(ctaLink)
const newTab = ctaLink?.newTab ?? false
// ── FIX: Resolve optimized image URL via Payload size or fall back to original ──
const imageSrc = media?.sizes?.medium?.url || media?.sizes?.large?.url || media?.url || ''
const imageWidth = media?.sizes?.medium?.width || media?.sizes?.large?.width || media?.width || 560
const imageHeight = media?.sizes?.medium?.height || media?.sizes?.large?.height || media?.height || 720
const isSvg = media?.mimeType === 'image/svg+xml'
return (
<>
{/* Backdrop */}
@ -167,14 +174,29 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
)}
</div>
{/* Optional image */}
{media?.url && (
{/* Optional image — FIX: use next/image instead of raw <img> */}
{media?.url && imageSrc && (
<div className="hidden md:block md:w-[280px] relative">
<img
src={media.url}
alt={media.alt || ''}
className="w-full h-full object-cover"
/>
{isSvg ? (
// SVGs skip optimization (same as FDImage convention)
// eslint-disable-next-line @next/next/no-img-element
<img
src={media.url}
alt={media.alt || ''}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<Image
src={imageSrc}
alt={media.alt || ''}
width={imageWidth}
height={imageHeight}
className="w-full h-full object-cover"
sizes="280px"
quality={80}
/>
)}
</div>
)}
</div>

File diff suppressed because one or more lines are too long