Merge remote-tracking branch 'origin/master' into fix/agc-composer-layout
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m2s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m19s
Project CI / Frontend tests (pull_request) Successful in 3m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Successful in 6m11s
Project CI / Backend tests (pull_request) Successful in 8m0s
Project CI / Native shell tests (pull_request) Successful in 10m14s

This commit is contained in:
2026-10-06 11:35:11 +08:00
4 changed files with 106 additions and 6 deletions
@@ -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<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(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`,
@@ -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)。
@@ -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` 拒掉。此时切到运行页只有空态,
// 该走的是顶栏「运行」页签——刷新入口不该在没画面可刷的时候出现。
+23 -1
View File
@@ -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<LocalPreviewStatus>(\n 'activate_local_game_preview'",
'已载入客户端运行视图',