perf: 优化旧版浏览器页面恢复与资源加载

- 页面恢复时延后版本检查并补齐根背景,减少白闪与首帧卡顿

- 面向 Chrome 90 收敛公共分包,按需加载资源弹窗和知识库面板

- 压缩工作流图标并补充延迟加载与可见性生命周期测试
This commit is contained in:
2026-07-31 16:11:14 +08:00
parent 5c29ca9407
commit 12080ceedb
15 changed files with 505 additions and 68 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -0,0 +1,86 @@
/* eslint-disable vue/one-component-per-file -- Test-specific host components keep lazy-mount scenarios isolated. */
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import { createLazyComponentController } from './lazy-component';
interface DeferredInstance {
openDialog: () => void;
}
describe('createLazyComponentController', () => {
it('loads and mounts the component only when its instance is requested', async () => {
const openDialog = vi.fn();
const DeferredComponent = defineComponent({
setup(_, { expose }) {
expose({ openDialog });
return () => h('div', 'deferred');
},
});
const loader = vi.fn().mockResolvedValue({ default: DeferredComponent });
const controller = createLazyComponentController<DeferredInstance>(loader);
const Host = defineComponent({
setup() {
return () =>
controller.component.value
? h(controller.component.value, {
ref: controller.componentRef,
})
: null;
},
});
const wrapper = mount(Host);
expect(loader).not.toHaveBeenCalled();
expect(controller.loading.value).toBe(false);
const instance = await controller.ensureInstance();
instance.openDialog();
expect(loader).toHaveBeenCalledTimes(1);
expect(openDialog).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toBe('deferred');
expect(controller.loading.value).toBe(false);
await controller.ensureInstance();
expect(loader).toHaveBeenCalledTimes(1);
});
it('clears loading state and allows retry after a loading failure', async () => {
const DeferredComponent = defineComponent({
setup() {
return () => h('div', 'deferred');
},
});
const loader = vi
.fn()
.mockRejectedValueOnce(new Error('chunk failed'))
.mockResolvedValueOnce({ default: DeferredComponent });
const controller = createLazyComponentController<DeferredInstance>(loader);
const Host = defineComponent({
setup() {
return () =>
controller.component.value
? h(controller.component.value, {
ref: controller.componentRef,
})
: null;
},
});
mount(Host);
await expect(controller.ensureInstance()).rejects.toThrow('chunk failed');
expect(controller.loading.value).toBe(false);
const retryPromise = controller.ensureInstance();
await flushPromises();
await expect(retryPromise).resolves.toBeDefined();
expect(loader).toHaveBeenCalledTimes(2);
expect(controller.loading.value).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
import type { Component, DeepReadonly, Ref, ShallowRef } from 'vue';
import { markRaw, nextTick, readonly, ref, shallowRef } from 'vue';
interface LazyComponentModule {
default: Component;
}
interface LazyComponentController<Instance extends object> {
component: ShallowRef<Component | undefined>;
componentRef: ShallowRef<Instance | undefined>;
ensureInstance: () => Promise<Instance>;
loading: DeepReadonly<Ref<boolean>>;
}
/**
* 延迟加载并挂载通过模板 ref 调用的组件。
*
* @param loader 组件模块加载函数。
* @returns 延迟组件、组件实例引用、加载状态和实例获取函数。
*/
function createLazyComponentController<Instance extends object>(
loader: () => Promise<LazyComponentModule>,
): LazyComponentController<Instance> {
const component = shallowRef<Component>();
const componentRef = shallowRef<Instance>();
const loading = ref(false);
let loadPromise: Promise<Component> | undefined;
let mountPromise: Promise<Instance> | undefined;
async function loadComponent() {
if (component.value) {
return component.value;
}
if (!loadPromise) {
loadPromise = loader()
.then((module) => {
if (!module.default) {
throw new Error('Lazy component module has no default export');
}
return markRaw(module.default);
})
.catch((error) => {
loadPromise = undefined;
throw error;
});
}
const resolvedComponent = await loadPromise;
component.value = resolvedComponent;
return resolvedComponent;
}
function ensureInstance() {
if (componentRef.value) {
return Promise.resolve(componentRef.value);
}
if (mountPromise) {
return mountPromise;
}
loading.value = true;
mountPromise = loadComponent()
.then(async () => {
await nextTick();
if (!componentRef.value) {
throw new Error('Lazy component failed to mount');
}
return componentRef.value;
})
.finally(() => {
loading.value = false;
mountPromise = undefined;
});
return mountPromise;
}
return {
component,
componentRef,
ensureInstance,
loading: readonly(loading),
};
}
export { createLazyComponentController };

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, defineAsyncComponent, nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@easyflow/access';
@@ -7,25 +7,64 @@ import { $t } from '@easyflow/locales';
import { useUserStore } from '@easyflow/stores';
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
import { ElButton, ElIcon, ElImage } from 'element-plus';
import { ElButton, ElIcon, ElImage, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import bookIcon from '#/assets/ai/knowledge/book.svg';
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
import ChunkDocumentTable from '#/views/ai/documentCollection/ChunkDocumentTable.vue';
import DocumentCollectionDataConfig from '#/views/ai/documentCollection/DocumentCollectionDataConfig.vue';
import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
import { createLazyComponentController } from '#/utils/lazy-component';
const ChunkDocumentTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/ChunkDocumentTable.vue'),
);
const DocumentCollectionDataConfig = defineAsyncComponent(
() =>
import('#/views/ai/documentCollection/DocumentCollectionDataConfig.vue'),
);
const DocumentTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/DocumentTable.vue'),
);
const FaqTable = defineAsyncComponent(
() => import('#/views/ai/documentCollection/FaqTable.vue'),
);
const KnowledgeSearch = defineAsyncComponent(
() => import('#/views/ai/documentCollection/KnowledgeSearch.vue'),
);
const KnowledgeShareManagement = defineAsyncComponent(
() => import('#/views/ai/documentCollection/KnowledgeShareManagement.vue'),
);
const SegmenterDoc = defineAsyncComponent(
() => import('#/views/ai/documentCollection/SegmenterDoc.vue'),
);
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const { hasAccessByCodes } = useAccess();
interface ImportKnowledgeDocFileInstance {
openDialog: () => void;
}
const {
component: ImportKnowledgeDocFileComponent,
componentRef: importDocModalRef,
ensureInstance: ensureImportKnowledgeDocFile,
loading: importDialogLoading,
} = createLazyComponentController<ImportKnowledgeDocFileInstance>(
() => import('#/views/ai/documentCollection/ImportKnowledgeDocFile.vue'),
);
async function openImportDialog() {
try {
const modal = await ensureImportKnowledgeDocFile();
modal.openDialog();
} catch (error) {
console.error('Failed to load document import modal:', error);
ElMessage.error($t('message.getDataError'));
}
}
const knowledgeId = ref<string>((route.query.id as string) || '');
const activeMenu = ref<string>((route.query.activeMenu as string) || '');
const knowledgeInfo = ref<any>({});
@@ -149,10 +188,9 @@ const headerButtons = [
];
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
const documentTableRef = ref();
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
const documentTitle = ref('');
const handleSearch = (searchParams: string) => {
documentTableRef.value.search(searchParams);
documentTableRef.value?.search?.(searchParams);
};
const handleButtonClick = (event: any) => {
// 根据按钮 key 执行不同操作
@@ -162,7 +200,7 @@ const handleButtonClick = (event: any) => {
break;
}
case 'importFile': {
importDocModalRef.value?.openDialog?.();
void openImportDialog();
break;
}
}
@@ -193,7 +231,7 @@ const backDoc = async () => {
</script>
<template>
<div class="document-container">
<div v-loading="importDialogLoading" class="document-container">
<div class="doc-header-container">
<div class="doc-knowledge-container">
<div @click="back()" style="cursor: pointer">
@@ -301,7 +339,9 @@ const backDoc = async () => {
</div>
</div>
</div>
<ImportKnowledgeDocFile
<component
:is="ImportKnowledgeDocFileComponent"
v-if="ImportKnowledgeDocFileComponent"
ref="importDocModalRef"
:knowledge-id-prop="String(knowledgeId)"
@imported="backDoc"

View File

@@ -44,8 +44,8 @@ import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { createLazyComponentController } from '#/utils/lazy-component';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
import {
@@ -70,6 +70,29 @@ const collectionTypeLabelMap = {
};
type VisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
interface DocumentCollectionModalInstance {
openDialog: (row: Record<string, unknown>) => void;
}
const {
component: DocumentCollectionModalComponent,
componentRef: aiKnowledgeModalRef,
ensureInstance: ensureDocumentCollectionModal,
loading: knowledgeModalLoading,
} = createLazyComponentController<DocumentCollectionModalInstance>(
() => import('#/views/ai/documentCollection/DocumentCollectionModal.vue'),
);
async function openKnowledgeModal(row: Record<string, unknown>) {
try {
const modal = await ensureDocumentCollectionModal();
modal.openDialog(row);
} catch (error) {
console.error('Failed to load knowledge modal:', error);
ElMessage.error($t('message.getDataError'));
}
}
const canManageKnowledgePermission = computed(() =>
hasAccessByCodes(['/api/v1/documentCollection/save']),
);
@@ -205,7 +228,7 @@ const actions: ActionButton[] = [
if (!ensureManageKnowledgeItem(row)) {
return;
}
aiKnowledgeModalRef.value.openDialog(row);
void openKnowledgeModal(row);
},
},
{
@@ -460,7 +483,6 @@ function resolvePublishStatusMeta(
}
const pageDataRef = ref();
const aiKnowledgeModalRef = ref();
const headerButtons = [
{
key: 'add',
@@ -474,7 +496,7 @@ const headerButtons = [
const handleButtonClick = (event: any, _item: any) => {
switch (event.key) {
case 'add': {
aiKnowledgeModalRef.value.openDialog({});
void openKnowledgeModal({});
break;
}
}
@@ -667,7 +689,7 @@ function changeCategory(category: any) {
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<div v-loading="knowledgeModalLoading" class="flex h-full flex-col gap-6 p-6">
<div class="knowledge-header">
<HeaderSearch
:buttons="headerButtons"
@@ -865,7 +887,9 @@ function changeCategory(category: any) {
</EasyFlowFormModal>
<!-- 新增知识库模态框-->
<DocumentCollectionModal
<component
:is="DocumentCollectionModalComponent"
v-if="DocumentCollectionModalComponent"
ref="aiKnowledgeModalRef"
@reload="reloadKnowledgeList"
/>

View File

@@ -29,7 +29,7 @@ import PluginToolIcon from '#/components/icons/PluginToolIcon.vue';
import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue';
import AddPluginModal from '#/views/ai/plugin/AddPluginModal.vue';
import { createLazyComponentController } from '#/utils/lazy-component';
import { buildPluginPageQueryParams } from './plugin-query';
import {
@@ -61,6 +61,29 @@ interface CategoryFormData {
name: string;
}
interface AddPluginModalInstance {
openDialog: (row: Record<string, unknown>) => void;
}
const {
component: AddPluginModalComponent,
componentRef: aiPluginModalRef,
ensureInstance: ensureAddPluginModal,
loading: pluginModalLoading,
} = createLazyComponentController<AddPluginModalInstance>(
() => import('#/views/ai/plugin/AddPluginModal.vue'),
);
async function openPluginDialog(row: Record<string, unknown>) {
try {
const modal = await ensureAddPluginModal();
modal.openDialog(row);
} catch (error) {
console.error('Failed to load plugin modal:', error);
ElMessage.error($t('message.getDataError'));
}
}
function resolveNavTitle(item: PluginRecord) {
return (item.title as string) || (item.name as string) || '';
}
@@ -93,7 +116,7 @@ const actions: ActionButton[] = [
permission: '/api/v1/plugin/save',
placement: 'inline',
onClick(item) {
aiPluginModalRef.value.openDialog(item);
void openPluginDialog(item);
},
},
{
@@ -194,7 +217,6 @@ const handleDelete = (item: PluginRecord) => {
};
const pageDataRef = ref();
const aiPluginModalRef = ref();
const headerButtons = [
{
key: 'add',
@@ -221,7 +243,7 @@ const handleSubmit = () => {
const handleButtonClick = (event: HeaderActionEvent, _item: unknown) => {
switch (event.key) {
case 'add': {
aiPluginModalRef.value.openDialog({});
void openPluginDialog({});
break;
}
}
@@ -316,7 +338,7 @@ const handleClickCategory = (item: PluginCategory) => {
</script>
<template>
<div class="knowledge-container">
<div v-loading="pluginModalLoading" class="knowledge-container">
<div class="knowledge-header">
<HeaderSearch
:buttons="headerButtons"
@@ -378,7 +400,12 @@ const handleClickCategory = (item: PluginCategory) => {
</PageData>
</div>
</div>
<AddPluginModal ref="aiPluginModalRef" @reload="reloadCurrentList" />
<component
:is="AddPluginModalComponent"
v-if="AddPluginModalComponent"
ref="aiPluginModalRef"
@reload="reloadCurrentList"
/>
<EasyFlowFormModal
:title="isEdit ? `${$t('button.edit')}` : `${$t('button.add')}`"
v-model:open="dialogVisible"

View File

@@ -64,6 +64,7 @@ import { $t } from '#/locales';
import { router } from '#/router';
import { useDictStore } from '#/store';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { createLazyComponentController } from '#/utils/lazy-component';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
@@ -82,7 +83,6 @@ import {
mergeWorkflowListRouteQuery,
parseWorkflowListRouteState,
} from './workflow-list-route-state';
import WorkflowModal from './WorkflowModal.vue';
const ElXMarkdown = defineAsyncComponent(
() => import('vue-element-plus-x/es/XMarkdown/index.js'),
@@ -116,6 +116,19 @@ interface ApiFieldDoc {
placeholder: string;
}
interface WorkflowModalInstance {
openDialog: (row: Record<string, unknown>, importMode?: boolean) => void;
}
const {
component: WorkflowModalComponent,
componentRef: saveDialog,
ensureInstance: ensureWorkflowModal,
loading: workflowModalLoading,
} = createLazyComponentController<WorkflowModalInstance>(
() => import('./WorkflowModal.vue'),
);
const primaryAction: CardPrimaryAction = {
showIndicator: false,
text: $t('button.design'),
@@ -283,7 +296,6 @@ onMounted(() => {
getSideList();
});
const pageDataRef = ref();
const saveDialog = ref();
const selectedCategoryId = ref<number | string>(initialListState.categoryId);
const searchKeyword = ref(initialListState.keyword);
const currentPageNumber = ref(initialListState.pageNumber);
@@ -401,8 +413,14 @@ function handleSearch(keyword: string) {
function reloadCurrentList() {
pageDataRef.value?.reload?.();
}
function showDialog(row: any, importMode = false) {
saveDialog.value.openDialog({ ...row }, importMode);
async function showDialog(row: any, importMode = false) {
try {
const modal = await ensureWorkflowModal();
modal.openDialog({ ...row }, importMode);
} catch (error) {
console.error('Failed to load workflow modal:', error);
ElMessage.error($t('message.getDataError'));
}
}
function resolveNavTitle(row: any) {
return row?.title || row?.name || '';
@@ -1144,8 +1162,13 @@ function handleHeaderButtonClick(data: any) {
</script>
<template>
<div class="flex h-full flex-col gap-6 p-6">
<WorkflowModal ref="saveDialog" @reload="reloadCurrentList" />
<div v-loading="workflowModalLoading" class="flex h-full flex-col gap-6 p-6">
<component
:is="WorkflowModalComponent"
v-if="WorkflowModalComponent"
ref="saveDialog"
@reload="reloadCurrentList"
/>
<ElDialog
v-model="apiInstructionVisible"
:title="$t('aiWorkflow.apiInstruction')"

View File

@@ -1,13 +1,40 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from '@easyflow/vite-config';
import ElementPlus from 'unplugin-element-plus/vite';
import { createBasePathRedirectPlugin } from './vite-base-path-redirect';
const resourceListChunkEntries = [
'./src/components/headerSearch/HeaderSearch.vue',
'./src/components/page/CardList.vue',
'./src/components/page/PageData.vue',
'./src/components/page/PageSide.vue',
].map((file) => fileURLToPath(new URL(file, import.meta.url)));
function isResourceListModule(id: string) {
return resourceListChunkEntries.some(
(entry) => id === entry || id.startsWith(`${entry}?`),
);
}
export default defineConfig(async () => {
return {
application: {},
vite: {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (isResourceListModule(id)) {
return 'resource-list';
}
},
onlyExplicitManualChunks: true,
},
},
},
resolve: {
alias: {
// @vueuse/motion expects tslib default export during dev pre-bundling

View File

@@ -66,7 +66,7 @@ function defineApplicationConfig(userConfigPromise?: DefineApplicationOptions) {
entryFileNames: 'jse/index-[name]-[hash].js',
},
},
target: 'es2015',
target: 'chrome90',
},
css: createCssOptions(injectGlobalScss),
esbuild: {

View File

@@ -16,7 +16,6 @@
html {
@apply text-foreground bg-background font-sans text-[100%];
background-color: hsl(var(--surface-canvas));
font-variation-settings: normal;
line-height: 1.15;
text-size-adjust: 100%;
@@ -34,6 +33,8 @@
html {
@apply size-full;
background-color: hsl(var(--surface-canvas));
/* scrollbar-gutter: stable; */
}

View File

@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CheckUpdates from './check-updates.vue';
const { modalOpen } = vi.hoisted(() => ({
modalOpen: vi.fn(),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('@easyflow-core/popup-ui', () => ({
useEasyFlowModal: () => [
{
name: 'UpdateNoticeModalStub',
template: '<div><slot /></div>',
},
{ open: modalOpen },
],
}));
describe('check updates visibility lifecycle', () => {
let hidden = false;
beforeEach(() => {
hidden = false;
modalOpen.mockClear();
vi.useFakeTimers();
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
vi.stubGlobal('location', { hostname: 'customer.example' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
headers: {
get: (name: string) => (name === 'etag' ? 'version-1' : null),
},
}),
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('restarts polling without fetching immediately when the page becomes visible', async () => {
const wrapper = mount(CheckUpdates);
const fetchMock = vi.mocked(fetch);
hidden = true;
document.dispatchEvent(new Event('visibilitychange'));
hidden = false;
document.dispatchEvent(new Event('visibilitychange'));
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(59_999);
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await flushPromises();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'/',
expect.objectContaining({ method: 'HEAD' }),
);
expect(modalOpen).not.toHaveBeenCalled();
wrapper.unmount();
});
});

View File

@@ -20,7 +20,6 @@ const props = withDefaults(defineProps<Props>(), {
});
let isCheckingUpdates = false;
let resumeCheckFrame: number | undefined;
let versionCheckController: AbortController | null = null;
const VERSION_CHECK_TIMEOUT_MS = 10_000;
const currentVersionTag = ref('');
@@ -121,36 +120,12 @@ function start() {
);
}
function cancelResumeCheck() {
if (resumeCheckFrame === undefined) {
return;
}
cancelAnimationFrame(resumeCheckFrame);
resumeCheckFrame = undefined;
}
function scheduleResumeCheck() {
cancelResumeCheck();
// 页面恢复后先让浏览器完成两帧绘制,再发起版本检查请求。
resumeCheckFrame = requestAnimationFrame(() => {
resumeCheckFrame = requestAnimationFrame(() => {
resumeCheckFrame = undefined;
if (document.hidden) return;
runUpdateCheck().finally(() => {
if (!document.hidden) {
start();
}
});
});
});
}
function handleVisibilitychange() {
if (document.hidden) {
cancelResumeCheck();
stop();
} else {
scheduleResumeCheck();
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
start();
}
}
@@ -169,7 +144,6 @@ onMounted(() => {
});
onUnmounted(() => {
cancelResumeCheck();
stop();
document.removeEventListener('visibilitychange', handleVisibilitychange);
});

View File

@@ -33,6 +33,8 @@
html {
@apply size-full;
background-color: hsl(var(--background));
/* scrollbar-gutter: stable; */
}

View File

@@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CheckUpdates from './check-updates.vue';
const { modalOpen } = vi.hoisted(() => ({
modalOpen: vi.fn(),
}));
vi.mock('@easyflow/locales', () => ({
$t: (key: string) => key,
}));
vi.mock('@easyflow-core/popup-ui', () => ({
useEasyFlowModal: () => [
{
name: 'UpdateNoticeModalStub',
template: '<div><slot /></div>',
},
{ open: modalOpen },
],
}));
describe('check updates visibility lifecycle', () => {
let hidden = false;
beforeEach(() => {
hidden = false;
modalOpen.mockClear();
vi.useFakeTimers();
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden);
vi.stubGlobal('location', { hostname: 'customer.example' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
headers: {
get: (name: string) => (name === 'etag' ? 'version-1' : null),
},
}),
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('restarts polling without fetching immediately when the page becomes visible', async () => {
const wrapper = mount(CheckUpdates);
const fetchMock = vi.mocked(fetch);
hidden = true;
document.dispatchEvent(new Event('visibilitychange'));
hidden = false;
document.dispatchEvent(new Event('visibilitychange'));
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(59_999);
expect(fetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await flushPromises();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'/',
expect.objectContaining({ method: 'HEAD' }),
);
expect(modalOpen).not.toHaveBeenCalled();
wrapper.unmount();
});
});

View File

@@ -124,11 +124,8 @@ function handleVisibilitychange() {
if (document.hidden) {
stop();
} else {
runUpdateCheck().finally(() => {
if (!document.hidden) {
start();
}
});
// 恢复窗口时只重启轮询,避免与旧浏览器的首帧重绘争抢资源。
start();
}
}