From 14283db94332e1ced6d30fab28be8ae9432e1be1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:08:21 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E9=A1=B5=E3=80=8C=E5=88=B7=E6=96=B0=E8=BF=90=E8=A1=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=80=8D=E4=B8=8D=E6=98=AF=E7=9C=9F=E6=AD=A3=E9=87=8D?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 刷新时给 iframe 地址加一枚随 reloadNonce 递增的 __agc_reload 参数:同一地址的导航可能命中缓存,只换元素身份不足以保证重新请求 - 重新载入时丢掉上一版量到的内容尺寸,新页面先在容器尺寸上适配,不再被上一版的适配结果渲染 - tests/localGamePreviewFrame.test.ts 补刷新回归:换新节点 + 地址带一次性参数 + 丢弃旧测量 - tests/runPreviewRefresh.test.tsx 断言改为新契约(原地址逐字保留、参数逐次递增、节点每次都是新的) --- .../LocalGamePreviewFrame.tsx | 22 ++++++++-- .../tests/localGamePreviewFrame.test.ts | 43 +++++++++++++++++++ .../tests/runPreviewRefresh.test.tsx | 23 +++++++++- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx index f47951e8f..df58a5855 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -292,6 +292,20 @@ export function LocalGamePreviewFrame({ onInspectExit?: () => void; }) { const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + /** + * iframe 真正请求的地址。刷新时额外带一个只随 `reloadNonce` 变化的非业务参数。 + * + * 光换元素身份(`key`)不足以保证"重新拿一次页面":同一地址的导航可能命中 HTTP 缓存,用户看到的 + * 还是上一版(画面崩了、或刚重建过 dist,都恢复不过来,只能切走再切回来)。带上这个参数后这次导航 + * 一定是新请求。参数不进业务语义,原地址的既有 query / hash 原样保留,首次加载(nonce=0)不动地址。 + */ + const iframeUrl = useMemo(() => { + if (!embeddedUrl) return null; + if (reloadNonce <= 0) return embeddedUrl; + const url = new URL(embeddedUrl); + url.searchParams.set('__agc_reload', String(reloadNonce)); + return url.toString(); + }, [embeddedUrl, reloadNonce]); const containerRef = useRef(null); const iframeRef = useRef(null); const measuredContainerSizeRef = useRef({ width: 1, height: 1 }); @@ -361,12 +375,14 @@ export function LocalGamePreviewFrame({ }, [embeddedUrl]); useEffect(() => { + // 重新载入(换元素身份或换地址)时丢掉上一版量到的内容尺寸:新页面还没上报之前先按容器适配, + // 否则会拿上一版的尺寸渲染新页面(画面崩过一次后看着像"刷新没生效",只有切走再切回来才恢复)。 reportedViewportSizeRef.current = null; if (contentSizeRef.current) { contentSizeRef.current = null; setContentSize(null); } - }, [embeddedUrl]); + }, [iframeUrl]); useEffect(() => { if (!embeddedUrl) return; @@ -427,7 +443,7 @@ export function LocalGamePreviewFrame({ () => resolveLocalGamePreviewFitLayout(containerSize, contentSize), [containerSize, contentSize], ); - if (!embeddedUrl) { + if (!iframeUrl) { return null; } return ( @@ -437,7 +453,7 @@ export function LocalGamePreviewFrame({ key={reloadNonce} className={className} title={title} - src={embeddedUrl} + src={iframeUrl ?? undefined} scrolling="no" style={{ width: `${fit.width}px`, diff --git a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts index 422237838..416acb9d3 100644 --- a/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts +++ b/apps/ai-game-creator-shell/tests/localGamePreviewFrame.test.ts @@ -57,6 +57,20 @@ function renderFittedFrame(initialContainer: { const iframe = view.getByTitle('preview') as HTMLIFrameElement; return { iframe, + currentIframe() { + return view.container.querySelector('iframe') as HTMLIFrameElement | null; + }, + setReloadNonce(nonce: number) { + act(() => { + view.rerender( + createElement(LocalGamePreviewFrame, { + preview: { status: 'running', url: 'http://127.0.0.1:1234/' }, + title: 'preview', + reloadNonce: nonce, + }), + ); + }); + }, resizeTo(next: { width: number; height: number }) { containerRect = next; act(() => { @@ -103,6 +117,35 @@ describe('local game preview viewport fitting', () => { frame.cleanup(); }); + it('刷新运行画面:换新节点、地址带一次性参数,并丢掉上一版的测量结果按容器重新适配', () => { + // 「刷新没生效、切走再切回来才好」的两个缺口都在这里钉住:只换元素身份时,量到的内容尺寸还留在 + // 组件里(新页面被上一版的尺寸渲染),地址也不带变化(同一地址的导航可能直接吃缓存)。 + const frame = renderFittedFrame({ width: 800, height: 500 }); + frame.reportSize({ + contentWidth: 800, + contentHeight: 835, + viewportWidth: 800, + viewportHeight: 500, + }); + expect(frame.iframe.style.height).toBe('835px'); + expect(frame.iframe.getAttribute('src')).toBe('http://127.0.0.1:1234/'); + + frame.setReloadNonce(1); + + const reloaded = frame.currentIframe(); + expect(reloaded).not.toBe(frame.iframe); + expect( + new URL(String(reloaded?.getAttribute('src'))).searchParams.get( + '__agc_reload', + ), + ).toBe('1'); + // 上一版量到的 835px 不许带进新页面:先按容器适配,等新页面自己上报。 + expect(reloaded?.style.width).toBe('800px'); + expect(reloaded?.style.height).toBe('500px'); + + frame.cleanup(); + }); + it('returns the fitted iframe to the container after the host viewport shrinks', () => { // 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着 // 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。 diff --git a/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx index c0fa9213e..0e206995f 100644 --- a/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx +++ b/apps/ai-game-creator-shell/tests/runPreviewRefresh.test.tsx @@ -82,12 +82,13 @@ afterEach(() => { }); describe('运行页「刷新运行画面」', () => { - it('点一下就重新载入画面:iframe 换新节点、地址不变,并顺带把预览确认成活体', async () => { + it('点一下就是一次真正的重新载入:换新节点 + 地址带上一次性刷新参数,且顺带把预览确认成活体', async () => { const { onPlay } = renderRunView(); await screen.findByTitle('运行页刷新画面 游戏运行画面'); const before = document.querySelector('iframe'); expect(before).not.toBeNull(); + // 首次加载不动地址:原样的 loopback 地址,不带任何非业务参数。 expect(before?.getAttribute('src')).toBe(PREVIEW_URL); fireEvent.click(screen.getByRole('button', { name: '刷新运行画面' })); @@ -95,10 +96,28 @@ describe('运行页「刷新运行画面」', () => { const after = document.querySelector('iframe'); // 换元素身份=浏览器重新载入这一页:游戏预览不是 vite dev 的实时刷新,只有这一条路。 expect(after).not.toBe(before); - expect(after?.getAttribute('src')).toBe(PREVIEW_URL); + // 地址只多一枚随刷新递增的参数:原地址(含既有 query)逐字保留,导航因此一定是一次新请求。 + const afterUrl = new URL(String(after?.getAttribute('src'))); + expect(`${afterUrl.origin}${afterUrl.pathname}`).toBe(PREVIEW_URL); + expect(afterUrl.searchParams.get('__agc_reload')).toBe('1'); expect(onPlay).toHaveBeenCalledTimes(1); }); + it('连点两次各自都是一次新的载入:参数逐次递增,不是同一个地址原地打转', async () => { + renderRunView(); + await screen.findByTitle('运行页刷新画面 游戏运行画面'); + + fireEvent.click(screen.getByRole('button', { name: '刷新运行画面' })); + const first = document.querySelector('iframe'); + fireEvent.click(screen.getByRole('button', { name: '刷新运行画面' })); + const second = document.querySelector('iframe'); + + expect(second).not.toBe(first); + expect(new URL(String(first?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('1'); + expect(new URL(String(second?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('2'); + expect(new URL(String(second?.getAttribute('src'))).origin).toBe('http://127.0.0.1:4173'); + }); + it('没有活预览时不渲染刷新,而不是留一个点了没反应的入口', async () => { // 非 loopback 地址会被 `resolveEmbeddedPreviewUrl` 拒掉。此时切到运行页只有空态, // 该走的是顶栏「运行」页签——刷新入口不该在没画面可刷的时候出现。 From 430d983f65d702c171f454cd6663df7e478f68a6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 00:04:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=A3=B3=E9=A2=84=E8=A7=88=E8=BE=B9=E7=95=8C=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E4=BB=A5=E6=8E=A5=E5=8F=97=E5=88=B7=E6=96=B0=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 预览框 src 断言改为两种形态都接受:旧的 `src={embeddedUrl}`,或 `src={iframeUrl ?? undefined}` - 新形态额外要求 `iframeUrl` 由 `useMemo` 定义、且定义体内引用 `embeddedUrl`(只改名不算通过) - 其余断言(`resolveEmbeddedPreviewUrl(` / loopback 校验 / sandbox / 单 iframe 与单挂载)原样保留 - 背景:14283db94 为「刷新运行画面」引入 iframeUrl,撞红了 Native shell tests 的 Run native shell contract gates(missing src={embeddedUrl});只接受新形态会让 master 反过来红 --- scripts/check-native-shells.mjs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 84e7c5125..909290925 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2642,7 +2642,6 @@ function assertAiGameCreatorShellUserDevBoundary() { 'function resolveEmbeddedPreviewUrl(', "url.protocol !== 'http:' || url.hostname !== '127.0.0.1'", 'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"', - 'src={embeddedUrl}', ]) { if ( !sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet) @@ -2652,6 +2651,29 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + // 预览框的 `src` 允许经一个短变量中转(刷新时要在地址上叠加 `__agc_reload` 这类非业务参数), + // 但这个变量必须由被校验过的 `embeddedUrl` 派生:只换名字、丢掉派生关系仍按漂移处理。 + const iframeUrlDerivation = aiGameCreatorLocalGamePreviewFrameSource.match( + /const\s+iframeUrl\s*=\s*useMemo\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[/, + ); + if ( + !sourceIncludesSnippet( + aiGameCreatorLocalGamePreviewFrameSource, + 'src={embeddedUrl}', + ) && + !( + sourceIncludesSnippet( + aiGameCreatorLocalGamePreviewFrameSource, + 'src={iframeUrl ?? undefined}', + ) && + iframeUrlDerivation !== null && + iframeUrlDerivation[0].includes('embeddedUrl') + ) + ) { + throw new Error( + 'AI game creator embedded preview boundary drifted: iframe src must derive from the validated embeddedUrl', + ); + } for (const snippet of [ "await invoke(\n 'activate_local_game_preview'", '已载入客户端运行视图',