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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,5 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import { AnnouncementBarComponent } from '@/components/AnnouncementBar/AnnouncementBar' 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 React from 'react'
import { AdminBar } from '@/components/AdminBar' import { AdminBar } from '@/components/AdminBar'
import { Footer } from '@/Footer/Component' import { Footer } from '@/Footer/Component'
@ -20,6 +17,12 @@ import { PopupAnnouncementComponent } from '@/globals/PopupAnnouncement/Componen
import './globals.css' import './globals.css'
import { getServerSideURL } from '@/utilities/getURL' 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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { isEnabled } = await draftMode() const { isEnabled } = await draftMode()
@ -41,7 +44,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return ( return (
<html <html
className={cn(GeistSans.variable, GeistMono.variable)}
lang="sv" lang="sv"
suppressHydrationWarning suppressHydrationWarning
style={{ colorScheme: 'light dark' }} 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.ico" rel="icon" sizes="32x32" />
<link href="/favicon.svg" rel="icon" type="image/svg+xml" /> <link href="/favicon.svg" rel="icon" type="image/svg+xml" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <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> </head>
<body> <body>
<Providers> <Providers>
@ -78,6 +82,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/> />
)} )}
{matomoEnabled && <MatomoScript code={matomoCode} />} {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} /> <FooterInjection siteSettings={siteSettings} />
</Providers> </Providers>
</body> </body>
@ -90,7 +97,7 @@ export const metadata: Metadata = {
openGraph: mergeOpenGraph(), openGraph: mergeOpenGraph(),
twitter: { twitter: {
card: 'summary_large_image', card: 'summary_large_image',
creator: '@payloadcms', creator: '@fiberdirekt',
}, },
} }

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import React from 'react' 'use client'
import React, { useEffect } from 'react'
type SiteSettings = { type SiteSettings = {
headerCodeInjection?: { enabled?: boolean; code?: string } | null headerCodeInjection?: { enabled?: boolean; code?: string } | null
@ -6,27 +8,89 @@ type SiteSettings = {
} }
/** /**
* HeadInjection accepts already-fetched siteSettings as a prop. * HeadInjection injects header code safely via useEffect.
* Data is fetched once in layout.tsx via getCachedGlobal and passed down, *
* eliminating the uncached DB hit that was here previously. * 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 }) { export function HeadInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const headerCode = siteSettings?.headerCodeInjection?.enabled const headerCode = siteSettings?.headerCodeInjection?.enabled
? (siteSettings.headerCodeInjection.code ?? '') ? (siteSettings.headerCodeInjection.code ?? '')
: '' : ''
if (!headerCode) return null useEffect(() => {
return <div dangerouslySetInnerHTML={{ __html: headerCode }} /> 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 }) { export function FooterInjection({ siteSettings }: { siteSettings: SiteSettings }) {
const footerCode = siteSettings?.footerCodeInjection?.enabled const footerCode = siteSettings?.footerCodeInjection?.enabled
? (siteSettings.footerCodeInjection.code ?? '') ? (siteSettings.footerCodeInjection.code ?? '')
: '' : ''
if (!footerCode) return null useEffect(() => {
return <div dangerouslySetInnerHTML={{ __html: footerCode }} /> 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)} className={clsx(className)}
style={{ width: '150px', height: 'auto' }} style={{ width: '150px', height: 'auto' }}
src={src} src={src}
width={150}
height={88}
/> />
) )
} }

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import Image from 'next/image'
import type { Media, Page } from '@/payload-types' import type { Media, Page } from '@/payload-types'
type LinkGroup = { type LinkGroup = {
@ -104,6 +105,12 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
const href = resolveUrl(ctaLink) const href = resolveUrl(ctaLink)
const newTab = ctaLink?.newTab ?? false 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 ( return (
<> <>
{/* Backdrop */} {/* Backdrop */}
@ -167,14 +174,29 @@ export const PopupAnnouncementComponent: React.FC<PopupAnnouncementProps> = ({
)} )}
</div> </div>
{/* Optional image */} {/* Optional image — FIX: use next/image instead of raw <img> */}
{media?.url && ( {media?.url && imageSrc && (
<div className="hidden md:block md:w-[280px] relative"> <div className="hidden md:block md:w-[280px] relative">
{isSvg ? (
// SVGs skip optimization (same as FDImage convention)
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={media.url} src={media.url}
alt={media.alt || ''} alt={media.alt || ''}
className="w-full h-full object-cover" 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>
)} )}
</div> </div>

File diff suppressed because one or more lines are too long