Files
Noah 20fe058b15 fix(ui): 修复 React Compiler lint 命中并恢复规则为 error (#1711)
* 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、无横向溢出,注册模式(三个输入框)同样。
2026-09-22 08:27:12 +00:00

158 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}