fix(webview): preserve entry module identity for usage dashboard

This commit is contained in:
he_dai_xiang
2026-10-21 10:08:30 +08:00
parent ef2c5648ef
commit e0c356a542
3 changed files with 39 additions and 23 deletions
+30 -3
View File
@@ -20,8 +20,8 @@ test('packaged chat tabs bootstrap compressed assets and preserve input and lazy
const loadedAssets: string[] = [];
const startupMs: number[] = [];
// Mirror JCEF's binary resource handler: .gz is fetched as-is, while lazy modules
// and styles are decompressed by Java before reaching Chromium.
// Mirror JCEF's binary resource handler: modules and styles are stored as
// gzip and decompressed by Java before reaching Chromium at their stable URL.
await context.route('https://cc-gui-webview.local/assets/**', async (route) => {
const fileName = path.posix.basename(new URL(route.request().url()).pathname);
const compressed = fileName.endsWith('.js') || fileName.endsWith('.css');
@@ -55,8 +55,18 @@ test('packaged chat tabs bootstrap compressed assets and preserve input and lazy
}));
const bridge = window as PackagedWindow;
bridge.__packagedBridgeEvents = [];
localStorage.setItem('language', 'en');
bridge.sendToJava = (message) => {
bridge.__packagedBridgeEvents.push(message);
if (message.startsWith('tt_ensure_server:') || message.startsWith('tt_proxy:')) {
const request = JSON.parse(message.slice(message.indexOf(':') + 1));
const data = message.startsWith('tt_ensure_server:')
? { running: true, port: 7680 }
: { body: JSON.stringify({ data: [], totals: {}, projects: [], token: 'fixture' }) };
setTimeout(() => window.onTokenTrackerResponse?.(JSON.stringify({
requestId: request.requestId, ok: true, data,
})), 0);
}
if (message.startsWith('get_dependency_status:')) {
setTimeout(() => {
const callback = (window as unknown as Record<string, unknown>).updateDependencyStatus;
@@ -112,12 +122,29 @@ test('packaged chat tabs bootstrap compressed assets and preserve input and lazy
await expect(input).toBeEmpty();
if (tab === 1) {
// This lazy chunk imports React from the entry module. Reopening it must
// reuse the running app rather than evaluate a second entry/React copy.
for (let visit = 0; visit < 2; visit++) {
await page.locator('.header button:has(.codicon-settings-gear)').click();
await page.locator('.codicon-graph').click();
await expect(page.locator('.tt-dashboard')).toBeVisible();
await expect(page.locator('.tt-dashboard')).toContainText('Daily Breakdown');
await expect(page.locator('.tt-dashboard').getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled();
await expect.poll(() => page.evaluate(() => (window as PackagedWindow).__packagedBridgeEvents
.filter((event) => event.startsWith('frontend_ready:')).length)).toBe(1);
if (visit === 0) {
await page.screenshot({ path: testInfo.outputPath('packaged-usage.png') });
}
await page.locator('button:has(.codicon-arrow-left)').click();
await expect(input).toBeVisible();
}
await page.screenshot({ path: testInfo.outputPath('packaged-chat.png') });
}
await page.close();
}
expect(loadedAssets.some((name) => /^index-.*\.js\.gz$/.test(name))).toBe(true);
expect(loadedAssets.some((name) => /^index-.*\.js$/.test(name))).toBe(true);
expect(loadedAssets.some((name) => name.endsWith('.js.gz'))).toBe(false);
expect(loadedAssets.some((name) => /^flowDiagram-.*\.js$/.test(name))).toBe(true);
expect(errors).toEqual([]);
await testInfo.attach('startup-metrics', {
+7 -17
View File
@@ -33,29 +33,19 @@ const rewriteRelativeModuleImports = async (source) => {
return rewritten;
};
const injectCompressedModuleBootstrap = (html) => {
const injectModuleBootstrap = (html) => {
const moduleScriptPattern = /<script type="module" crossorigin src="([^"]+)"><\/script>/;
const match = html.match(moduleScriptPattern);
if (!match) {
throw new Error('Unable to find the Vite entry module');
}
const compressedModuleUrl = `${match[1]}.gz`;
// Use the same URL as imports from lazy chunks so the browser evaluates the
// entry (and its React instance) exactly once. A blob URL creates a second
// module identity when a lazy chunk imports the canonical entry URL.
// The JCEF asset handler decompresses the stored .js.gz for this .js URL.
const bootstrap = `<script type="module">
(async () => {
const response = await fetch(${JSON.stringify(compressedModuleUrl)});
if (!response.ok || !response.body) {
throw new Error(\`Failed to load compressed webview module: \${response.status}\`);
}
const sourceStream = response.body.pipeThrough(new DecompressionStream('gzip'));
const source = await new Response(sourceStream).text();
const objectUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
try {
await import(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
})().catch((error) => {
import(${JSON.stringify(match[1])}).catch((error) => {
console.error('[CC GUI] Webview bootstrap failed', error);
// A blank chat tab is indistinguishable from a slow one; surface the failure.
const fallback = document.createElement('p');
@@ -89,7 +79,7 @@ const copyAsset = async (fileName) => {
};
const main = async () => {
const html = injectCompressedModuleBootstrap(await readFile(distFile, 'utf-8'));
const html = injectModuleBootstrap(await readFile(distFile, 'utf-8'));
await mkdir(path.dirname(targetFile), { recursive: true });
await writeFile(targetFile, html, 'utf-8');
+2 -3
View File
@@ -52,9 +52,8 @@ test('packages a small bootstrap and lossless compressed assets with valid modul
assert.equal(result.status, 0, result.stderr);
const bootstrap = await readFile(path.join(resources, 'html/claude-chat.html'), 'utf8');
assert.ok(Buffer.byteLength(bootstrap) < 32_768, 'per-tab HTML must not inline the bundle');
assert.ok(bootstrap.includes(`${origin}/assets/index-entry.js.gz`));
assert.ok(bootstrap.includes("DecompressionStream('gzip')"));
assert.ok(bootstrap.includes('URL.revokeObjectURL(objectUrl)'));
assert.ok(bootstrap.includes(`import("${origin}/assets/index-entry.js")`));
assert.ok(!bootstrap.includes('createObjectURL'), 'entry and lazy imports must share one module URL');
const packaged = path.join(resources, 'webview/assets');
const compressed = await readFile(path.join(packaged, 'index-entry.js.gz'));