diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index e355a8557..af46eced9 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -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( [], ); + /** + * 卸载时释放仍挂在界面上的本地预览 blob URL。回调里拿不到最新 state,用 ref 跟踪;显式 + * 移除/替换的那一份已由 fieldset 处理器 revoke,这里兜住卸载与线上默认值整体覆盖。 + */ + const coverImageRef = useRef(null); + const screenshotImagesRef = useRef([]); + 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' diff --git a/src/components/game-distribution/GameWorkMetadataEditor.tsx b/src/components/game-distribution/GameWorkMetadataEditor.tsx index ab3de7fa9..d0b426266 100644 --- a/src/components/game-distribution/GameWorkMetadataEditor.tsx +++ b/src/components/game-distribution/GameWorkMetadataEditor.tsx @@ -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( [], ); + /** + * 卸载时释放仍挂在界面上的本地预览 blob URL。回调里拿不到最新 state,用 ref 跟踪;显式 + * 移除/替换的那一份已由 fieldset 处理器 revoke,这里兜住卸载与线上默认值整体覆盖。 + */ + const coverImageRef = useRef(null); + const screenshotImagesRef = useRef([]); + 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), ); diff --git a/src/components/game-distribution/gamePublishMediaDefaults.test.ts b/src/components/game-distribution/gamePublishMediaDefaults.test.ts new file mode 100644 index 000000000..b3c1ed84e --- /dev/null +++ b/src/components/game-distribution/gamePublishMediaDefaults.test.ts @@ -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([]); + }); +}); diff --git a/src/components/game-distribution/gamePublishMediaDefaults.ts b/src/components/game-distribution/gamePublishMediaDefaults.ts index 29219ab5b..1576e0c4e 100644 --- a/src/components/game-distribution/gamePublishMediaDefaults.ts +++ b/src/components/game-distribution/gamePublishMediaDefaults.ts @@ -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,