已发布作品可二次编辑

This commit is contained in:
2026-04-26 15:32:36 +08:00
parent 7aabbcc10c
commit de2c49005f
13 changed files with 154 additions and 77 deletions

View File

@@ -1,12 +1,19 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectRouteImageUrls,
extractCssImageUrls,
normalizePreloadImageUrl,
} from './routeImageReadyGateUtils';
import { RouteImageReadyGate } from './RouteImageReadyGate';
afterEach(() => {
vi.useRealTimers();
});
describe('RouteImageReadyGate image url helpers', () => {
it('extracts urls from layered CSS image values', () => {
@@ -41,4 +48,40 @@ describe('RouteImageReadyGate image url helpers', () => {
new URL('/ui/frame.png', document.baseURI).href,
]);
});
it('reveals route content after a short cap when images stay pending', () => {
vi.useFakeTimers();
render(
createElement(
RouteImageReadyGate,
{
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
},
createElement(
'section',
{
'data-testid': 'route-content',
},
createElement('img', {
src: '/generated-characters/slow-cover.png',
alt: 'slow cover',
}),
),
),
);
const content = screen.getByTestId('route-content');
const visibilityGate = content.parentElement;
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('true');
expect(visibilityGate?.style.visibility).toBe('hidden');
act(() => {
vi.advanceTimersByTime(1600);
});
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
expect(visibilityGate?.style.visibility).toBe('visible');
});
});

View File

@@ -15,6 +15,7 @@ type RouteImageReadyGateProps = {
const IMAGE_GATE_QUIET_MS = 140;
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
const IMAGE_GATE_MAX_BLOCK_MS = 1400;
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
const settledImageUrls = new Set<string>();
@@ -66,7 +67,7 @@ function preloadImageUrl(url: string) {
/**
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
* 等当前 DOM 中已发现的图片全部 settled 后再一次性显示
* 只等待短暂稳定窗口,不再把所有图片加载完成作为首屏硬阻塞
*/
export function RouteImageReadyGate({
children,
@@ -78,6 +79,7 @@ export function RouteImageReadyGate({
const scanTimerRef = useRef<number | null>(null);
const revealTimerRef = useRef<number | null>(null);
const scanVersionRef = useRef(0);
const revealedRef = useRef(false);
const [ready, setReady] = useState(false);
useEffect(() => {
@@ -88,6 +90,7 @@ export function RouteImageReadyGate({
let disposed = false;
startTimeRef.current = window.performance.now();
revealedRef.current = false;
setReady(false);
const clearScanTimer = () => {
@@ -110,27 +113,26 @@ export function RouteImageReadyGate({
};
const scheduleReveal = (version: number) => {
if (revealedRef.current) {
return;
}
clearRevealTimer();
const elapsed = window.performance.now() - startTimeRef.current;
const delay = Math.max(
const preferredDelay = Math.max(
IMAGE_GATE_QUIET_MS,
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
);
const maxRemainingDelay = Math.max(0, IMAGE_GATE_MAX_BLOCK_MS - elapsed);
const delay = Math.min(preferredDelay, maxRemainingDelay);
revealTimerRef.current = window.setTimeout(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
const pendingUrls = collectRouteImageUrls(root).filter(
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length > 0) {
scheduleScan();
return;
}
revealedRef.current = true;
setReady(true);
}, delay);
};
@@ -146,23 +148,18 @@ export function RouteImageReadyGate({
(url) => !settledImageUrls.has(url),
);
if (pendingUrls.length === 0) {
scheduleReveal(version);
return;
if (pendingUrls.length > 0) {
// 首屏慢加载的核心约束:图片可预热,但不能无限期阻塞页面主体可见。
pendingUrls.forEach((url) => {
void preloadImageUrl(url);
});
}
// 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。
setReady(false);
void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => {
if (disposed || version !== scanVersionRef.current) {
return;
}
scheduleScan();
});
scheduleReveal(version);
}
const observer = new MutationObserver((mutations) => {
if (disposed) {
if (disposed || revealedRef.current) {
return;
}