perf(landing): share one SSE socket for online counters, respect reduced motion
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:
1 parent
4b5dda467c
commit
a6e808a099
4 files changed
+95
-34
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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 })}</>;
|
||||||
}
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user