Compare commits
3 Commits
f68f4914ec
...
5eb37d595b
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb37d595b | |||
| f43295b471 | |||
| 8171fc59b0 |
@@ -112,6 +112,7 @@
|
||||
- 用户主动关闭弹窗时,只关闭弹窗,不改变当前平台页面
|
||||
- 不清空首页浏览状态
|
||||
- 不自动跳转到其他 tab
|
||||
- 登录弹窗下次重新打开时必须恢复初始表单状态:回到默认登录页签、关闭重置密码面板、清空密码 / 验证码 / 图形验证码 / 提示 / 倒计时等本次草稿状态;只允许保留“最近一次成功登录手机号”的本地回填能力。
|
||||
|
||||
---
|
||||
|
||||
@@ -149,6 +150,8 @@
|
||||
- 不再提供 `AuthGate` 层右上角固定悬浮的全局登录 / 账号信息入口
|
||||
- 登录触发统一来自页面内受保护动作、个人页、存档页等明确入口
|
||||
- 账号信息面板只通过页面内按钮打开,不在平台右上角常驻悬浮
|
||||
- 未登录移动端底部导航不展示“我的”时,平台页头必须保留一个直接可点的 `登录` 入口,避免用户只能通过受保护动作被动触发弹窗
|
||||
- 桌面端平台页头的账号胶囊在未登录时主文案必须直接显示 `登录`,不能只显示“进入账户”这类弱入口
|
||||
|
||||
## 4.2 平台首页数据加载
|
||||
|
||||
@@ -221,3 +224,4 @@
|
||||
3. 未登录选择 RPG 创作类型时,直接弹出登录弹窗,登录后自动进入创作工作台。
|
||||
4. 登录弹窗内没有介绍性大段文字,只剩必要输入与按钮。
|
||||
5. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。
|
||||
6. 未登录移动端首页页头存在明确 `登录` 入口,点击后打开同一个登录弹窗。
|
||||
|
||||
25
docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md
Normal file
25
docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 路由首屏图片等待门闩 2026-04-25
|
||||
|
||||
## 背景
|
||||
|
||||
平台首页、作品详情、拼图运行态等页面会在首屏展示作品封面、角色图、场景背景图或拼图原图。此前页面主体可能先进入,图片随后分批出现,移动端尤其容易看到背景图闪入、封面占位与真实图片切换。
|
||||
|
||||
## 落地约束
|
||||
|
||||
1. 入口路由组件加载完成后,页面主体先挂载但保持不可见,继续让业务数据请求、图片请求和布局计算正常发生。
|
||||
2. 门闩扫描当前路由根节点内的 `<img>`、`src/currentSrc` 和 CSS `background-image / border-image-source` 中的 `url(...)`。
|
||||
3. 扫描到的图片统一通过 `Image()` 预加载;图片成功、失败或超时都视为 settled,避免单张异常图片阻塞整站进入。
|
||||
4. 所有已发现图片 settled 且 DOM 短暂稳定后,再一次性显示页面主体。
|
||||
5. 门闩只负责前端呈现时机,不承接作品封面选择、资产读链路、玩法逻辑或后端数据裁决。
|
||||
|
||||
## 体验规则
|
||||
|
||||
- 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。
|
||||
- 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。
|
||||
- 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `src/routing/RouteImageReadyGate.tsx`
|
||||
- `src/routing/RouteImageReadyGate.test.ts`
|
||||
- `src/main.tsx`
|
||||
@@ -312,6 +312,68 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login modal resets draft state every time it is reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'password'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
|
||||
await user.click(within(firstDialog).getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
expect(
|
||||
await within(firstDialog).findByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
).toBeTruthy();
|
||||
await user.type(within(firstDialog).getByLabelText('验证码'), '123456');
|
||||
await user.click(within(firstDialog).getByRole('tab', { name: '密码登录' }));
|
||||
await user.type(within(firstDialog).getByLabelText('密码'), 'passw0rd');
|
||||
await user.click(within(firstDialog).getByRole('button', { name: '忘记密码' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '重置密码' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '关闭登录弹窗' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '进入作品' }));
|
||||
|
||||
const reopenedDialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(
|
||||
within(reopenedDialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('手机号') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(within(reopenedDialog).getByLabelText('验证码') as HTMLInputElement).value,
|
||||
).toBe('');
|
||||
expect(within(reopenedDialog).queryByLabelText('密码')).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).queryByText('短信请求已提交,验证码有效期约 5 分钟。'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('button', { name: '获取验证码' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate separates sms and password login by tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -75,6 +75,26 @@ export function LoginScreen({
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次重新打开弹窗都丢弃上一次未完成的表单草稿,只保留最近成功登录手机号回填。
|
||||
setIsResetPanelOpen(false);
|
||||
setPhone(getStoredLastLoginPhone());
|
||||
setPassword('');
|
||||
setCode('');
|
||||
setResetPhone('');
|
||||
setResetCode('');
|
||||
setResetPasswordValue('');
|
||||
setCaptchaAnswer('');
|
||||
setCooldownSeconds(0);
|
||||
setResetCooldownSeconds(0);
|
||||
setHint('');
|
||||
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
|
||||
}, [isOpen, phoneLoginEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
|
||||
setActiveLoginTab('password');
|
||||
|
||||
@@ -157,6 +157,55 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoggedOutHomeView(openLoginModal = vi.fn()) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal,
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -175,3 +224,13 @@ test('opens recharge modal and submits points product', async () => {
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(openLoginModal);
|
||||
await user.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Search,
|
||||
@@ -1896,8 +1897,18 @@ export function RpgEntryHomeView({
|
||||
if (!isDesktopLayout) {
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="mb-3 shrink-0 px-0.5">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
@@ -1912,7 +1923,7 @@ export function RpgEntryHomeView({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : 'grid-cols-2'}`}
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
@@ -2005,10 +2016,10 @@ export function RpgEntryHomeView({
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{authUi?.user?.displayName || '进入账户'}
|
||||
{authUi?.user?.displayName || '登录'}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
|
||||
{authUi?.user ? publicUserCode : '登录后同步作品与进度'}
|
||||
{authUi?.user ? publicUserCode : '账号入口'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {StrictMode, Suspense} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
import {resolveAppRoute} from './routing/appRoutes';
|
||||
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
||||
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
||||
|
||||
type AppRoot = ReturnType<typeof createRoot>;
|
||||
@@ -29,7 +30,12 @@ const RouteComponent = route.Component;
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Suspense fallback={<RouteLoadingScreen eyebrow={route.loadingEyebrow} text={route.loadingText} />}>
|
||||
<RouteComponent {...(route.componentProps ?? {})} />
|
||||
<RouteImageReadyGate
|
||||
eyebrow={route.loadingEyebrow}
|
||||
text={route.loadingText}
|
||||
>
|
||||
<RouteComponent {...(route.componentProps ?? {})} />
|
||||
</RouteImageReadyGate>
|
||||
</Suspense>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
44
src/routing/RouteImageReadyGate.test.ts
Normal file
44
src/routing/RouteImageReadyGate.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectRouteImageUrls,
|
||||
extractCssImageUrls,
|
||||
normalizePreloadImageUrl,
|
||||
} from './routeImageReadyGateUtils';
|
||||
|
||||
describe('RouteImageReadyGate image url helpers', () => {
|
||||
it('extracts urls from layered CSS image values', () => {
|
||||
expect(
|
||||
extractCssImageUrls(
|
||||
'linear-gradient(#000,#111), url("/hero.png"), url("icons/card.webp")',
|
||||
),
|
||||
).toEqual(['/hero.png', 'icons/card.webp']);
|
||||
});
|
||||
|
||||
it('normalizes preloadable urls against the current document', () => {
|
||||
expect(normalizePreloadImageUrl('/cover.png')).toBe(
|
||||
new URL('/cover.png', document.baseURI).href,
|
||||
);
|
||||
expect(normalizePreloadImageUrl('data:image/png;base64,abc')).toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
expect(normalizePreloadImageUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('collects img and CSS background urls from a route root', () => {
|
||||
const root = document.createElement('section');
|
||||
root.innerHTML = `
|
||||
<img src="/images/card.png" />
|
||||
<div style='background-image: url("/images/bg.webp")'></div>
|
||||
<div style='border-image-source: url("/ui/frame.png")'></div>
|
||||
`;
|
||||
|
||||
expect(collectRouteImageUrls(root)).toEqual([
|
||||
new URL('/images/card.png', document.baseURI).href,
|
||||
new URL('/images/bg.webp', document.baseURI).href,
|
||||
new URL('/ui/frame.png', document.baseURI).href,
|
||||
]);
|
||||
});
|
||||
});
|
||||
233
src/routing/RouteImageReadyGate.tsx
Normal file
233
src/routing/RouteImageReadyGate.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
collectRouteImageUrls,
|
||||
hasCssImageUrlChange,
|
||||
type ImagePreloadResult,
|
||||
} from './routeImageReadyGateUtils';
|
||||
import { RouteLoadingScreen } from './RouteLoadingScreen';
|
||||
|
||||
type RouteImageReadyGateProps = {
|
||||
children: ReactNode;
|
||||
eyebrow: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const IMAGE_GATE_QUIET_MS = 140;
|
||||
const IMAGE_GATE_MIN_VISIBLE_WAIT_MS = 260;
|
||||
const IMAGE_PRELOAD_TIMEOUT_MS = 12000;
|
||||
|
||||
const settledImageUrls = new Set<string>();
|
||||
const imagePreloadTasks = new Map<string, Promise<ImagePreloadResult>>();
|
||||
|
||||
function preloadImageUrl(url: string) {
|
||||
const existingTask = imagePreloadTasks.get(url);
|
||||
if (existingTask) {
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
const task = new Promise<ImagePreloadResult>((resolve) => {
|
||||
if (typeof Image === 'undefined') {
|
||||
resolve({ url, status: 'loaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
|
||||
const settle = (status: ImagePreloadResult['status']) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
settledImageUrls.add(url);
|
||||
resolve({ url, status });
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
settle('timeout');
|
||||
}, IMAGE_PRELOAD_TIMEOUT_MS);
|
||||
|
||||
image.onload = () => settle('loaded');
|
||||
image.onerror = () => settle('failed');
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
|
||||
if (image.complete) {
|
||||
settle(image.naturalWidth > 0 ? 'loaded' : 'failed');
|
||||
}
|
||||
});
|
||||
|
||||
imagePreloadTasks.set(url, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由首屏图片门闩:业务页面先真实挂载但不可见,
|
||||
* 等当前 DOM 中已发现的图片全部 settled 后再一次性显示。
|
||||
*/
|
||||
export function RouteImageReadyGate({
|
||||
children,
|
||||
eyebrow,
|
||||
text,
|
||||
}: RouteImageReadyGateProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const startTimeRef = useRef(0);
|
||||
const scanTimerRef = useRef<number | null>(null);
|
||||
const revealTimerRef = useRef<number | null>(null);
|
||||
const scanVersionRef = useRef(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
startTimeRef.current = window.performance.now();
|
||||
setReady(false);
|
||||
|
||||
const clearScanTimer = () => {
|
||||
if (scanTimerRef.current !== null) {
|
||||
window.clearTimeout(scanTimerRef.current);
|
||||
scanTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRevealTimer = () => {
|
||||
if (revealTimerRef.current !== null) {
|
||||
window.clearTimeout(revealTimerRef.current);
|
||||
revealTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleScan = () => {
|
||||
clearScanTimer();
|
||||
scanTimerRef.current = window.setTimeout(runScan, IMAGE_GATE_QUIET_MS);
|
||||
};
|
||||
|
||||
const scheduleReveal = (version: number) => {
|
||||
clearRevealTimer();
|
||||
|
||||
const elapsed = window.performance.now() - startTimeRef.current;
|
||||
const delay = Math.max(
|
||||
IMAGE_GATE_QUIET_MS,
|
||||
IMAGE_GATE_MIN_VISIBLE_WAIT_MS - elapsed,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setReady(true);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
function runScan() {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const version = scanVersionRef.current + 1;
|
||||
scanVersionRef.current = version;
|
||||
const pendingUrls = collectRouteImageUrls(root).filter(
|
||||
(url) => !settledImageUrls.has(url),
|
||||
);
|
||||
|
||||
if (pendingUrls.length === 0) {
|
||||
scheduleReveal(version);
|
||||
return;
|
||||
}
|
||||
|
||||
// 已进入页面但新 DOM 批量挂载图片时,先回到等待态,避免图片逐张闪入。
|
||||
setReady(false);
|
||||
void Promise.allSettled(pendingUrls.map(preloadImageUrl)).then(() => {
|
||||
if (disposed || version !== scanVersionRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduleScan();
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRescan = mutations.some((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
return (
|
||||
mutation.addedNodes.length > 0 ||
|
||||
mutation.removedNodes.length > 0
|
||||
);
|
||||
}
|
||||
if (mutation.type !== 'attributes') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
mutation.attributeName === 'src' ||
|
||||
mutation.attributeName === 'srcset'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return mutation.attributeName === 'style'
|
||||
? hasCssImageUrlChange(mutation)
|
||||
: false;
|
||||
});
|
||||
|
||||
if (!shouldRescan) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRevealTimer();
|
||||
scheduleScan();
|
||||
});
|
||||
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'srcset', 'style'],
|
||||
attributeOldValue: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
scheduleScan();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
clearScanTimer();
|
||||
clearRevealTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-hidden={!ready}
|
||||
style={{
|
||||
visibility: ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!ready ? (
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<RouteLoadingScreen eyebrow={eyebrow} text={text} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
111
src/routing/routeImageReadyGateUtils.ts
Normal file
111
src/routing/routeImageReadyGateUtils.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
const CSS_IMAGE_URL_PATTERN =
|
||||
/url\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\)/gu;
|
||||
|
||||
export type ImagePreloadResult = {
|
||||
url: string;
|
||||
status: 'loaded' | 'failed' | 'timeout';
|
||||
};
|
||||
|
||||
export function extractCssImageUrls(value: string) {
|
||||
const urls: string[] = [];
|
||||
CSS_IMAGE_URL_PATTERN.lastIndex = 0;
|
||||
|
||||
let match = CSS_IMAGE_URL_PATTERN.exec(value);
|
||||
while (match) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? '';
|
||||
const normalizedRawUrl = rawUrl.trim();
|
||||
if (normalizedRawUrl) {
|
||||
urls.push(normalizedRawUrl);
|
||||
}
|
||||
match = CSS_IMAGE_URL_PATTERN.exec(value);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function normalizePreloadImageUrl(rawUrl: string) {
|
||||
const trimmedUrl = rawUrl.trim();
|
||||
if (!trimmedUrl || trimmedUrl === 'none' || trimmedUrl.startsWith('#')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
trimmedUrl.startsWith('data:') ||
|
||||
trimmedUrl.startsWith('blob:') ||
|
||||
trimmedUrl.startsWith('http://') ||
|
||||
trimmedUrl.startsWith('https://')
|
||||
) {
|
||||
return trimmedUrl;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof document === 'undefined'
|
||||
? 'http://localhost/'
|
||||
: document.baseURI;
|
||||
|
||||
try {
|
||||
return new URL(trimmedUrl, baseUrl).href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addNormalizedImageUrl(urls: Set<string>, rawUrl: string | null) {
|
||||
if (!rawUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizePreloadImageUrl(rawUrl);
|
||||
if (normalizedUrl) {
|
||||
urls.add(normalizedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCssImageUrlChange(mutation: MutationRecord) {
|
||||
if (!(mutation.target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousUrls = mutation.oldValue
|
||||
? extractCssImageUrls(mutation.oldValue)
|
||||
: [];
|
||||
const currentUrls = [
|
||||
...extractCssImageUrls(mutation.target.style.backgroundImage),
|
||||
...extractCssImageUrls(mutation.target.style.borderImageSource),
|
||||
...extractCssImageUrls(mutation.target.style.listStyleImage),
|
||||
];
|
||||
|
||||
if (previousUrls.length !== currentUrls.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentUrls.some((url, index) => url !== previousUrls[index]);
|
||||
}
|
||||
|
||||
export function collectRouteImageUrls(root: HTMLElement) {
|
||||
const urls = new Set<string>();
|
||||
const elements = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
|
||||
|
||||
root.querySelectorAll<HTMLImageElement>('img').forEach((image) => {
|
||||
addNormalizedImageUrl(urls, image.currentSrc);
|
||||
addNormalizedImageUrl(urls, image.getAttribute('src'));
|
||||
});
|
||||
|
||||
elements.forEach((element) => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
[
|
||||
element.style.backgroundImage,
|
||||
element.style.borderImageSource,
|
||||
element.style.listStyleImage,
|
||||
computedStyle.backgroundImage,
|
||||
computedStyle.borderImageSource,
|
||||
computedStyle.listStyleImage,
|
||||
].forEach((cssImageValue) => {
|
||||
extractCssImageUrls(cssImageValue).forEach((url) => {
|
||||
addNormalizedImageUrl(urls, url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(urls);
|
||||
}
|
||||
Reference in New Issue
Block a user