perf(landing): share one SSE socket for online counters, respect reduced motion
CI / check (push) Successful in 4m10s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m43s

Header and hero/stats counters each opened their own EventSource to the
online-count stream; a shared subscriber now opens a single socket and
multicasts to every mounted counter. The entrance count-up animation skips
its requestAnimationFrame loop when the user prefers reduced motion.
This commit is contained in:
openhands committed 2026-09-18 12:57:52 +02:00
1 parent 4b5dda467c
commit a6e808a099
4 files changed
+95 -34

No files matched your search

+8 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { motion, useInView } from "motion/react"; import { motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
export function AnimatedCounter({ export function AnimatedCounter({
@@ -12,6 +12,7 @@ export function AnimatedCounter({
}) { }) {
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
const isInView = useInView(ref, { once: true, margin: "-50px" }); const isInView = useInView(ref, { once: true, margin: "-50px" });
const prefersReducedMotion = useReducedMotion();
const started = useRef(false); const started = useRef(false);
const [display, setDisplay] = useState(0); const [display, setDisplay] = useState(0);
@@ -24,6 +25,11 @@ export function AnimatedCounter({
} }
started.current = true; started.current = true;
if (prefersReducedMotion) {
setDisplay(value);
return;
}
const duration = 1200; const duration = 1200;
const start = performance.now(); const start = performance.now();
let raf = 0; let raf = 0;
@@ -41,7 +47,7 @@ export function AnimatedCounter({
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [isInView, value]); }, [isInView, value, prefersReducedMotion]);
return ( return (
<motion.span <motion.span
+5 -16
View File
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useOnlineCount } from "@/lib/online-count-sse";
import { useEventSource } from "@/lib/use-event-source";
/** /**
* Live online user counter. Subscribes to the SSE online-count stream so the * Live online user counter. Shares a single SSE connection (see
* displayed count updates in real-time (multicast) instead of only reflecting * `useOnlineCount`) so the whole page uses one socket instead of one per
* the server-rendered value at page load. * counter.
*/ */
export function LiveOnlineCount({ export function LiveOnlineCount({
initial, initial,
@@ -17,17 +16,7 @@ export function LiveOnlineCount({
hotelName: string; hotelName: string;
}) { }) {
const t = useTranslations("header"); const t = useTranslations("header");
const [count, setCount] = useState(initial); const count = useOnlineCount(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <>{t("online", { count, hotel: hotelName })}</>; return <>{t("online", { count, hotel: hotelName })}</>;
} }
+5 -16
View File
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { AnimatedCounter } from "@/components/animated-counter"; import { AnimatedCounter } from "@/components/animated-counter";
import { useEventSource } from "@/lib/use-event-source"; import { useOnlineCount } from "@/lib/online-count-sse";
/** /**
* Live animated online-count number. Subscribes to the SSE online-count stream * Live animated online-count number. Shares a single SSE connection (see
* so the stat updates in real-time (multicast) instead of only the initial SSR * `useOnlineCount`) so the whole page uses one socket instead of one per
* value, while keeping the entrance counter animation. * counter, while keeping the entrance counter animation.
*/ */
export function LiveOnlineCounter({ export function LiveOnlineCounter({
initial, initial,
@@ -16,17 +15,7 @@ export function LiveOnlineCounter({
initial: number; initial: number;
suffix?: string; suffix?: string;
}) { }) {
const [count, setCount] = useState(initial); const count = useOnlineCount(initial);
const { data: onlineStream } = useEventSource<{ count: number }>(
"/api/online/count/stream",
{ enabled: true },
);
useEffect(() => {
if (onlineStream && typeof onlineStream.count === "number") {
setCount(onlineStream.count);
}
}, [onlineStream]);
return <AnimatedCounter value={count} suffix={suffix} />; return <AnimatedCounter value={count} suffix={suffix} />;
} }
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
type Listener = (count: number) => void;
/* Single shared EventSource for /api/online/count/stream. Every subscribing
* component re-uses one connection (multicast to all listeners) instead of
* opening its own SSE socket per component. */
const MAX_RETRIES = 8;
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 30_000;
let eventSource: EventSource | null = null;
const listeners = new Set<Listener>();
let refCount = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let attempts = 0;
function start() {
if (eventSource || refCount === 0) return;
eventSource = new EventSource("/api/online/count/stream");
eventSource.onopen = () => {
attempts = 0;
};
eventSource.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data) as { count?: number };
if (typeof parsed.count === "number") {
const count = parsed.count;
listeners.forEach((listener) => {
listener(count);
});
}
} catch {
// ignore malformed messages
}
};
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
if (refCount === 0 || attempts >= MAX_RETRIES) return;
const delay = Math.min(BASE_DELAY_MS * 2 ** attempts, MAX_DELAY_MS);
attempts++;
reconnectTimer = setTimeout(start, delay);
};
}
function stop() {
if (refCount > 0 || !eventSource) return;
if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
eventSource.close();
eventSource = null;
attempts = 0;
}
/**
* Live online-user count shared by the header and the hero/stats counters.
* Falls back to the server-rendered `initial` value until the stream reports.
*/
export function useOnlineCount(initial: number): number {
const [count, setCount] = useState(initial);
useEffect(() => {
const listener: Listener = setCount;
refCount++;
listeners.add(listener);
start();
return () => {
listeners.delete(listener);
refCount--;
stop();
};
}, []);
return count;
}