From a7b6cf996726b49c456d1b418bc972ca96ce7b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=AC=91=E7=9A=84=E6=AF=9B=E6=AF=9B=E8=99=AB?= <315515767@qq.com> Date: Thu, 6 Aug 2026 08:32:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E4=B8=BB=E9=A2=98=E5=AD=98?= =?UTF-8?q?=E5=82=A8=20-=20=E4=BF=AE=E5=A4=8D=E9=9D=9E=E6=B3=95=20theme=20?= =?UTF-8?q?=E5=80=BC=E5=AF=BC=E8=87=B4=20/create=20=E5=B4=A9=E6=BA=83=20(#?= =?UTF-8?q?155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useThemeStore persist 无 merge 校验,坏 session/后端同步失败时可能将 非法 theme 值写入 localStorage,导致 canvasThemes[非法值] 为 undefined, 渲染时访问 theme.node 抛出 "Cannot read properties of undefined (reading 'node')",/create 页面崩溃。 4 层防线(只加校验,不回退代码): - useThemeStore persist merge 白名单校验,非法值回退 dark - useThemeStore setTheme 写入前校验,防止同步路径绕过 merge - normalizeConfigSnapshot 入口兜底 undefined/partial 快照 - ModelPicker 与 CanvasResourceMentionTextarea 消费端加 fallback Closes #154 Co-authored-by: sikongyue <3155157671@qq.com> --- .../canvas-resource-mention-textarea.tsx | 3 ++- web/src/components/model-picker.tsx | 6 ++++-- web/src/stores/use-config-store.ts | 5 +++-- web/src/stores/use-theme-store.ts | 18 ++++++++++++++++-- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index 002f91c6c..c97b9e50e 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -45,7 +45,8 @@ export const CanvasResourceMentionTextarea = forwardRef state.theme)]; + const rawTheme = useThemeStore((state) => state.theme); + const theme = canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark; const containerRef = useRef(null); const textareaRef = useRef(null); const editorRef = useRef(null); diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 4e31f2f7e..9d4b4e866 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from import { Check, ChevronDown, Coins, Cpu } from "lucide-react"; import { Popover } from "antd"; -import { canvasThemes } from "@/lib/canvas-theme"; +import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -24,7 +24,9 @@ type ModelPickerProps = { export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) { const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const pickerId = useId(); - const theme = canvasThemes[useThemeStore((state) => state.theme)]; + // 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃 + const rawTheme = useThemeStore((state) => state.theme); + const theme = (canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark) as CanvasTheme; const [open, setOpen] = useState(false); const menuRef = useRef(null); const triggerRef = useRef(null); diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index b9f5abdfb..a0843e67c 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -279,8 +279,9 @@ export const useConfigStore = create()( ), ); -export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) { - const persistedConfig = (snapshot.config || {}) as Partial; +export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefined = {}) { + // 坏存储/旧版本快照可能是 undefined 或缺 config,兜底为 defaultConfig,保证渲染不崩溃 + const persistedConfig = (snapshot?.config || {}) as Partial; const config = { ...defaultConfig, ...persistedConfig }; const hasPersistedChannels = Array.isArray(persistedConfig.channels); if (!hasPersistedChannels) config.channels = []; diff --git a/web/src/stores/use-theme-store.ts b/web/src/stores/use-theme-store.ts index b6073cee5..466f337f8 100644 --- a/web/src/stores/use-theme-store.ts +++ b/web/src/stores/use-theme-store.ts @@ -8,12 +8,26 @@ type ThemeStore = { setTheme: (theme: ThemeName) => void; }; +const VALID_THEMES: ThemeName[] = ["light", "dark"]; + export const useThemeStore = create()( persist( (set) => ({ theme: "dark", - setTheme: (theme) => set({ theme }), + // 写入前校验:非法 theme 直接忽略,防止 syncRemoteUserData 等路径绕过 merge 写入坏值 + setTheme: (next) => set((state) => (VALID_THEMES.includes(next) ? { theme: next } : state)), }), - { name: "infinite-canvas:theme_store" }, + { + name: "infinite-canvas:theme_store", + // 持久化恢复校验:旧版本/坏 session 写入的非法值回退到 dark, + // 避免 canvasThemes[非法值] = undefined 触发 "reading 'node'" 崩溃 + merge: (persisted, current) => { + const stored = (persisted || {}) as Partial; + const theme = VALID_THEMES.includes(stored.theme as ThemeName) + ? (stored.theme as ThemeName) + : "dark"; + return { ...current, theme }; + }, + }, ), );