diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx
new file mode 100644
index 000000000..f37a3e1e2
--- /dev/null
+++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/__tests__/esi-upload.test.tsx
@@ -0,0 +1,96 @@
+import type { ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types'
+import { fireEvent, render, waitFor } from '@testing-library/react'
+
+// Mocked EsiPort surface — the two methods the upload flow touches. The
+// `mock`-prefixed name is referenced lazily inside the factory (only when
+// `useEsi()` is called at render time), so this works under both Vitest and
+// the editor's Jest+vi shim — without `vi.hoisted`, which the shim lacks.
+const mockEsi = {
+ parseAndSaveFile: vi.fn(),
+ loadRepositoryLight: vi.fn(),
+}
+
+vi.mock('@root/middleware/shared/providers/platform-context', () => ({
+ useEsi: () => mockEsi,
+}))
+
+import { ESIUpload } from '../esi-upload'
+
+const SAMPLE_ITEM: ESIRepositoryItemLight = {
+ id: 'item-1',
+ filename: 'Beckhoff.xml',
+ vendor: { id: '0x0002', name: 'Beckhoff' },
+ devices: [],
+ loadedAt: '2026-01-01T00:00:00.000Z',
+}
+
+/** Build an XML File whose `.text()` resolves regardless of jsdom version. */
+function xmlFile(name = 'Beckhoff.xml', content = ''): File {
+ const file = new File([content], name, { type: 'text/xml' })
+ Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) })
+ return file
+}
+
+/** Render the component and drive a single-file upload through the input. */
+function uploadFile(repository: ESIRepositoryItemLight[] = []) {
+ const onFilesLoaded = vi.fn()
+ const { container } = render()
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+ fireEvent.change(input, { target: { files: [xmlFile()] } })
+ return { onFilesLoaded }
+}
+
+describe('ESIUpload — dedupAfterRetry handling', () => {
+ beforeEach(() => {
+ mockEsi.parseAndSaveFile.mockReset()
+ mockEsi.loadRepositoryLight.mockReset()
+ })
+
+ it('appends a newly added item without re-listing the repository', async () => {
+ mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, item: SAMPLE_ITEM })
+
+ const { onFilesLoaded } = uploadFile()
+
+ await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled())
+ expect(onFilesLoaded).toHaveBeenCalledWith([SAMPLE_ITEM], undefined)
+ expect(mockEsi.loadRepositoryLight).not.toHaveBeenCalled()
+ })
+
+ it('re-lists the repository when a dedupAfterRetry lands without an item', async () => {
+ mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, dedupAfterRetry: true })
+ mockEsi.loadRepositoryLight.mockResolvedValueOnce({ success: true, items: [SAMPLE_ITEM] })
+
+ const { onFilesLoaded } = uploadFile()
+
+ await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled())
+ expect(mockEsi.loadRepositoryLight).toHaveBeenCalledTimes(1)
+ // The refreshed list is authoritative — the recovered row appears here.
+ expect(onFilesLoaded).toHaveBeenCalledWith([SAMPLE_ITEM], undefined)
+ })
+
+ it('falls back to the local list when the refresh itself fails', async () => {
+ const existing: ESIRepositoryItemLight[] = [{ ...SAMPLE_ITEM, id: 'old', filename: 'Old.xml' }]
+ mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, dedupAfterRetry: true })
+ mockEsi.loadRepositoryLight.mockResolvedValueOnce({ success: false, error: 'list failed' })
+
+ const onFilesLoaded = vi.fn()
+ const { container } = render()
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement
+ fireEvent.change(input, { target: { files: [xmlFile()] } })
+
+ await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled())
+ expect(mockEsi.loadRepositoryLight).toHaveBeenCalledTimes(1)
+ // No new item was returned, so the fallback keeps the existing repository.
+ expect(onFilesLoaded).toHaveBeenCalledWith(existing, undefined)
+ })
+
+ it('skips a real duplicate silently without re-listing', async () => {
+ mockEsi.parseAndSaveFile.mockResolvedValueOnce({ success: true, duplicate: true })
+
+ const { onFilesLoaded } = uploadFile()
+
+ await waitFor(() => expect(onFilesLoaded).toHaveBeenCalled())
+ expect(onFilesLoaded).toHaveBeenCalledWith([], undefined)
+ expect(mockEsi.loadRepositoryLight).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx
index 42cd63c03..f051eacc2 100644
--- a/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx
+++ b/src/frontend/components/_features/[workspace]/editor/device/ethercat/components/esi-upload.tsx
@@ -55,6 +55,12 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr
const newItems: ESIRepositoryItemLight[] = []
const errors: Array<{ filename: string; error: string }> = []
+ // A dedup-after-retry result means the file was persisted on the backend
+ // but its row was missing from the upload response (and the adapter's own
+ // recovery lookup also failed). Honor the EsiPort contract by re-listing
+ // the repository after the batch so the file appears instead of silently
+ // vanishing — see EsiPort.parseAndSaveFile (`dedupAfterRetry`).
+ let needsRepositoryRefresh = false
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
@@ -84,8 +90,13 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr
if (result.success && result.item) {
newItems.push(result.item)
+ } else if (result.success && result.dedupAfterRetry) {
+ // Uploaded but absent from the response: a transient-failure retry
+ // hit the backend dedup and the adapter couldn't recover the row.
+ // Flag a repository refresh so the file surfaces after the batch.
+ needsRepositoryRefresh = true
} else if (result.success) {
- // Duplicate content already in the repository — skip silently.
+ // Real duplicate — content already in the repository. Skip silently.
// See EsiPort.parseAndSaveFile for the duplicate-handling contract.
} else {
errors.push({ filename: file.name, error: result.error ?? 'Parse failed' })
@@ -102,6 +113,17 @@ const ESIUpload = ({ onFilesLoaded, repository, isLoading = false }: ESIUploadPr
percentage: 100,
})
+ // A recovered-but-unlisted upload (dedupAfterRetry) is on the backend yet
+ // missing from `newItems`. Re-list the repository so it shows up; fall
+ // back to the locally accumulated list if the refresh itself fails.
+ if (needsRepositoryRefresh) {
+ const refreshed = await esi!.loadRepositoryLight()
+ if (refreshed.success && refreshed.items) {
+ onFilesLoaded(refreshed.items, errors.length > 0 ? errors : undefined)
+ return
+ }
+ }
+
onFilesLoaded([...repository, ...newItems], errors.length > 0 ? errors : undefined)
},
[onFilesLoaded, repository, esi],
diff --git a/src/middleware/shared/ports/esi-port.ts b/src/middleware/shared/ports/esi-port.ts
index 240e73fc7..6e220d324 100644
--- a/src/middleware/shared/ports/esi-port.ts
+++ b/src/middleware/shared/ports/esi-port.ts
@@ -39,11 +39,17 @@ export interface EsiPort {
* Dedup is filename-based: reimporting the same bytes under a different name
* will add a new entry, and replacing a file's contents without renaming it
* is reported as a duplicate.
+ *
+ * `dedupAfterRetry: true` indicates the duplicate response landed after a
+ * transient-failure retry — the first attempt likely persisted the file and
+ * its response was lost. Callers should treat this as "uploaded but absent
+ * from the response" rather than silently skipping, since the user expects
+ * the file to appear in the repository.
*/
parseAndSaveFile(
filename: string,
content: string,
- ): Promise>
+ ): Promise>
/**
* Delete a single repository item and its associated XML file.