8000 fix(pwa): reduce cold-start work for 0.9.5 by InfinityPacer · Pull Request #32 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [未发布]

## [0.9.5] - 2026-10-06

- 页面冷启动时按需加载账户趋势图,减少首页启动需要下载和执行的脚本。
- 启动提示不再等待完整样式下载,网络较慢时也能先显示连接状态,样式就绪后再显示完整页面。

## [0.9.4] - 2026-10-04

- 七天额度预估按已观测的套餐区间隔离历史,换套餐后不再沿用旧套餐额度;同一周期中途变更也会重新计算,累计用量保持完整。
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.9.4",
"version": "0.9.5",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
2 changes: 1 addition & 1 deletion src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ import {
ReportBuildingPanel,
ReportRebuildingNotice,
} from "./components/ReportBuilding";
import { AccountTrend } from "./components/AccountTrend";
import { LazyAccountTrend as AccountTrend } from "./components/LazyAccountTrend";
import { ChartStyleControl } from "./components/ChartStyleControl";
import { useMobileLayout } from "./lib/use-mobile-layout";
import { useLiveUpdates } from "./lib/use-live-updates";
Expand Down
35 changes: 35 additions & 0 deletions src/web/boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** 完整样式加载期间保留内联启动提示,避免 React 提前替换成无样式内容。 */
export async function waitForAppStyles(): Promise<void> {
await Promise.all(
Array.from(
document.querySelectorAll<HTMLLinkElement>("link[data-app-styles]"),
(link) =>
new Promise<void>((resolve, reject) => {
if (link.dataset.failed === "true") {
reject(new Error("Application stylesheet failed to load"));
return;
}
if (link.sheet) {
link.media = "all";
resolve();
return;
}
const cleanup = () => {
link.removeEventListener("load", loaded);
link.removeEventListener("error", failed);
};
const loaded = () => {
cleanup();
link.media = "all";
resolve();
};
const failed = () => {
cleanup();
reject(new Error("Application stylesheet failed to load"));
};
link.addEventListener("load", loaded);
link.addEventListener("error", failed);
}),
),
);
}
29 changes: 29 additions & 0 deletions src/web/components/LazyAccountTrend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { lazy, Suspense } from "react";
import type { AccountTrendProps } from "./AccountTrend";
import "./account-trend.css";

// 微图不应把图表引擎带入启动依赖,尤其移动首页并不显示这些桌面微图。
const AccountTrend = lazy(() =>
import("./AccountTrend").then((module) => ({ default: module.AccountTrend })),
);

export function LazyAccountTrend(props: AccountTrendProps) {
return (
<Suspense
fallback={
<div
className={`mini-trend${props.hideCaption ? " is-caption-hidden" : ""}`}
>
{!props.hideCaption && (
<span className="mini-trend-caption">近 7 天</span>
)}
<div className="mini-trend-state" role="status">
读取中…
</div>
</div>
}
>
<AccountTrend {...props} />
</Suspense>
);
}
6 changes: 6 additions & 0 deletions src/web/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "./mobile.css";
import "./desktop.css";
import "./account-identity.css";
import { registerPwa } from "./pwa";
import { waitForAppStyles } from "./boot";

void registerPwa().catch(() => undefined);

Expand All @@ -18,6 +19,11 @@ const queryClient = new QueryClient({
queries: { retry: 1, staleTime: 30000, refetchOnWindowFocus: false },
},
});
await waitForAppStyles().catch((error: unknown) => {
const content = document.querySelector(".boot-shell__content");
if (content) content.textContent = "页面样式加载失败,请重新打开 Meterleaf。";
throw error;
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
Expand Down
95 changes: 95 additions & 0 deletions tests/pwa-boot.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { chromium, expect } from "@playwright/test";

const endpoint = process.env.METERLEAF_CDP_URL;
if (!endpoint) throw new Error("METERLEAF_CDP_URL is required");
const base = process.env.METERLEAF_TEST_URL ?? "http://127.0.0.1:4329/";
const browser = await chromium.connectOverCDP(endpoint);

try {
for (const scenario of [
{ name: "mobile-dark-css", width: 390, dark: true, asset: "css" },
{ name: "desktop-light-css", width: 1440, dark: false, asset: "css" },
{ name: "mobile-reduced-js", width: 390, dark: false, asset: "js" },
{ name: "mobile-api", width: 390, dark: true, asset: "api" },
{ name: "css-error-before-js", width: 390, dark: true, asset: "error" },
]) {
// 独立上下文隔离缓存、主题和 SW,不改动维护者预览页。
const context = await browser.newContext({
viewport: { width: scenario.width, height: 844 },
colorScheme: scenario.dark ? "dark" : "light",
reducedMotion: "reduce",
serviceWorkers: "block",
});
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
try {
const page = await context.newPage();
await page.route("**/api/**", async (route) => {
if (scenario.asset === "api") await gate;
await route.fulfill({
status: 503,
json: { error: "test unavailable" },
});
});
if (scenario.asset === "error") {
await page.route("**/assets/*.css", (route) => route.abort());
}
if (scenario.asset !== "api") {
const extension = scenario.asset === "error" ? "js" : scenario.asset;
await page.route(`**/assets/*.${extension}`, async (route) => {
await gate;
await route.continue();
});
}
await page.goto(base, { waitUntil: "commit" });
if (scenario.asset === "api") {
await expect(page.locator(".app-shell")).toBeVisible();
} else {
await expect(page.locator(".boot-shell")).toBeVisible();
// DOM 存在和尺寸可见不能证明首绘,必须在资源仍被拦截时看到 FCP。
await expect
.poll(() =>
page.evaluate(
() =>
performance.getEntriesByName("first-contentful-paint").length,
),
)
.toBe(1);
await expect(page.locator(".app-shell")).toHaveCount(0);
}
const paints = await page.evaluate(() =>
performance.getEntriesByType("paint").map((entry) => ({
name: entry.name,
ms: Math.round(entry.startTime),
})),
);
release();
if (scenario.asset === "error") {
await expect(page.locator(".boot-shell")).toContainText(
"页面样式加载失败,请重新打开 Meterleaf。",
);
await expect(page.locator(".app-shell")).toHaveCount(0);
} else {
await expect(page.locator(".app-shell")).toBeVisible();
await expect(page.locator(".boot-shell")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLLinkElement>(
"link[data-app-styles]",
),
).every((link) => link.media === "all" && !!link.sheet),
),
)
.toBe(true);
}
console.log(JSON.stringify({ scenario: scenario.name, paints }));
} finally {
release();
await context.close();
}
}
} finally {
await browser.close();
}
20 changes: 19 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,25 @@ import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";

export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
{
name: "meterleaf-boot-styles",
apply: "build",
transformIndexHtml: {
order: "post",
// 启动提示使用内联样式,完整样式下载不应阻止它首次绘制。
handler: (html) =>
html.replace(/<link\b[^>]*\brel="stylesheet"[^>]*>/g, (link) =>
link.replace(
"<link",
'<link data-app-styles media="print" >
),
),
},
},
],
resolve: {
alias: { "@": fileURLToPath(new URL("./src/web", import.meta.url)) },
},
Expand Down
Loading
0