perf: lighthouse fixes — remove Geist fonts, optimize popup image, fix hydration, CSP, SEO
This commit is contained in:
parent
e05cacb37c
commit
f673837c18
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -31,6 +31,8 @@ export const Logo = (props: Props) => {
|
||||
className={clsx(className)}
|
||||
style={{ width: '150px', height: 'auto' }}
|
||||
src={src}
|
||||
width={150}
|
||||
height={88}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user