fix(post): hydration 后点击标题锚点触发整页刷新

WASM 下载完成后点击 TOC/标题 # 锚点会整页刷新且不滚动,而 SSR 阶段(WASM
未就绪)点击正常——这是 Dioxus 0.7 事件委托的副作用:

项目任意位置用 onclick(post_nav_links.rs)即触发 Dioxus 全局 click 委托。
点击 dangerous_inner_html 注入的 <a href="#id"> 时,事件冒泡到根监听器,
handleEvent 发现 <a> 无 Dioxus onclick → 走兜底 handleClickNavigate:
preventDefault 拦掉原生 fragment-scroll,再 browser_open IPC 把 hash 当
外部 URL 整页加载(History::external → location.set_href)。

修复:yggdrasil-core 新增 anchor-click.ts,在 window capture 阶段(早于
Dioxus 的 bubble 委托)拦截同页 hash 锚点点击,stopPropagation 阻止事件
到达 Dioxus,自行 scrollIntoView + replaceState 更新地址栏。中键/修饰键、
外链、目标不存在等情况放行原行为。

81682eb 的 scrollToHash 只在 PostContent 挂载时跑一次,不覆盖点击场景;
本修复与之正交,共同覆盖刷新与点击两条路径。initAnchorClick 幂等。
This commit is contained in:
xfy 2026-07-13 18:22:53 +08:00
parent 56f599574f
commit 5ad6ce60cd
4 changed files with 207 additions and 0 deletions

View File

