From e18e3acadbc8760deef2fe595e0191ebe335f243 Mon Sep 17 00:00:00 2001
From: CR29-22-2805 <266851988+CR29-22-2805@users.noreply.github.com>
Date: Sun, 19 Jul 2026 21:46:35 -0500
Subject: [PATCH] Preserve duplicate WebView asset paths
---
packages/cli/src/util/AssetUploader.test.ts | 80 +++++++++++++++++++++
packages/cli/src/util/AssetUploader.ts | 24 ++++++-
2 files changed, 102 insertions(+), 2 deletions(-)
diff --git a/packages/cli/src/util/AssetUploader.test.ts b/packages/cli/src/util/AssetUploader.test.ts
index 7c49eab0..754c8383 100644
--- a/packages/cli/src/util/AssetUploader.test.ts
+++ b/packages/cli/src/util/AssetUploader.test.ts
@@ -9,6 +9,26 @@ import {
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
+const appClient = vi.hoisted(() => ({
+ CheckIfMediaExists: vi.fn(),
+ UploadNewMedia: vi.fn(),
+}));
+
+vi.mock('./clientGenerators.js', () => ({
+ createAppClient: () => appClient,
+}));
+
+vi.mock('@oclif/core', () => ({
+ ux: {
+ action: {
+ start: vi.fn(),
+ stop: vi.fn(),
+ },
+ error: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
import { AssetUploader, queryAssets, transformHTML } from './AssetUploader.js';
import type { DevvitCommand } from './commands/DevvitCommand.js';
@@ -165,6 +185,66 @@ describe('assertAssetCanBeAnIcon', () => {
}
});
+describe('syncAssets()', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devvit-assets-test-'));
+ fs.writeFileSync(path.join(tmpDir, 'header-logo.svg'), '');
+ fs.writeFileSync(path.join(tmpDir, 'footer-logo.svg'), '');
+
+ appClient.CheckIfMediaExists.mockImplementation(
+ async ({ signatures }: { signatures: { filePath: string }[] }) => ({
+ statuses: signatures.map((signature, index) => ({
+ ...signature,
+ isNew: true,
+ uploadUrl: `https://uploads.example.com/assets/${index}?signature=test`,
+ uploadHeaders: {},
+ })),
+ })
+ );
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ })
+ );
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('maps duplicate WebView asset paths to the same uploaded URL', async () => {
+ const cmd = {
+ project: {
+ root: tmpDir,
+ mediaDir: undefined,
+ clientDir: '.',
+ appConfig: undefined,
+ },
+ error: vi.fn((message: string) => {
+ throw new Error(message);
+ }),
+ log: vi.fn(),
+ warn: vi.fn(),
+ };
+ const assetUploader = new AssetUploader(cmd as unknown as DevvitCommand, 'some-slug', {
+ verbose: false,
+ });
+
+ const result = await assetUploader.syncAssets();
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(result.webViewAssetMap).toEqual({
+ 'footer-logo.svg': 'https://uploads.example.com/assets/0',
+ 'header-logo.svg': 'https://uploads.example.com/assets/0',
+ });
+ });
+});
+
describe('queryAssets()', () => {
let tmpDir: string;
diff --git a/packages/cli/src/util/AssetUploader.ts b/packages/cli/src/util/AssetUploader.ts
index 58e77b04..ab85f85c 100644
--- a/packages/cli/src/util/AssetUploader.ts
+++ b/packages/cli/src/util/AssetUploader.ts
@@ -54,6 +54,10 @@ type MediaSignatureWithContentsAndUploadInfo = MediaSignatureWithContents & {
uploadHeaders: { [key: string]: string };
};
+function getAssetSignature(asset: Pick): string {
+ return `${asset.hash}:${asset.size}`;
+}
+
type SyncAssetsResult = {
assetMap: AssetMap | undefined;
webViewAssetMap: AssetMap | undefined;
@@ -457,6 +461,7 @@ export class AssetUploader {
ux.action.start(
`Uploading new WebView assets, ${assetsRemaining} remaining (${prettyPrintSize(sizeRemaining)})`
);
+ const uploadedUrlsBySignature = new Map();
await mapAsyncWithMaxConcurrency(
newAssets,
@@ -487,12 +492,24 @@ export class AssetUploader {
}
const uploadUrl = new URL(newAsset.uploadUrl);
- assetMap[newAsset.filePath] = uploadUrl.origin + uploadUrl.pathname;
+ const publicUploadUrl = uploadUrl.origin + uploadUrl.pathname;
+ assetMap[newAsset.filePath] = publicUploadUrl;
+ uploadedUrlsBySignature.set(getAssetSignature(newAsset), publicUploadUrl);
updateUploadMsg(newAsset.size);
},
PARALLEL_UPLOADS
);
+ for (const duplicateAsset of duplicateAssets) {
+ const uploadedUrl = uploadedUrlsBySignature.get(getAssetSignature(duplicateAsset));
+ if (!uploadedUrl) {
+ throw new Error(
+ `Could not find the uploaded WebView asset matching duplicate ${duplicateAsset.filePath}.`
+ );
+ }
+ assetMap[duplicateAsset.filePath] = uploadedUrl;
+ }
+
ux.action.stop(`${newAssets.length} new WebView assets uploaded.`);
ux.action.start(`Finishing upload`);
@@ -536,6 +553,7 @@ export class AssetUploader {
const newAssets: (MediaSignatureWithContents | MediaSignatureWithContentsAndUploadInfo)[] = [];
const duplicateAssets: MediaSignatureWithContents[] = [];
+ const newAssetSignatures = new Set();
const existingAssets: AssetMap = {};
statuses.forEach((status) => {
const asset = assetsByFilePath[status.filePath];
@@ -547,9 +565,11 @@ export class AssetUploader {
if (status.isNew) {
// The user may have the same asset in multiple places, but we need to
// only upload it once
- if (newAssets.find((a) => a.hash === asset.hash && a.size === asset.size)) {
+ const signature = getAssetSignature(asset);
+ if (newAssetSignatures.has(signature)) {
duplicateAssets.push(asset);
} else {
+ newAssetSignatures.add(signature);
if (!areWebviewAssets) {
newAssets.push(asset);
return;