From aa23ed307ff9ba9132339085d14e0608bbfa0e27 Mon Sep 17 00:00:00 2001 From: "fenge\\flying" Date: Tue, 7 Jul 2026 18:34:33 +0800 Subject: [PATCH] fix(preview): avoid stale script chunks when the pack is rebuilt out-of-band When Creator and the cli-driven IDE open the same project, they share /temp/programming/packer-driver. Creator recompiling on save mutates that shared store (new chunk hashes, old chunks removed); the cli then serves a stale in-memory import-map / browser-cached chunk that references a removed chunk hash, so the preview fails with SystemJS Error#3 (chunk 404). - scripting-routes: serve pack resources (/scripting/x, /chunks) with Cache-Control: no-store so the browser never keeps a stale chunk / import-map. - live-reload: watch temp/programming/packer-driver; when import-map / resolution-detail-map change (a compile finished, possibly in another process), refresh the in-memory QuickPackLoader (facet.notifyPackDriverUpdated) and trigger a browser reload, so the preview loads a consistent import-map + chunks. --- src/core/preview/live-reload.ts | 58 ++++++++++++++++++++++++++++ src/core/preview/scripting-routes.ts | 5 +++ 2 files changed, 63 insertions(+) diff --git a/src/core/preview/live-reload.ts b/src/core/preview/live-reload.ts index 0fa048da6..cb0866fac 100644 --- a/src/core/preview/live-reload.ts +++ b/src/core/preview/live-reload.ts @@ -1,3 +1,6 @@ +import { watch, FSWatcher } from 'fs'; +import { ensureDirSync } from 'fs-extra'; +import { join } from 'path'; import { socketService } from '../../server/socket'; import { invalidatePreviewSettings } from './preview-settings'; @@ -19,6 +22,9 @@ let configRef: { off?: Function; removeListener?: Function } | null = null; let onCompiled: (() => void) | null = null; let onRefreshFinish: (() => void) | null = null; let onConfigChanged: (() => void) | null = null; +// pack 产物目录监听:感知本进程或外部进程(如 Creator)对共用 temp/packer-driver 的重编。 +let packWatcher: FSWatcher | null = null; +let packTimer: NodeJS.Timeout | null = null; function removeListener(emitter: { off?: Function; removeListener?: Function } | null, event: string, fn: Function | null): void { if (!emitter || !fn) { @@ -48,6 +54,29 @@ export function triggerPreviewReload(): void { scheduleReload(); } +/** + * pack 产物(import-map / chunk)变化后:先刷新 cli 内存态 QuickPackLoader(读到磁盘最新映射), + * 再触发浏览器整页刷新,取到一致的 import-map + chunk。去抖,避免一次编译的多次写入触发多次刷新。 + */ +function schedulePackReload(): void { + if (packTimer) { + clearTimeout(packTimer); + } + packTimer = setTimeout(async () => { + packTimer = null; + try { + const { waitForProgrammingFacet } = await import('../scripting/programming/FacetInstance'); + const facet = await waitForProgrammingFacet(); + // 重载内存 loader:感知 Creator 等外部进程对共用 temp/packer-driver 的重编, + // 避免 cli 仍用旧 import-map 引用已被删除的 chunk 哈希 → 浏览器 chunk 404(SystemJS Error#3)。 + await facet.notifyPackDriverUpdated(); + } catch (e) { + console.warn('[Live Reload] refresh pack loader failed:', e); + } + scheduleReload(); + }, 300); +} + /** * 注册热重载监听。仅生效一次。 */ @@ -78,6 +107,27 @@ export async function registerLiveReload(): Promise { // 工程配置变更(set / reload) configurationManager.on(MessageType.Update, onConfigChanged); configurationManager.on(MessageType.Reload, onConfigChanged); + + // 监听 pack 产物目录:脚本被本进程或外部进程(如 Creator,共用 temp/programming/packer-driver) + // 重编时,import-map / chunk 会变化。此时刷新 cli 内存态 loader 并触发浏览器刷新, + // 解决「Creator 与 cli 同开一个项目、Creator 重编后 cli 仍用旧 import-map → chunk 404」。 + try { + const packDir = join(scripting.projectPath, 'temp', 'programming', 'packer-driver'); + ensureDirSync(packDir); + // recursive 在 Windows/macOS 支持;Linux 不支持会抛错,降级为不监听(config/compiled 事件仍覆盖本进程改动)。 + packWatcher = watch(packDir, { recursive: true }, (_event, filename) => { + if (!filename) { + return; + } + const name = filename.toString(); + // 只在编译结束信号(import-map / resolution-detail-map 重写)时刷新,忽略 chunks 写入噪声。 + if (name.endsWith('import-map.json') || name.endsWith('resolution-detail-map.json')) { + schedulePackReload(); + } + }); + } catch (e) { + console.warn('[Live Reload] watch packer-driver dir failed (pack hot-refresh disabled):', e); + } } /** @@ -91,6 +141,14 @@ export function unregisterLiveReload(): void { clearTimeout(timer); timer = null; } + if (packTimer) { + clearTimeout(packTimer); + packTimer = null; + } + if (packWatcher) { + packWatcher.close(); + packWatcher = null; + } removeListener(scriptingRef, 'compiled', onCompiled); removeListener(assetDBRef, 'assets:refresh-finish', onRefreshFinish); // MessageType.Update / MessageType.Reload diff --git a/src/core/preview/scripting-routes.ts b/src/core/preview/scripting-routes.ts index cea889883..33ea6ed42 100644 --- a/src/core/preview/scripting-routes.ts +++ b/src/core/preview/scripting-routes.ts @@ -341,6 +341,9 @@ export const scriptingRoutes = [ async handler(req: Request, res: Response, next: NextFunction) { const { waitForProgrammingFacet } = await import('../scripting/programming/FacetInstance'); const facet = await waitForProgrammingFacet(); + // pack 产物(import-map / resolution-detail-map / chunk)会随脚本重编而变化(哈希改变、旧 chunk 被删), + // 一旦浏览器缓存了旧 chunk,其内嵌 import 会指向已删除的哈希 → 404(SystemJS Error#3)。禁用缓存。 + res.set('Cache-Control', 'no-store'); const url = req.path.substring('/scripting/x'.length).replace(/^\//, ''); if (url === '' || url === '/') { @@ -417,6 +420,8 @@ export const scriptingRoutes = [ async handler(req: Request, res: Response, next: NextFunction) { const { waitForProgrammingFacet } = await import('../scripting/programming/FacetInstance'); const facet = await waitForProgrammingFacet(); + // 同 /scripting/x:chunk 随重编变化,禁用浏览器缓存避免旧 chunk 引用已删哈希导致 404。 + res.set('Cache-Control', 'no-store'); const url = req.path.substring(1); try { const packResource = await facet.loadPackResource(url);