@ -0,0 +1,125 @@
/**
* anchor-click 测试:钉住 hash URL
* 黑盒驱动:只通过 window.__initAnchorClick + DOM
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index';
// 模拟 Dioxus 委托监听器:挂在 document 的 bubble 阶段 click模拟 handleClickNavigate
// 会被触发时的副作用preventDefault + 整页跳转)。测试用它验证拦截器确实阻止了冒泡。
let dioxusInterceptorCalled = false;
describe('initAnchorClick', () => {
beforeEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
vi.restoreAllMocks();
dioxusInterceptorCalled = false;
// 模拟 Dioxus 在 document bubble 阶段的 click 委托监听器。
// 拦截器在 window capture 阶段 stopPropagation 后,这个监听器不应被触发。
document.addEventListener(
'click',
() => {
dioxusInterceptorCalled = true;
},
false,
);
window.__initAnchorClick();
});
afterEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
});
it('点击 hash 锚点滚动到目标并阻止 Dioxus 委托监听器', () => {
const heading = document.createElement('h2');
heading.id = 'ru-men-zhi-nan';
document.body.appendChild(heading);
const anchor = document.createElement('a');
anchor.setAttribute('href', '#ru-men-zhi-nan');
document.body.appendChild(anchor);
const spy = vi.spyOn(heading, 'scrollIntoView');
anchor.click();
expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
// 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
expect(dioxusInterceptorCalled).toBe(false);
});
it('点击后 URL hash 更新为锚点 id不触发整页刷新', () => {
const heading = document.createElement('h2');
heading.id = 'section-2';
document.body.appendChild(heading);
const anchor = document.createElement('a');
anchor.setAttribute('href', '#section-2');
document.body.appendChild(anchor);
anchor.click();
expect(window.location.hash).toBe('#section-2');
});
it('目标元素不存在时放行,不阻止 Dioxus 原行为', () => {
const anchor = document.createElement('a');
anchor.setAttribute('href', '#no-such-heading');
document.body.appendChild(anchor);
anchor.click();
// 目标不存在:拦截器不接管,事件继续冒泡到 Dioxus 监听器。
expect(dioxusInterceptorCalled).toBe(true);
});
it('非 hash 链接不拦截(外链/路径链接交给原行为)', () => {
const anchor = document.createElement('a');
anchor.setAttribute('href', '/post/other');
document.body.appendChild(anchor);
anchor.click();
expect(dioxusInterceptorCalled).toBe(true);
});
it('带修饰键的点击不拦截(新窗口等交给浏览器)', () => {
const heading = document.createElement('h2');
heading.id = 'target';
document.body.appendChild(heading);
const anchor = document.createElement('a');
anchor.setAttribute('href', '#target');
document.body.appendChild(anchor);
const spy = vi.spyOn(heading, 'scrollIntoView');
// 模拟 ctrl+click新标签页:dispatchEvent 携带 ctrlKey。
const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
anchor.dispatchEvent(event);
expect(spy).not.toHaveBeenCalled();
expect(dioxusInterceptorCalled).toBe(true);
});
it('重复调用 initAnchorClick 幂等,不会重复注册监听器', () => {
const heading = document.createElement('h2');
heading.id = 'dup-target';
document.body.appendChild(heading);
const anchor = document.createElement('a');
anchor.setAttribute('href', '#dup-target');
document.body.appendChild(anchor);
// 再次调用(模拟 PostContent 多次挂载)。
window.__initAnchorClick();
window.__initAnchorClick();
const spy = vi.spyOn(heading, 'scrollIntoView');
anchor.click();
// 即使重复调用scrollIntoView 仍只触发一次(监听器只注册一份)。
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,73 @@
/**
* / hash Dioxus
*
* Dioxus 0.7 NativeInterpreter click
* app onclick post_nav_links.rs Dioxus
* click dangerous_inner_html <a href="#id">
* 1. click handleEvent POST /__events
* 2. <a> Dioxus onclick response preventDefault
* 3. handleClickNavigate: event.preventDefault() + browser_open IPC
* 4. browser_open History::external window.location.set_href("#id")
*
* fragment-scroll preventDefault IPC hash URL
* WASM SSR HTML
* fragment-scroll hydration
*
* capture Dioxus bubble hash
* <a> stopPropagation Dioxus scrollIntoView
* history.replaceState URL hash popstate/hashchange
*/
let installed = false;
/**
* hash
*
* capture + window Dioxus
* stopPropagation Dioxus handleEvent/handleClickNavigate
* browser_open IPC
*/
export function initAnchorClick(): void {
if (installed) return;
installed = true;
window.addEventListener(
'click',
(event) => {
// 只处理无修饰键的左键点击(中键/ctrl/cmd 点击交给浏览器新窗口行为)。
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
// 从点击点向上找最近的 <a>(点击可能落在 <a> 内的子元素上)。
const anchor = target.closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
// 只接管同页 hash 锚点href 以 # 开头且非空(排除纯 "#" 和 "#top" 之外的真锚点)。
// 外链、路径链接、空 hash 都交给浏览器/Dioxus 原行为。
if (!href?.startsWith('#') || href === '#') return;
const id = decodeURIComponent(href.slice(1));
const el = document.getElementById(id);
if (!el) return; // 目标不存在:放行,交还原生行为(不滚动但不刷新)
// 阻止事件继续传播到 Dioxus 的委托监听器 → 避免 handleClickNavigate。
event.stopPropagation();
event.preventDefault();
// 平滑滚动到目标标题scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 更新 URL hash 但不产生历史记录抖动replaceState 不触发 popstate/hashchange。
// 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。
if (window.location.hash !== href) {
history.replaceState(null, '', href);
}
},
true, // capture在 Dioxus 的 bubble 监听器之前执行
);
}

View File

@ -1,3 +1,4 @@
import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll';
import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
@ -6,6 +7,7 @@ import './style.css';
declare global {
interface Window {
__initPostContent: (selector: string) => void;
__initAnchorClick: () => void;
__scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void;
__applyResolvedTheme: (isDark: boolean) => void;
@ -13,6 +15,7 @@ declare global {
}
window.__initPostContent = initPostContent;
window.__initAnchorClick = initAnchorClick;
window.__scrollToHash = scrollToHash;
window.__startThemeTransition = startThemeTransition;
window.__applyResolvedTheme = applyResolvedTheme;

View File

@ -154,6 +154,12 @@ pub fn PostContent(content_html: String) -> Element {
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
// 安装 hash 锚点点击拦截器(幂等)。
// Dioxus hydration 后其事件委托会接管所有 <a> click见 handleClickNavigate
// 把 hash 锚点当外部 URL 整页刷新。拦截器在 capture 阶段阻止事件到达 Dioxus
// 自行 scrollIntoView。initAnchorClick 内部幂等PostContent 多次挂载也安全。
invoke_optional_global(&window, "__initAnchorClick", &[]);
// 内容挂载后若 URL 带 hash滚动到对应标题。
// 解决骨架屏阶段标题 DOM 缺失导致浏览器原生 fragment-scroll 失效的问题:
// PostDetail 用 use_server_future 异步取数,首屏渲染骨架屏,此时标题 DOM