发布/编辑媒体在覆盖与卸载时释放本地预览 blob URL

- applyVersionMediaDefaults 用线上默认值覆盖前先 revoke 被替换的本地预览
- GamePublishPage / GameWorkMetadataEditor 用 ref 跟踪最新图片,卸载时兜底 revoke
- 补「线上默认值覆盖本地选择」的 revoke 断言测试
This commit is contained in:
2026-10-07 11:47:14 +08:00
parent 009a772ebd
commit 5ce5cb006a
4 changed files with 122 additions and 7 deletions
@@ -28,7 +28,10 @@ import {
parseGameTagsInput,
resolveGameInputModes,
} from './gameMetadataOptions';
import { type GamePublishImage } from './gamePublishAssets';
import {
type GamePublishImage,
revokeGamePublishImagePreview,
} from './gamePublishAssets';
import { GamePublishClientGuide } from './GamePublishClientGuide';
import {
clearPublishDraft,
@@ -99,6 +102,27 @@ export function GamePublishPage({
const [screenshotImages, setScreenshotImages] = useState<GamePublishImage[]>(
[],
);
/**
* 卸载时释放仍挂在界面上的本地预览 blob URL。回调里拿不到最新 state,用 ref 跟踪;显式
* 移除/替换的那一份已由 fieldset 处理器 revoke,这里兜住卸载与线上默认值整体覆盖。
*/
const coverImageRef = useRef<GamePublishImage | null>(null);
const screenshotImagesRef = useRef<GamePublishImage[]>([]);
useEffect(() => {
coverImageRef.current = coverImage;
}, [coverImage]);
useEffect(() => {
screenshotImagesRef.current = screenshotImages;
}, [screenshotImages]);
useEffect(
() => () => {
revokeGamePublishImagePreview(coverImageRef.current);
for (const image of screenshotImagesRef.current) {
revokeGamePublishImagePreview(image);
}
},
[],
);
// 发布灰度:`checking` 期间不渲染表单,避免白名单外的作者先上传再被后端拒绝。
const [publishGate, setPublishGate] = useState<
'checking' | 'allowed' | 'blocked'
@@ -23,7 +23,10 @@ import {
parseGameTagsInput,
resolveGameInputModes,
} from './gameMetadataOptions';
import { type GamePublishImage } from './gamePublishAssets';
import {
type GamePublishImage,
revokeGamePublishImagePreview,
} from './gamePublishAssets';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
import { resolvePublishMetadataError } from './gamePublishMetadata';
@@ -84,6 +87,27 @@ export function GameWorkMetadataEditor({
const [screenshotImages, setScreenshotImages] = useState<GamePublishImage[]>(
[],
);
/**
* 卸载时释放仍挂在界面上的本地预览 blob URL。回调里拿不到最新 state,用 ref 跟踪;显式
* 移除/替换的那一份已由 fieldset 处理器 revoke,这里兜住卸载与线上默认值整体覆盖。
*/
const coverImageRef = useRef<GamePublishImage | null>(null);
const screenshotImagesRef = useRef<GamePublishImage[]>([]);
useEffect(() => {
coverImageRef.current = coverImage;
}, [coverImage]);
useEffect(() => {
screenshotImagesRef.current = screenshotImages;
}, [screenshotImages]);
useEffect(
() => () => {
revokeGamePublishImagePreview(coverImageRef.current);
for (const image of screenshotImagesRef.current) {
revokeGamePublishImagePreview(image);
}
},
[],
);
const [isLoadingMedia, setIsLoadingMedia] = useState(
Boolean(game.latestVersion),
);
@@ -0,0 +1,61 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionVersionDetail } from '../../../packages/shared/src/contracts/gameDistribution';
import { type GamePublishImage } from './gamePublishAssets';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
function localImage(id: string, name: string): GamePublishImage {
return {
id,
file: new File(['image-bytes'], name, { type: 'image/png' }),
src: `blob:${id}`,
name,
};
}
describe('applyVersionMediaDefaults', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('用线上默认值覆盖本地选择前先释放旧 blob 预览', async () => {
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: () => 'blob:unused',
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: revokeObjectURL,
});
let cover: GamePublishImage | null = localImage('local-cover', '封面.png');
let screenshots: GamePublishImage[] = [
localImage('local-shot', '截图.png'),
];
// 线上没有可沿用的媒体:覆盖结果为空,但被替换掉的本地预览仍必须释放。
const detail = {
game: { id: 'game-1', title: '线上作品', screenshots: [] },
version: { frozenMetadata: null },
} as unknown as GameDistributionVersionDetail;
await applyVersionMediaDefaults(detail, {
setCover: (updater) => {
cover = updater(cover);
},
setScreenshots: (updater) => {
screenshots = updater(screenshots);
},
isCancelled: () => false,
});
expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-cover');
expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-shot');
expect(cover).toBeNull();
expect(screenshots).toEqual([]);
});
});
@@ -3,6 +3,7 @@ import {
createGamePublishImageId,
type GamePublishImage,
resolveGamePublishImagePreview,
revokeGamePublishImagePreview,
} from './gamePublishAssets';
type VersionMediaApplyTarget = {
@@ -46,15 +47,20 @@ export async function applyVersionMediaDefaults(
.map((objectKey) => objectKey?.trim() ?? '')
.filter(Boolean);
if (apply.isCancelled()) return;
apply.setCover(() => (coverObjectKey ? buildCover('') : null));
apply.setScreenshots(() =>
screenshotObjectKeys.map((objectKey, index) => ({
// 覆盖前先释放被替换掉的本地预览 blob URL:这一步之后旧对象不再有人引用,不 revoke 就泄漏。
apply.setCover((current) => {
revokeGamePublishImagePreview(current);
return coverObjectKey ? buildCover('') : null;
});
apply.setScreenshots((current) => {
for (const image of current) revokeGamePublishImagePreview(image);
return screenshotObjectKeys.map((objectKey, index) => ({
id: createGamePublishImageId(),
objectKey,
src: '',
name: `截图 ${index + 1}`,
})),
);
}));
});
const coverPreview = await resolveGamePublishImagePreview(
coverObjectKey,