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:
parent
56f599574f
commit
5ad6ce60cd
125
libs/yggdrasil-core/src/anchor-click.test.ts
Normal file
125
libs/yggdrasil-core/src/anchor-click.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
73
libs/yggdrasil-core/src/anchor-click.ts
Normal file
73
libs/yggdrasil-core/src/anchor-click.ts
Normal 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 监听器之前执行
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user