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
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:
+19
-3
@@ -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` 拒掉。此时切到运行页只有空态,
|
||||
// 该走的是顶栏「运行」页签——刷新入口不该在没画面可刷的时候出现。
|
||||
|
||||
@@ -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'",
|
||||
'已载入客户端运行视图',
|
||||
|
||||
Reference in New Issue
Block a user