* fix(ui): 主题与侧栏偏好改用 useSyncExternalStore 读取,去掉 effect 内 setState
react-hooks/set-state-in-effect 命中 4 处,都是「挂载后在 effect 里读浏览器状态再 setState」:
- app/(app)/layout.tsx:117 侧栏折叠偏好、:136 主题模式:两者都是 localStorage 里的值。
新增 useLocalStorageValue(key),用 useSyncExternalStore 订阅:服务端与水合期快照固定为
null(与 SSR 输出一致,不会水合不一致),水合完成后 React 自己用真实值重渲染一次;
写入走 setter 并通知本页订阅者。localStorage 写失败时退回内存值,本页内仍可切换,
与原先 try/catch 后照常 setState 的行为一致。
- app/lib/utils/index.tsx:66 useSystemTheme:matchMedia 是外部状态,改为 useSyncExternalStore
订阅 change 事件,服务端快照 light,与原来 useState('light') 再 effect 校正的结果一致。
- app/ui/ThemeButton.tsx:30:原来点击后先置 switchTrigger,再在 effect 里读标志位切主题、
setMode、清标志位,图标也在 effect 里 setState。改为点击事件里直接算出下一模式并
applyThemeMode + setMode,图标由 mode 派生,不再有中间 state 与 effect;切换结果相同,
少一轮渲染。setMode 的类型收窄为 (mode: string) => void,调用方只传字符串。
useTheme 未改:它的首次跳过逻辑仍然成立(首帧主题由 <head> 内联脚本设置)。
* fix(ui): 日志页、空间配置页、账号列表与模板表单去掉 effect 内同步 setState
react-hooks/set-state-in-effect 其余 5 处:
- app/(app)/logviewer/page.tsx:133:connectWebSocket() 开头同步 setIsLoading(true)/setLogs([])
再建连接,effect 与「刷新」按钮共用。拆开:effect 只负责建立 WebSocket、把消息写进
state、cleanup 时关闭(上一条连接不再靠 ref 手动 close);清空日志 + 进入加载态移到触发
重连的事件里(切 Tab 的 onChange、刷新按钮),刷新通过 connectSeq 计数器让 effect 重跑。
首次挂载时两个 state 本来就是初始值,其余时序与原来一致;顺带删掉不再需要的
exhaustive-deps 禁用注释。
- app/(app)/dashboard/page.tsx:101:校验失败后先切 Tab / 平台,再在 effect 里滚到出错字段并
把 pendingField 清空。改为 flushSync 同步提交切面板的 state,随后直接 scrollIntoView +
focus(React 文档推荐的「更新后读 DOM」写法),去掉 pendingField 这个中间 state。
onSubmitFail 由 Semi 在 Promise.catch 里调用,不在渲染期,flushSync 合法。
- app/lib/use-streamers.ts:26 useBiliUsers:data 为空时同步 setList([])。改为返回值直接派生
(无账号 → 空数组常量),effect 里只保留异步补全;data 为空数组时 Promise.all 解析为 []
仍会把上一批结果清掉,之后再有账号不会先闪旧数据。
- app/ui/TemplateFields.tsx:183:挂载后从 formApi.getValue('dtime') 算滚轮初始选中值。
Semi Form 在构造函数里就把 initValues 放进 store,首次渲染即可读到,改为 useState 的
惰性初始值(换算逻辑抽成 delayFromDtime),结果与 effect 版本相同。
- app/(app)/components/BackgroundSetter.tsx:71:hasBg / opacity 挂载后从 localStorage 读。
改为 useState 惰性初始值;两个值只在面板展开后渲染,SSR 首帧输出不受影响。
* fix(ui): 修复渲染期不纯写法:卡片实时时长、平台插件查找、time_range 转换
- react-hooks/purity app/ui/StreamerCard.tsx:101:渲染期调 Date.now() 算「已直播多久」。
新增共享时钟 useNowSec()(app/lib/use-dashboard.ts):模块级 store 由定时器每秒推进,
组件用 useSyncExternalStore 读取,首个订阅者接入时校准一次。时长拆成 LiveDuration 子组件,
只有录制中的卡片挂载它,其余卡片不随时钟重渲染。显示值语义不变(开播至今,同一
formatDuration),刷新节奏从「随 10 s 轮询重渲染时更新」变为每秒更新。timeAgo 内部也用
Date.now() 但未被规则命中(在工具函数里),本次不动。
- react-hooks/static-components app/ui/OverrideModal.tsx:297:JSX 里用 IIFE 调 platformSetting()
拿到组件再渲染,规则把它当成「渲染期创建的组件」。实际是按 url 从模块级常量表
SupportedPlatforms 取组件。改为先找出匹配的键,再按键从常量表取值,让「同一引用」这一点
在代码上可见;匹配顺序与结果不变。
- react-hooks/immutability app/ui/TemplateModal.tsx:121:把 props.entity.time_range 由 JSON
字串就地改写成 Date[]。改为 useMemo 生成一份本地副本作为 Form 的 initValues,解析失败
保持原样并 console.error(同原逻辑),不再修改上层传下来的对象。
* chore(lint): 恢复 React Compiler 系列规则为 eslint-plugin-react-hooks 的默认 error
#1710 升级 eslint-plugin-react-hooks 7 时把 set-state-in-effect / static-components /
purity / immutability 四条降为 warn,留给专门 PR 处理。命中的 12 处已在前三个 commit 修完,
这里删掉覆盖,回到插件 recommended 的 error 级别。
* fix(ui): useTheme 不再回写 localStorage,mode 只由 useLocalStorageValue 的 setter 持久化
mode 改由 useSyncExternalStore 从 localStorage 读取后,同一个键有了两个写入方。
StrictMode 下 effect 会在挂载时执行两次,useTheme 的「跳过首次」只能挡住第一次,
第二次会用水合期的默认值 auto 把用户刚保存的主题覆盖掉,再由 store 读回 auto——
`next dev` 里每次刷新主题都会丢(生产构建没有双次 effect,不受影响)。
去掉 useTheme 里的 setItem:切换主题时 setter 已经写过同一个值;系统配色变化时原来会
把 auto 写进去,去掉后键保持缺省,读出来同样按 auto 处理,行为一致。
* fix(ui): 登录页在手机宽度下卡片超出视口、两侧被裁切
review 意见(#1711 下 ForgQi):手机尺寸下登录页两侧被裁切。
原因:三个输入框写死 style={{ width: 440 }},卡片宽度由它们撑成 552px(440 + 56×2 内边距),
.main 居中放置、.frame 又是 overflow: hidden,窄于 552 的屏幕上卡片两侧直接被裁掉。
改法:去掉输入框的固定宽度,卡片定宽 552px 并允许 max-width: 100% 收窄到视口,
.inputs 改为 align-items: stretch 让字段撑满卡片内容区(Semi 的 .semi-input-wrapper 默认 width: 100%),
≤480px 时内边距收到 32px 24px。桌面 1440 浅 / 深色截图与修改前像素 0 差异;390px 下卡片 370px、
输入框 322px、无横向溢出,注册模式(三个输入框)同样。
158 lines
5.9 KiB
TypeScript
158 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
|
||
|
||
export const responsiveMap = {
|
||
xs: '(max-width: 575px)',
|
||
sm: '(min-width: 576px)',
|
||
md: '(min-width: 768px)',
|
||
lg: '(min-width: 992px)',
|
||
xl: '(min-width: 1200px)',
|
||
xxl: '(min-width: 1600px)',
|
||
}
|
||
|
||
export interface RegisterMediaQueryOption {
|
||
match?: (e: MediaQueryList | MediaQueryListEvent) => void
|
||
unmatch?: (e: MediaQueryList | MediaQueryListEvent) => void
|
||
callInInit?: boolean
|
||
}
|
||
|
||
/**
|
||
* register matchFn and unMatchFn callback while media query
|
||
* @param {string} media media string
|
||
* @param {object} param param object
|
||
* @returns function
|
||
*/
|
||
export const registerMediaQuery = (
|
||
media: string,
|
||
{ match, unmatch, callInInit = true }: RegisterMediaQueryOption
|
||
): (() => void) => {
|
||
if (typeof window !== 'undefined') {
|
||
const mediaQueryList = window.matchMedia(media)
|
||
const handlerMediaChange = function (e: MediaQueryList | MediaQueryListEvent): void {
|
||
if (e.matches) {
|
||
match && match(e)
|
||
} else {
|
||
unmatch && unmatch(e)
|
||
}
|
||
}
|
||
callInInit && handlerMediaChange(mediaQueryList)
|
||
if (Object.prototype.hasOwnProperty.call(mediaQueryList, 'addEventListener')) {
|
||
mediaQueryList.addEventListener('change', handlerMediaChange)
|
||
return (): void => mediaQueryList.removeEventListener('change', handlerMediaChange)
|
||
}
|
||
mediaQueryList.addListener(handlerMediaChange)
|
||
return (): void => mediaQueryList.removeListener(handlerMediaChange)
|
||
}
|
||
return () => undefined
|
||
}
|
||
|
||
export const humDate = (time: number): string =>
|
||
new Date(time * 1000)
|
||
.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false,
|
||
})
|
||
.replaceAll('/', '-')
|
||
|
||
const DARK_SCHEME_QUERY = '(prefers-color-scheme: dark)'
|
||
const subscribeSystemTheme = (onChange: () => void) => {
|
||
const mediaQuery = window.matchMedia(DARK_SCHEME_QUERY)
|
||
mediaQuery.addEventListener('change', onChange)
|
||
return () => mediaQuery.removeEventListener('change', onChange)
|
||
}
|
||
const getSystemTheme = () => (window.matchMedia(DARK_SCHEME_QUERY).matches ? 'dark' : 'light')
|
||
const getServerSystemTheme = () => 'light'
|
||
|
||
/**
|
||
* 系统配色(light / dark)。matchMedia 是浏览器侧的外部状态,用 useSyncExternalStore 订阅:
|
||
* 服务端与水合期固定为 light(与 SSR 输出一致),水合后 React 自行切到真实值,不需要在 effect 里 setState。
|
||
*/
|
||
export const useSystemTheme = () =>
|
||
useSyncExternalStore(subscribeSystemTheme, getSystemTheme, getServerSystemTheme)
|
||
|
||
/* ---------- localStorage 偏好(侧栏折叠、主题模式)的订阅式读写 ---------- */
|
||
|
||
const storageListeners = new Map<string, Set<() => void>>()
|
||
/** localStorage 写入失败(配额满、被禁用)时的内存兜底,保证本页内仍能切换,只是刷新后不保留 */
|
||
const storageFallback = new Map<string, string | null>()
|
||
|
||
const readStorage = (key: string): string | null => {
|
||
if (storageFallback.has(key)) return storageFallback.get(key) ?? null
|
||
try {
|
||
return localStorage.getItem(key)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
const writeStorage = (key: string, value: string | null) => {
|
||
try {
|
||
if (value === null) localStorage.removeItem(key)
|
||
else localStorage.setItem(key, value)
|
||
storageFallback.delete(key)
|
||
} catch {
|
||
storageFallback.set(key, value)
|
||
}
|
||
storageListeners.get(key)?.forEach(listener => listener())
|
||
}
|
||
const subscribeStorage = (key: string, onChange: () => void) => {
|
||
let listeners = storageListeners.get(key)
|
||
if (!listeners) {
|
||
listeners = new Set()
|
||
storageListeners.set(key, listeners)
|
||
}
|
||
listeners.add(onChange)
|
||
return () => {
|
||
listeners.delete(onChange)
|
||
}
|
||
}
|
||
const getServerStorage = () => null
|
||
|
||
/**
|
||
* 读写 localStorage 里的一个键,并订阅它在本页内的变化。
|
||
* 替代「useState + 挂载 effect 里 setState」的写法:服务端与水合期快照固定为 null(与 SSR 输出一致),
|
||
* 水合完成后 React 自己用真实值重渲染一次,既没有水合不一致也没有级联渲染。
|
||
* 同一个键的写入都要走返回的 setter,订阅者才会收到通知;不要在别处直接改 localStorage 里的这个键。
|
||
*/
|
||
export const useLocalStorageValue = (key: string) => {
|
||
const subscribe = useCallback((onChange: () => void) => subscribeStorage(key, onChange), [key])
|
||
const getSnapshot = useCallback(() => readStorage(key), [key])
|
||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerStorage)
|
||
const setValue = useCallback((next: string | null) => writeStorage(key, next), [key])
|
||
return [value, setValue] as const
|
||
}
|
||
|
||
/**
|
||
* 同步主题属性到 <html> 与 <body>。
|
||
* Semi Design 的 CSS 变量绑定在 body[theme-mode] 上;
|
||
* 我们自己的 CSS(如 .shadow)与 no-flash 脚本使用 <html> 上的 theme-mode。
|
||
* 两者都写才能同时兼容 Semi 与自定义选择器。
|
||
*/
|
||
export const applyThemeMode = (mode: 'light' | 'dark') => {
|
||
if (typeof document === 'undefined') return
|
||
document.documentElement.setAttribute('theme-mode', mode)
|
||
document.body.setAttribute('theme-mode', mode)
|
||
}
|
||
|
||
/**
|
||
* mode / systemTheme 变化时把实际主题写到 DOM 上。
|
||
* mode 的持久化由 useLocalStorageValue('mode') 的 setter 负责,这里不再回写 localStorage:
|
||
* 两处都写会在 StrictMode 的双次 effect 下用水合期的默认值 auto 把刚读到的已保存主题覆盖掉。
|
||
*/
|
||
export const useTheme = (mode: string, systemTheme: string) => {
|
||
const firstRun = useRef(true)
|
||
useEffect(() => {
|
||
// 首屏主题已由根布局 <head> 内联脚本前置设置;这里跳过首次执行,
|
||
// 避免水合后用默认值(auto→system)又把已保存的主题覆盖掉一次,造成闪烁。
|
||
if (firstRun.current) {
|
||
firstRun.current = false
|
||
return
|
||
}
|
||
const actualMode = (mode === 'auto' ? systemTheme : mode) as 'light' | 'dark'
|
||
applyThemeMode(actualMode)
|
||
}, [mode, systemTheme])
|
||
}
|