From 899827936f509efec48fdb36b918b85ea455c68b Mon Sep 17 00:00:00 2001 From: Durvesh Pilankar Date: Mon, 29 Jun 2026 18:54:19 -0700 Subject: [PATCH] Fix MockPlugin clobbering the active mock when a non-active duplicate is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a manual mock with duplicates was removed, #onFileRemoved unconditionally reassigned the active mock to an arbitrary remaining duplicate (the first in the Set). If the removed file was NOT the active mock and 3+ duplicates existed, this silently changed which __mocks__ file resolves for that name — even though the active mock still existed. Only reassign when the removed file was the active mock; otherwise leave it untouched. Adds a regression test (3 duplicates, remove a non-active one; fails before / passes after). --- .../metro-file-map/src/plugins/MockPlugin.js | 11 ++++++++--- .../mocks/__tests__/MockPlugin-test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/metro-file-map/src/plugins/MockPlugin.js b/packages/metro-file-map/src/plugins/MockPlugin.js index 805c0c5bb1..98e119f744 100644 --- a/packages/metro-file-map/src/plugins/MockPlugin.js +++ b/packages/metro-file-map/src/plugins/MockPlugin.js @@ -147,13 +147,18 @@ export default class MockPlugin const duplicates = this.#raw.duplicates.get(mockName); if (duplicates != null) { const posixRelativePath = normalizePathSeparatorsToPosix(canonicalPath); + const wasActiveMock = this.#raw.mocks.get(mockName) === posixRelativePath; duplicates.delete(posixRelativePath); if (duplicates.size === 1) { this.#raw.duplicates.delete(mockName); } - // Set the mock to a remaining duplicate. Should never be empty. - const remaining = nullthrows(duplicates.values().next().value); - this.#raw.mocks.set(mockName, remaining); + // Only reassign the active mock if the file we removed *was* the active + // one; otherwise a non-active duplicate's removal would clobber it. + if (wasActiveMock) { + // Set the mock to a remaining duplicate. Should never be empty. + const remaining = nullthrows(duplicates.values().next().value); + this.#raw.mocks.set(mockName, remaining); + } } else { this.#raw.mocks.delete(mockName); } diff --git a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js index 43f2835646..36e95abf24 100644 --- a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js +++ b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js @@ -107,6 +107,25 @@ Duplicate manual mock found for \`foo\`: }); }); + test('removing a non-active duplicate keeps the active mock', () => { + onFileAdded(p('a/__mocks__/foo.js')); + onFileAdded(p('b/__mocks__/foo.js')); + onFileAdded(p('c/__mocks__/foo.js')); // latest wins -> active mock is c + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + + // Remove a NON-active duplicate (b); the active mock (c) must be kept. + mockMap.onChanged({ + addedFiles: new Map(), + modifiedFiles: new Map(), + removedFiles: new Map([[p('b/__mocks__/foo.js'), null]]), + addedDirectories: new Set(), + removedDirectories: new Set(), + }); + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + }); + test('loads from a snapshot', async () => { await mockMap.initialize({ files: {