fix(theme): VT 期间冻结 CSS 过渡,让圆形展开可见

body 有 transition: background-color .3s,VT 在 toggle dark class
后下一帧拍 NEW 快照时背景色才走了 ~5%,新旧快照几乎一样,
圆形展开看不出颜色差。修复:回调里加 vt-freeze 类禁用所有
过渡,让 NEW 快照立即拿到新主题配色;动画结束(finished)后
移除。补 1 个测试覆盖 freeze 生命周期。
This commit is contained in:
xfy 2026-06-26 14:36:41 +08:00
parent 1e78c7f0f9
commit c1781d7831
3 changed files with 46 additions and 2 deletions

View File

@ -1,6 +1,17 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */
/* 取消默认交叉淡入淡出,由 clip-path: circle() 接管新快照的展开。 */
/* VT 回调期间临时冻结所有 CSS 过渡
原因:body transition: background-color 0.3s(input.css),
toggle dark class 后真实渲染的背景色要 0.3s 才过渡到新主题
VT toggle 后的下一帧(~16ms) NEW 快照此时背景色才走了 ~5%,
新旧两张快照几乎一样,圆形展开就看不出颜色差
冻结过渡让 NEW 快照立即拿到新主题配色,圆形才有可见的颜色对比 */
html.vt-freeze,
html.vt-freeze * {
transition: none !important;
}
/* 取消默认交叉淡入淡出,由 clip-path: circle() 接管新快照的展开。 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
@ -20,3 +31,4 @@
clip-path: circle(var(--tt-max-r, 100vmax) at var(--tt-x, 0px) var(--tt-y, 0px));
}
}

View File

@ -10,11 +10,13 @@ import './index';
describe('startThemeTransition', () => {
beforeEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('vt-freeze');
document.documentElement.style.cssText = '';
vi.restoreAllMocks();
});
afterEach(() => {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('vt-freeze');
document.documentElement.style.cssText = '';
});
@ -66,6 +68,32 @@ describe('startThemeTransition', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false);
cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// 回调里同时冻结 CSS 过渡,让 NEW 快照立即反映新主题配色
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('主路径:vt.finished 结束后移除 vt-freeze 并复位重入标志', async () => {
const finishedPromise = Promise.resolve();
const startVT = vi.fn((cb: () => void) => {
cb();
return { finished: finishedPromise, skipTransition: () => {} };
});
Object.defineProperty(document, 'startViewTransition', {
value: startVT,
configurable: true,
writable: true,
});
window.__startThemeTransition(0, 0, true);
// 回调执行后 freeze 已加
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
await finishedPromise;
// 微任务:.finally 在 finished resolve 后于微任务阶段执行,等一轮
await Promise.resolve();
expect(document.documentElement.classList.contains('vt-freeze')).toBe(false);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});

View File

@ -65,15 +65,19 @@ export function startThemeTransition(x: number, y: number, isDark: boolean): voi
html.style.setProperty('--tt-y', `${y}px`);
html.style.setProperty('--tt-max-r', `${maxCornerDistance(x, y)}px`);
// VT 回调里:先冻结所有 CSS 过渡(让 NEW 快照立即拿到新主题配色,
// 不被 body 的 background-color .3s 过渡拖慢),再 toggle dark class。
const vt = document.startViewTransition(() => {
html.classList.add('vt-freeze');
applyDarkClass(isDark);
});
// vt.finished 在 skipTransition 或页面跳转时会 reject(属预期),用 .catch 吞掉
// 以避免 unhandled rejection 刷控制台;无论 resolve/reject 都复位重入标志
// 以避免 unhandled rejection 刷控制台;无论 resolve/reject 都复位重入标志与冻结
vt.finished
.catch(() => {})
.finally(() => {
html.classList.remove('vt-freeze');
transitioning = false;
});
}