From 17a44df7bda306fa693984f49520cd944eaa6eb4 Mon Sep 17 00:00:00 2001 From: haotool Date: Wed, 8 Jul 2026 14:46:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(ratewise):=20persisted=20=E6=AC=84=E4=BD=8D?= =?UTF-8?q?=20hydration=20=E6=9A=B4=E9=9C=B2=E9=9D=A2=E7=9B=A4=E9=BB=9E?= =?UTF-8?q?=E8=88=87=20Settings=20=E5=90=8C=E6=A7=8B=E7=A0=B4=E5=8F=A3?= =?UTF-8?q?=E4=BF=AE=E5=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings 套用 issue 664 同款 two-pass gate:variant、rateMode、theme、customPrimary、 splash 首幀恆用 SSG 預設,layout effect 於 paint 前切回 persisted 偏好,remount 不重演 - 修復 converterStore onRehydrateStorage TDZ 死路:同步 storage 下後置回呼於 create() 內觸發時模組綁定仍在 TDZ,改用回呼參數 state 走閉包,wave-A 遷移與 sanitize 恢復生效 - 新增 Settings 首幀契約測試(client-only 首幀取樣,壞版突變必紅)與 converterStore rehydrate 契約測試(模組綁定壞版必紅) - 新增 e2e settings-hydration.spec.ts:三組高風險 persisted 組合冷載 console 零錯誤 測試:vitest run 全綠(9331)、settings-hydration 與 converter-v2 e2e 34 passed、typecheck 過 Co-authored-by: Cursor --- .../persisted-hydration-inventory-666.md | 5 + .../src/pages/Settings.first-frame.test.tsx | 342 ++++++++++++++++++ apps/ratewise/src/pages/Settings.tsx | 70 +++- .../converterStore.rehydrate.test.ts | 71 ++++ apps/ratewise/src/stores/converterStore.ts | 12 +- .../tests/e2e/settings-hydration.spec.ts | 108 ++++++ .../dev/002_development_reward_penalty_log.md | 7 +- 7 files changed, 598 insertions(+), 17 deletions(-) create mode 100644 .changeset/persisted-hydration-inventory-666.md create mode 100644 apps/ratewise/src/pages/Settings.first-frame.test.tsx create mode 100644 apps/ratewise/src/stores/__tests__/converterStore.rehydrate.test.ts create mode 100644 apps/ratewise/tests/e2e/settings-hydration.spec.ts diff --git a/.changeset/persisted-hydration-inventory-666.md b/.changeset/persisted-hydration-inventory-666.md new file mode 100644 index 000000000..aefd79385 --- /dev/null +++ b/.changeset/persisted-hydration-inventory-666.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修復設定頁冷載間歇性錯誤與舊版偏好遺失:persisted 偏好(單幣別版面、匯率模式、主題、啟動畫面)於設定頁首幀不再與預渲染內容衝突;早期 wave-A 版面偏好與損毀資料的自動遷移/修復機制恢復生效,舊使用者的等值雙列偏好不再於冷載遺失。 diff --git a/apps/ratewise/src/pages/Settings.first-frame.test.tsx b/apps/ratewise/src/pages/Settings.first-frame.test.tsx new file mode 100644 index 000000000..9a3c587f2 --- /dev/null +++ b/apps/ratewise/src/pages/Settings.first-frame.test.tsx @@ -0,0 +1,342 @@ +/** + * Settings persisted 欄位首幀契約(issue #666,比照 #664 client-only 首幀取樣模式)。 + * + * /settings 為預渲染頁(APP_ONLY_PRERENDER_PATHS);persisted 偏好 + * (singleConverterVariant/rateMode/theme style/customPrimary/splash)若於首次 + * render 直讀,client render 路徑(hydration de-opt/早期更新 #423/SSG fallback guard) + * 首幀輸出會偏離 SSG HTML(#653 同族 #418 破口)。 + * + * 宣稱範圍(誠實標注,同 #664):jsdom 中 React 的 hydration render 對 uSES 恆讀 + * getServerSnapshot、store 訂閱 commit 後才建立,壞版無法以 hydration 錯誤重演; + * 壞版鑑別由「client-only 首幀取樣」承擔——callback ref 於首次 commit 的 layout 階段 + * (gate 的 layout effect 切換 re-render 之前)同步取樣 DOM: + * - 修法版:首幀全部為 SSG 預設(zen/auto/legacy/splash on)→ 綠 + * - 壞版(移除 hydrated gate 或初值 true):首幀即 persisted 值 → 紅 + * runtime mismatch 防回歸由 e2e settings-hydration.spec.ts(persisted 冷載 console 斷言)承擔。 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { act, createElement, type ReactNode } from 'react'; +import '@testing-library/jest-dom/vitest'; +import { MemoryRouter } from 'react-router-dom'; +import Settings, { resetSettingsHydrationForTests } from './Settings'; +import { useConverterStore } from '../stores/converterStore'; + +vi.mock('../components/SEOHelmet', () => ({ + SEOHelmet: () => null, +})); + +vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: () => {}, + }, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock('motion/react', () => ({ + AnimatePresence: ({ children }: { children?: ReactNode }) => children ?? null, + motion: new Proxy( + {}, + { + get: + (_, tag: string) => + ({ + children, + whileHover: _whileHover, + whileTap: _whileTap, + layoutId: _layoutId, + transition: _transition, + animate: _animate, + initial: _initial, + ...props + }: Record) => + createElement(tag, props, children as ReactNode), + }, + ), +})); + +const THEME_STORAGE_KEY = 'ratewise-theme'; +const SPLASH_STORAGE_KEY = 'ratewise-splash-enabled'; + +/** 首幀取樣結構:涵蓋盤點表中 /settings 的全部破口欄位消費點。 */ +interface SettingsFrameSample { + /** singleConverterVariant:v2 選項 aria-pressed。 */ + variantV2Pressed: string | null; + /** rateMode:sell 選項 aria-pressed。 */ + rateModeSellPressed: string | null; + /** theme style:nitro 風格卡 aria-pressed。 */ + nitroPressed: string | null; + /** splash 偏好:switch aria-checked。 */ + splashChecked: string | null; + /** URL override 提示 badge 是否存在(結構性節點差異)。 */ + hasOverrideBadge: boolean; + /** customPrimary:自訂主題卡 inline style 的 ring 色值。 */ + customCardStyle: string; +} + +function findByText(node: ParentNode, selector: string, text: string): Element | null { + return ( + Array.from(node.querySelectorAll(selector)).find((el) => el.textContent?.includes(text)) ?? null + ); +} + +function sampleSettingsFrame(node: ParentNode): SettingsFrameSample { + const nitroButton = Array.from(node.querySelectorAll('button')).find((el) => + el.getAttribute('aria-label')?.includes('styles.nitro'), + ); + const customButton = Array.from(node.querySelectorAll('button')).find((el) => + el.getAttribute('aria-label')?.includes('styles.custom'), + ); + return { + variantV2Pressed: + node.querySelector('[data-testid="converter-variant-v2"]')?.getAttribute('aria-pressed') ?? + null, + rateModeSellPressed: + findByText(node, 'button', 'settings.rateModeSell')?.getAttribute('aria-pressed') ?? null, + nitroPressed: nitroButton?.getAttribute('aria-pressed') ?? null, + splashChecked: node.querySelector('[role="switch"]')?.getAttribute('aria-checked') ?? null, + hasOverrideBadge: + node.querySelector('[data-testid="converter-variant-override-badge"]') !== null, + customCardStyle: customButton?.getAttribute('style') ?? '', + }; +} + +/** SSG 預設幀(server snapshot 契約值):zen/auto/legacy/splash on/品牌藍。 */ +const SSG_DEFAULT_FRAME = { + variantV2Pressed: 'false', + rateModeSellPressed: 'false', + nitroPressed: 'false', + splashChecked: 'true', + hasOverrideBadge: false, +} as const; + +/** 寫入全部破口欄位的非預設 persisted 值。 */ +function seedNonDefaultPersistedState() { + window.localStorage.setItem( + THEME_STORAGE_KEY, + JSON.stringify({ style: 'nitro', customPrimary: '#FF6B6B' }), + ); + window.localStorage.setItem(SPLASH_STORAGE_KEY, '0'); + useConverterStore.setState({ singleConverterVariant: 'v2', rateMode: 'sell' }); +} + +const settingsElement = ( + + + +); + +describe('Settings persisted 欄位首幀契約(#666)', () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState(null, '', '/settings'); + useConverterStore.setState({ singleConverterVariant: 'legacy', rateMode: 'auto' }); + resetSettingsHydrationForTests(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + // 真 SSG 契約:window/document/localStorage 不存在(Node build 環境), + // 即使 converterStore module state 已被污染,renderToString 輸出仍為預設 + // (zustand uSES 於 SSR 讀 getInitialState,theme/splash initializer 走 SSR 分支)。 + it('SSG server snapshot:persisted 非預設值不得影響預渲染輸出', () => { + useConverterStore.setState({ singleConverterVariant: 'v2', rateMode: 'sell' }); + + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalLocalStorage = globalThis.localStorage; + for (const key of ['window', 'document', 'localStorage'] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: undefined, + }); + } + + let html = ''; + try { + html = renderToString(settingsElement); + } finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: originalWindow, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: originalDocument, + }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: originalLocalStorage, + }); + } + + const container = document.createElement('div'); + container.innerHTML = html; + expect(sampleSettingsFrame(container)).toMatchObject(SSG_DEFAULT_FRAME); + expect(container.querySelector('[data-testid="converter-variant-legacy"]')).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }); + + // 壞版鑑別核心(同 #664):client render 路徑首次 commit 不得讀取 persisted 偏好。 + // callback ref 於首次 commit 的 layout 階段(gate layout effect 切換 re-render 之前) + // 同步取樣 DOM;移除 gate(或 hydrated 初值 true)時首幀即 persisted 值 → 紅。 + it('client-only 首幀契約:persisted 非預設值首次 commit 恆為 SSG 預設、切換後呈現偏好(壞版必紅)', async () => { + seedNonDefaultPersistedState(); + + const firstFrames: SettingsFrameSample[] = []; + const probeRef = (node: HTMLDivElement | null) => { + if (node !== null && firstFrames.length === 0) { + firstFrames.push(sampleSettingsFrame(node)); + } + }; + + const { container } = render(
{settingsElement}
); + + expect(firstFrames).toHaveLength(1); + expect(firstFrames[0]).toMatchObject(SSG_DEFAULT_FRAME); + expect(firstFrames[0]?.customCardStyle).toContain('#3182F6'); + expect(firstFrames[0]?.customCardStyle).not.toContain('#FF6B6B'); + + // layout effect 切換後:全部欄位呈現 persisted 偏好,無殘留預設值。 + await vi.waitFor(() => { + expect(sampleSettingsFrame(container)).toMatchObject({ + variantV2Pressed: 'true', + rateModeSellPressed: 'true', + nitroPressed: 'true', + splashChecked: 'false', + hasOverrideBadge: false, + }); + }); + expect(sampleSettingsFrame(container).customCardStyle).toContain('#FF6B6B'); + }); + + // URL override badge 為結構性節點(有/無),首幀出現即 #425/#418 同族破口: + // effective variant 首幀必須走 server snapshot(legacy),badge 於切換後才可出現。 + it('URL override badge:首幀不得出現、hydration 完成後才顯示', async () => { + window.history.replaceState(null, '', '/settings?converter=v2'); + + const firstFrames: SettingsFrameSample[] = []; + const probeRef = (node: HTMLDivElement | null) => { + if (node !== null && firstFrames.length === 0) { + firstFrames.push(sampleSettingsFrame(node)); + } + }; + + const { container } = render(
{settingsElement}
); + + expect(firstFrames[0]?.hasOverrideBadge).toBe(false); + + await vi.waitFor(() => { + expect(sampleSettingsFrame(container).hasOverrideBadge).toBe(true); + }); + }); + + // SPA 導覽 remount:本次 page load 已完成 hydration,首幀直接依 persisted 偏好渲染, + // 不重演 two-pass(零閃爍;同 #664 模組級旗標設計)。 + it('SPA remount:hydration 已完成後首幀直接呈現 persisted 偏好', () => { + seedNonDefaultPersistedState(); + + // 第一次 mount:完成 two-pass,模組旗標翻真。 + const first = render(settingsElement); + first.unmount(); + + const firstFrames: SettingsFrameSample[] = []; + const probeRef = (node: HTMLDivElement | null) => { + if (node !== null && firstFrames.length === 0) { + firstFrames.push(sampleSettingsFrame(node)); + } + }; + render(
{settingsElement}
); + + expect(firstFrames[0]).toMatchObject({ + variantV2Pressed: 'true', + rateModeSellPressed: 'true', + splashChecked: 'false', + }); + }); + + // hydration 契約(宣稱範圍見檔頭):SSG HTML + persisted 非預設值 hydrate, + // onRecoverableError 零錯誤;hydration 窗口內 store 更新(#653 情境)不得產生破口。 + it('persisted 非預設值 hydration:onRecoverableError 零錯誤、commit 後切 persisted 偏好', async () => { + // 先產出乾淨 SSG HTML(真 SSG 環境模擬)。 + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalLocalStorage = globalThis.localStorage; + for (const key of ['window', 'document', 'localStorage'] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: undefined, + }); + } + let html = ''; + try { + html = renderToString(settingsElement); + } finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: originalWindow, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: originalDocument, + }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: originalLocalStorage, + }); + } + + seedNonDefaultPersistedState(); + + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + + const recoverableErrors: unknown[] = []; + let root: Root | undefined; + act(() => { + root = hydrateRoot(container, settingsElement, { + onRecoverableError: (error) => recoverableErrors.push(error), + }); + // 模擬 rehydrate/遷移於 hydration 同一同步窗口內觸發的 store 更新(#653 破口情境)。 + useConverterStore.setState({ singleConverterVariant: 'legacy' }); + useConverterStore.setState({ singleConverterVariant: 'v2' }); + }); + + expect(recoverableErrors).toEqual([]); + + await vi.waitFor(() => { + expect(sampleSettingsFrame(container)).toMatchObject({ + variantV2Pressed: 'true', + rateModeSellPressed: 'true', + splashChecked: 'false', + }); + }); + + act(() => { + root?.unmount(); + }); + container.remove(); + }); +}); diff --git a/apps/ratewise/src/pages/Settings.tsx b/apps/ratewise/src/pages/Settings.tsx index 89ba88997..0b724f199 100644 --- a/apps/ratewise/src/pages/Settings.tsx +++ b/apps/ratewise/src/pages/Settings.tsx @@ -38,7 +38,7 @@ import { Rows3, type LucideIcon, } from 'lucide-react'; -import { useState, useSyncExternalStore } from 'react'; +import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { SEOHelmet } from '../components/SEOHelmet'; @@ -46,14 +46,15 @@ import { motion } from 'motion/react'; import { useAppTheme } from '../hooks/useAppTheme'; import { useCustomThemeDraft } from '../hooks/useCustomThemeDraft'; import { useInlineConfirm } from '../hooks/useInlineConfirm'; -import { STYLE_OPTIONS } from '../config/themes'; -import { choosePrimaryForeground } from '../config/custom-theme'; +import { STYLE_OPTIONS, DEFAULT_THEME_CONFIG } from '../config/themes'; +import { choosePrimaryForeground, DEFAULT_CUSTOM_PRIMARY } from '../config/custom-theme'; import { CustomThemeSheet } from '../components/CustomThemeSheet'; import { LANGUAGE_OPTIONS, getResolvedLanguage, type SupportedLanguage } from '../i18n'; import { getDisplayVersion } from '../config/version'; import { transitions, segmentedSwitch } from '../config/animations'; import { APP_ONLY_PAGE_SEO } from '../config/seo-metadata'; import type { ConverterV2Variant, RateMode } from '../features/ratewise/types'; +import { DEFAULT_CONVERTER_V2_VARIANT, DEFAULT_RATE_MODE } from '../features/ratewise/constants'; import { useConverterStore } from '../stores/converterStore'; import { subscribeConverterV2Variant, @@ -62,25 +63,70 @@ import { } from '../config/converter-v2-flag'; import { isSplashEnabled, setSplashEnabled, SPLASH_PREVIEW_EVENT } from '../utils/splashPreference'; +// SSR 環境呼叫 useLayoutEffect 會產生 React 警告;依 window 存在與否切換(同 #664 慣例)。 +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +// issue #666:/settings 為預渲染頁,persisted 偏好(variant/rateMode/theme/splash)若於 +// 首幀直讀,client render 路徑(hydration de-opt/早期更新/SSG fallback guard)首幀輸出 +// 會偏離 SSG HTML,屬 #653 同族破口。模組級旗標記錄本次 page load 是否已完成一次 +// hydration:SPA 導覽 remount 首幀直接依 persisted 偏好渲染,不重演 two-pass(零閃爍)。 +let hasCompletedHydration = false; + +// 測試專用:重置 hydration 旗標,模擬新的 page load。 +// eslint-disable-next-line react-refresh/only-export-components +export function resetSettingsHydrationForTests() { + hasCompletedHydration = false; +} + export default function Settings() { const { t, i18n } = useTranslation(); - const { config, style, setStyle, customPrimary, commitCustomTheme, resetTheme, isLoaded } = - useAppTheme(); + const { + config, + style: persistedStyle, + setStyle, + customPrimary: persistedCustomPrimary, + commitCustomTheme, + resetTheme, + isLoaded, + } = useAppTheme(); const pageSeo = APP_ONLY_PAGE_SEO.settings; - const { rateMode, setRateMode, singleConverterVariant, setSingleConverterVariant } = - useConverterStore(); + const { + rateMode: persistedRateMode, + setRateMode, + singleConverterVariant: persistedConverterVariant, + setSingleConverterVariant, + } = useConverterStore(); + + // Two-pass render(#666,比照 #664):第一 pass(hydration)所有 persisted 欄位一律 + // 沿用 SSG server snapshot 值,保證任何強制 client render 的首幀輸出與預渲染 HTML 一致; + // 第二 pass 由 layout effect 於 paint 前切回 persisted 偏好,無多餘可見預設幀。 + const [hydrated, setHydrated] = useState(hasCompletedHydration); + + useIsomorphicLayoutEffect(() => { + hasCompletedHydration = true; + setHydrated(true); + }, []); // URL override 提示:effective 值(含 ?converter= 覆寫)與儲存偏好不一致時顯示 badge。 - // server snapshot 恆 legacy,與 SSG 輸出一致(hydration 安全)。 + // server snapshot 恆 legacy;hydration 完成前 client snapshot 也固定走 server 值(同 #664)。 const effectiveConverterVariant = useSyncExternalStore( subscribeConverterV2Variant, - getConverterV2Variant, + hydrated ? getConverterV2Variant : getConverterV2VariantServerSnapshot, getConverterV2VariantServerSnapshot, ); - const isConverterVariantOverridden = effectiveConverterVariant !== singleConverterVariant; - // 啟動畫面偏好:與 useAppTheme 相同模式(initializer 讀 localStorage,SSR 回傳預設)。 - const [splashEnabled, setSplashEnabledState] = useState(() => isSplashEnabled()); + // 啟動畫面偏好:initializer 讀 localStorage(SSR 回傳預設 true)。 + const [splashEnabledState, setSplashEnabledState] = useState(() => isSplashEnabled()); + + // 首幀顯示值:hydration 完成前一律用 SSG 預設(zen/品牌藍/auto/legacy/splash on)。 + const style = hydrated ? persistedStyle : DEFAULT_THEME_CONFIG.style; + const customPrimary = hydrated ? persistedCustomPrimary : DEFAULT_CUSTOM_PRIMARY; + const rateMode = hydrated ? persistedRateMode : DEFAULT_RATE_MODE; + const singleConverterVariant = hydrated + ? persistedConverterVariant + : DEFAULT_CONVERTER_V2_VARIANT; + const splashEnabled = hydrated ? splashEnabledState : true; + const isConverterVariantOverridden = effectiveConverterVariant !== singleConverterVariant; // 主題工作室 draft 模式(E7 wave-B):開啟即時預覽全站、關閉 sheet 才 commit persist、 // 「取消」回滾開啟前快照。 diff --git a/apps/ratewise/src/stores/__tests__/converterStore.rehydrate.test.ts b/apps/ratewise/src/stores/__tests__/converterStore.rehydrate.test.ts new file mode 100644 index 000000000..29f5670d9 --- /dev/null +++ b/apps/ratewise/src/stores/__tests__/converterStore.rehydrate.test.ts @@ -0,0 +1,71 @@ +/** + * converterStore 冷載 rehydrate 契約(issue #666 盤點揭露)。 + * + * localStorage 為同步 storage:persist 的 onRehydrateStorage 後置回呼於 create() 內 + * 同步觸發,此時模組綁定 useConverterStore 仍在 TDZ;舊寫法經模組綁定呼叫 + * getState() 會拋 ReferenceError 且被 middleware 靜默吞掉——一次性遷移與 sanitize + * 於冷載全滅、hasHydrated() 恆 false(真瀏覽器 production build 同樣重現)。 + * + * 本檔以 vi.resetModules + 動態 import 重演「模組首次載入」的真實冷載路徑 + * (不直接呼叫 __migrateFromLegacy,鑑別回呼是否真的執行); + * 壞版(還原模組綁定寫法)三條測試全紅。 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const STORE_MODULE = '../converterStore'; +const STORE_KEY = 'ratewise-converter'; +const WAVE_A_KEY = 'ratewise:converterV2'; + +async function importFreshStore() { + const mod = await import(STORE_MODULE); + return mod.useConverterStore; +} + +describe('converterStore 冷載 rehydrate(onRehydrateStorage TDZ 契約)', () => { + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + }); + + it('模組首次載入即完成 hydration(hasHydrated=true、onFinishHydration 已觸發)', async () => { + window.localStorage.setItem( + STORE_KEY, + JSON.stringify({ state: { singleConverterVariant: 'v2' }, version: 0 }), + ); + + const useConverterStore = await importFreshStore(); + + expect(useConverterStore.persist.hasHydrated()).toBe(true); + expect(useConverterStore.getState().singleConverterVariant).toBe('v2'); + }); + + it('wave-A legacy converter key 於冷載自動遷移併入 store 並刪除舊 key', async () => { + window.localStorage.setItem(WAVE_A_KEY, 'v2'); + + const useConverterStore = await importFreshStore(); + + expect(useConverterStore.getState().singleConverterVariant).toBe('v2'); + expect(window.localStorage.getItem(WAVE_A_KEY)).toBeNull(); + const persisted = JSON.parse(window.localStorage.getItem(STORE_KEY) ?? '{}') as { + state?: { singleConverterVariant?: string }; + }; + expect(persisted.state?.singleConverterVariant).toBe('v2'); + }); + + it('損毀 persisted 欄位於冷載自動 sanitize 回合法值', async () => { + window.localStorage.setItem( + STORE_KEY, + JSON.stringify({ + state: { rateType: 'INVALID', favorites: ['USD', 'BOGUS', 'TWD'] }, + version: 0, + }), + ); + + const useConverterStore = await importFreshStore(); + + const state = useConverterStore.getState(); + expect(state.rateType).toBe('spot'); + expect(state.favorites).toEqual(['USD']); + }); +}); diff --git a/apps/ratewise/src/stores/converterStore.ts b/apps/ratewise/src/stores/converterStore.ts index 3c753fcb7..4a3a2bba2 100644 --- a/apps/ratewise/src/stores/converterStore.ts +++ b/apps/ratewise/src/stores/converterStore.ts @@ -557,11 +557,15 @@ export const useConverterStore = create()( cardRateEnabled: state.cardRateEnabled, cardFeePercent: state.cardFeePercent, }), - onRehydrateStorage: () => (_state, error) => { - if (error) return; + // issue #666:localStorage 為同步 storage,本回呼於 create() 內同步觸發—— + // 此時模組綁定 useConverterStore 仍在 TDZ,經其呼叫會拋 ReferenceError 且被 + // persist middleware 靜默吞掉(遷移/sanitize 全滅、hasHydrated 恆 false)。 + // 必須使用回呼參數 state(閉包 get/set),不可引用模組綁定。 + onRehydrateStorage: () => (state, error) => { + if (error || !state) return; // 舊版個別 key 的一次性遷移 - useConverterStore.getState().__migrateFromLegacy(); - useConverterStore.getState().__validateAndSanitize(); + state.__migrateFromLegacy(); + state.__validateAndSanitize(); }, }, ), diff --git a/apps/ratewise/tests/e2e/settings-hydration.spec.ts b/apps/ratewise/tests/e2e/settings-hydration.spec.ts new file mode 100644 index 000000000..df0100e3b --- /dev/null +++ b/apps/ratewise/tests/e2e/settings-hydration.spec.ts @@ -0,0 +1,108 @@ +/** + * /settings persisted 欄位冷載 hydration E2E(issue #666)。 + * + * /settings 為預渲染頁;persisted 偏好與 SSG snapshot 不一致時,冷載(直接導覽) + * 期間任何強制 client render 都不得產生 React #418 家族 console error。 + * 挑 3 個高風險欄位組合實測(盤點表破口欄位全覆蓋): + * 1. converterStore:singleConverterVariant=v2 + rateMode=sell(issue 核心破口) + * 2. ratewise-theme:style=nitro + customPrimary(獨立 localStorage key) + * 3. 全欄位疊加:variant/rateMode/theme/customPrimary/splash off + wave-A legacy + * converter key(觸發 __migrateFromLegacy 的 hydration 窗口 set(),#653 情境) + */ + +import type { Page } from '@playwright/test'; +import { test, expect } from './fixtures/test'; + +const BASE_PATH = + process.env['E2E_BASE_PATH'] || process.env['VITE_RATEWISE_BASE_PATH'] || '/ratewise'; +const SETTINGS_PATH = `${BASE_PATH}/settings`.replace(/\/{2,}/g, '/'); + +function collectConsoleErrors(page: Page): string[] { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + return consoleErrors; +} + +// addInitScript 於下一次導覽生效;寫入 persisted 值後以 goto 冷載 /settings。 +async function coldLoadSettings(page: Page, seed: () => void) { + await page.addInitScript(() => { + sessionStorage.setItem('ratewise:pwa-install-guide-dismissed:v1', 'true'); + }); + await page.addInitScript(seed); + await page.goto(SETTINGS_PATH); + await expect(page.getByTestId('converter-variant-v2')).toBeVisible({ timeout: 30_000 }); +} + +test.describe('/settings persisted 欄位冷載(#666)', () => { + test.beforeEach(() => { + test.setTimeout(90_000); + }); + + test('組合 1:persisted v2+sell 冷載 console 零錯誤、UI 呈現偏好', async ({ + rateWisePage: page, + }) => { + const consoleErrors = collectConsoleErrors(page); + + await coldLoadSettings(page, () => { + const persisted = JSON.parse(localStorage.getItem('ratewise-converter') ?? '{}') as { + state?: Record; + version?: number; + }; + persisted.state = { ...persisted.state, singleConverterVariant: 'v2', rateMode: 'sell' }; + persisted.version ??= 0; + localStorage.setItem('ratewise-converter', JSON.stringify(persisted)); + }); + + // two-pass 切換後 UI 必須呈現 persisted 偏好(gate 不得吞掉使用者設定)。 + await expect(page.getByTestId('converter-variant-v2')).toHaveAttribute('aria-pressed', 'true'); + + expect(consoleErrors).toEqual([]); + }); + + test('組合 2:persisted nitro+customPrimary 冷載 console 零錯誤', async ({ + rateWisePage: page, + }) => { + const consoleErrors = collectConsoleErrors(page); + + await coldLoadSettings(page, () => { + localStorage.setItem( + 'ratewise-theme', + JSON.stringify({ style: 'nitro', customPrimary: '#FF6B6B' }), + ); + }); + + // 主題卡選中態呈現 persisted 偏好。 + await expect(page.getByRole('button', { name: /Nitro/ })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + + expect(consoleErrors).toEqual([]); + }); + + test('組合 3:全欄位疊加+wave-A legacy key 遷移冷載 console 零錯誤', async ({ + rateWisePage: page, + }) => { + const consoleErrors = collectConsoleErrors(page); + + await coldLoadSettings(page, () => { + // 移除 store key 讓 __migrateFromLegacy 走 wave-A legacy converter key 遷移, + // 於 hydration 窗口內觸發 set()(#653 破口情境)。 + localStorage.removeItem('ratewise-converter'); + localStorage.setItem('ratewise:converterV2', 'v2'); + localStorage.setItem( + 'ratewise-theme', + JSON.stringify({ style: 'custom', customPrimary: '#BE123C' }), + ); + localStorage.setItem('ratewise-splash-enabled', '0'); + }); + + // 遷移後偏好生效:v2 選中、splash 關閉、自訂主題選中。 + await expect(page.getByTestId('converter-variant-v2')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('switch')).toHaveAttribute('aria-checked', 'false'); + + expect(consoleErrors).toEqual([]); + }); +}); diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 09bc6c123..fb48a7ca0 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+201 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+202 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-07-08 +- ID:reward-rw-666-persisted-hydration-inventory +- 原因:#664 修 SingleConverter 後同構破口殘留——Settings.tsx(預渲染頁)的 variant/rateMode/theme/customPrimary/splash 首幀直讀 persisted 值,client render 路徑首幀輸出偏離 SSG(#653 同族 #418);盤點中另揭露 converterStore onRehydrateStorage 後置回呼經模組綁定呼叫 getState(),同步 storage 下 create() 內觸發時綁定仍在 TDZ,ReferenceError 被 middleware 吞掉——wave-A 遷移與 sanitize 冷載全滅、hasHydrated 恆 false(真瀏覽器實證) +- 解法:13 persisted 欄位 × 預渲染頁盤點表入 PR body(3 破口/10 豁免附理由);Settings 套 #664 同款 two-pass gate(首幀恆 SSG 預設、layout effect paint 前切 persisted、SPA remount 模組旗標不重演);rehydrate 回呼改用參數 state 走閉包修復 TDZ 死路;新增首幀契約測試 5 條(壞版突變 3 紅)+ rehydrate 契約 3 條(模組綁定壞版 3 紅)+ e2e 三組高風險 persisted 組合冷載 console 零錯誤(wave-A 遷移組合於 base 必紅) + - 日期:2026-07-08 - ID:reward-rw-687-tv-attribution-logo-theme - 原因:MiniTrendChart 設 layout.textColor 為 'transparent',lightweight-charts 官方 AttributionLogoWidget 以 grayscale(textColor)>160 切換 logo 亮/暗版,transparent 解析為灰階 0 → logo 永遠深色版(#131722)在 nitro/racing/custom 深調近乎不可見(授權標示合規與視覺品質雙重問題,QA-K K-2)