From 2c6e46f357472b3381d4a913afcfc2bf91038368 Mon Sep 17 00:00:00 2001 From: Jan Riethmayer Date: Mon, 25 May 2026 19:38:08 +0200 Subject: [PATCH] fix: hydration mismatch in the dev strategy switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StrategySwitcher initialised `current` from getOrderStrategy() during render, which reads localStorage on the client but not during SSR — so the highlighted strategy differed between server and client HTML and React regenerated the subtree (the visible "can't see the strategy" symptom). Read the active strategy via useSyncExternalStore: server snapshot = the build-time default (matches SSR + first client render → no mismatch), client snapshot resolves the real value after hydration, and a new subscribeOrderStrategy lets the badge re-render when the strategy changes (cycling). Dev-only component; no production behaviour change. --- src/components/strategy-switcher/index.tsx | 14 ++++++++++---- src/utils/order-strategies.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/strategy-switcher/index.tsx b/src/components/strategy-switcher/index.tsx index 03a7e88..871b8fd 100644 --- a/src/components/strategy-switcher/index.tsx +++ b/src/components/strategy-switcher/index.tsx @@ -1,11 +1,13 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useSyncExternalStore } from "react"; import { useGameStore } from "@/zustand/game-store"; import { + DEFAULT_STRATEGY, getOrderStrategy, listOrderStrategies, setOrderStrategy, + subscribeOrderStrategy, } from "@/utils/order-strategies"; /** @@ -18,8 +20,13 @@ const enabled = process.env.NODE_ENV !== "production"; const StrategySwitcher: React.FC = () => { const redeal = useGameStore((state) => state.redealCurrentLevel); - const [current, setCurrent] = useState(() => - enabled ? getOrderStrategy().name : "" + // Hydration-safe read of the active strategy: SSR + first client render use the + // build-time default; after hydration the client snapshot resolves localStorage. + // Re-renders when setOrderStrategy fires (e.g. cycling). + const current = useSyncExternalStore( + subscribeOrderStrategy, + () => getOrderStrategy().name, + () => DEFAULT_STRATEGY ); const cycle = useCallback( @@ -28,7 +35,6 @@ const StrategySwitcher: React.FC = () => { const idx = names.indexOf(getOrderStrategy().name); const next = names[(idx + dir + names.length) % names.length]; setOrderStrategy(next); - setCurrent(next); redeal(); }, [redeal] diff --git a/src/utils/order-strategies.ts b/src/utils/order-strategies.ts index eec01c4..7332e85 100644 --- a/src/utils/order-strategies.ts +++ b/src/utils/order-strategies.ts @@ -199,6 +199,17 @@ export const listOrderStrategies = (): Array<{ name: string; description: string const DEV = process.env.NODE_ENV !== "production"; let runtimeOverride: string | null = null; +const strategyListeners = new Set<() => void>(); + +/** Subscribe to active-strategy changes. Returns an unsubscribe fn. + * Lets the dev switcher reflect the strategy via React's useSyncExternalStore + * (hydration-safe: server snapshot = DEFAULT, client snapshot = resolved). */ +export const subscribeOrderStrategy = (cb: () => void): (() => void) => { + strategyListeners.add(cb); + return () => { + strategyListeners.delete(cb); + }; +}; /** * Override the active strategy for this session. Pass a name from `STRATEGIES`, @@ -215,6 +226,7 @@ export const setOrderStrategy = (name: string | null): void => { } catch { /* localStorage unavailable (SSR / privacy mode) — in-memory override still applies */ } + strategyListeners.forEach((cb) => cb()); }; /**