Skip to content

fix(theme): 修复客户端导航的深色模式闪烁 - #49

Merged
PaloMiku merged 1 commit into
mainfrom
fix/theme-fouc-on-view-transition
Oct 3, 2026
Merged

PaloMiku merged 1 commit into
mainfrom
fix/theme-fouc-on-view-transition

Conversation

@PaloMiku

@PaloMiku PaloMiku commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

现象

从首页切到文档时,若用户已是夜间模式,会先闪一下日间再切回夜间。硬导航(F5 / 直接打开 URL)不受影响,只有 ClientRouter 客户端导航会闪。

根因

swapRootAttributes()(astro/dist/transitions/swap-functions.js:46-54):

const nonOverridableAstroAttributes = [...currentRoot.attributes].filter(
  ({ name }) => (currentRoot.removeAttribute(name), NON_OVERRIDABLE_ASTRO_ATTRS.includes(name))
);

filter 的副作用把 <html> 上的属性挨个 removeAttribute 掉,只保留 NON_OVERRIDABLE_ASTRO_ATTRS —— 而它只有 data-astro-transition 与 data-astro-transition-fallback 两项,class 不在内。再叠加服务端产物 <html lang="zh-CN"> 本身不带 class,dark 就此丢失。

时序上,router.js 里 doSwap() 完成属性交换(第 202 行)到 runScripts() 重跑内联防闪脚本(第 351 行)之间跨了一个 await;且导航走 startViewTransition,该中间态会被快照并交叉淡入,因此肉眼可见。

修复

在原有防闪脚本里注册 astro:before-swap 监听器,在交换发生前把当前主题写进 event.newDocument:

if (!window.__mxThemeSync) {
  window.__mxThemeSync = true;
  document.addEventListener('astro:before-swap', (event) => {
    event.newDocument.documentElement.classList.toggle(
      'dark',
      document.documentElement.classList.contains('dark'),
    );
  });
}

该事件由 doSwap 先 dispatchEvent 再 event.swap() 派发,携带 newDocument(events.js:117-118),因此交换过程本身不再产生中间态。

两个设计点:

  • 以 <html> 的实时 class 为真值,而不是回读 localStorage。用户中途切过主题、或 system 跟随系统变化时,实时 class 才是当前真相。
  • 用 __mxThemeSync 全局标志保证只注册一次。这个 is:inline 脚本每次导航都会被 runScripts() 重跑,不加守卫会重复注册。

顺带把 localStorage 读取拆出 try/catch(原先整个解析逻辑都在 try 内,matchMedia 抛错会连主题判断一起吞掉),主题判断抽成 resolveDark() 供硬导航与后续共用。

验证

单看"不闪了"说明不了什么 —— 可能是探针根本没在跑。所以临时插入了一个 MutationObserver 探针监视 <html> 的 class 变化,并做了反向对照(故意摘掉修复跑一次)以确认探针敏感:

darkLost finalDark
摘掉修复 1 次,navDepth=2 false(交换完成后是日间)
装回修复 0 次 true

navDepth=2 正是「before-preparation 与 before-swap 均已触发、swap 正在执行」的时刻,与从源码推的时序吻合。导航后 DOM 实测:

<html lang="zh-CN" class="dark" style="color-scheme: dark;">

探针与对照代码均已清除(已校验 7 个标记字符串在源码与产物中均不存在)。

回归

pnpm build 55 页零警告 · pnpm check 0 error / 1 hint · pnpm lint 通过 · pnpm pangu 通过 · check-links 0 阻塞。

已知未覆盖

浏览器实测只跑了「首页 → 文档」这一条(即原始报告路径)。修复挂在共用的 base.astro 上,对所有客户端导航生效,__mxThemeSync 守卫也经得起推敲(window / document 在视图切换中不被替换);但文档内互跳与返回首页未逐一跑完 —— 那几次点击因运行时对 SPA 导航的检测不稳定而超时,非修复本身问题。

从首页切到文档时若已是夜间模式,会先闪一下日间再切回夜间。硬导航不受影响,
只有 ClientRouter 客户端导航会闪。

根因:Astro 的 swapRootAttributes()(astro/dist/transitions/swap-functions.js:46-54)
用 filter 的副作用剥掉 html 元素上除 NON_OVERRIDABLE_ASTRO_ATTRS(仅 data-astro-transition
与 -fallback)外的全部属性,而服务端产物本身不带 class,于是 dark 丢失。router.js 里
doSwap() 完成属性交换(第 202 行)到 runScripts() 重跑内联防闪脚本(第 351 行)之间跨了
一个 await,且导航走 startViewTransition,该中间态会被快照交叉淡入,于是肉眼可见。

修复:注册 astro:before-swap 监听器,在交换发生前把当前主题写进 event.newDocument。
该事件由 doSwap 先 dispatchEvent 再 event.swap() 派发,携带 newDocument
(events.js:117-118),因此交换过程本身不再产生中间态。以 html 元素的实时 class 为真值
而非 localStorage,用户中途切过主题或 system 跟随系统变化时都能正确带上。

顺带把 localStorage 读取拆出 try/catch(原先整个解析逻辑都在 try 内,matchMedia 抛错会
连主题判断一起吞掉),主题判断抽成 resolveDark() 供硬导航与后续共用。

验证:临时插入 MutationObserver 探针监视 html 元素的 class 变化,并做反向对照(故意
摘掉修复跑一次)以确认探针敏感。结果:无修复时 darkLost=1、finalDark=false,丢失发生在
navDepth=2(before-preparation 与 before-swap 之后);装回修复后 darkLost=0、
finalDark=true。导航后 DOM 实测 class="dark" 保留。探针与对照代码均已清除。
Copilot AI balanced review requested due to automatic review settings October 3, 2026 13:11
@safedep

safedep Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

No dependency changes detected. Nothing to scan.

View complete scan results →

This report is generated by SafeDep GitHub App

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@PaloMiku
PaloMiku merged commit 5c1c017 into main Oct 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants