diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx
index e2394e2..625dd93 100644
--- a/src/__tests__/app.test.tsx
+++ b/src/__tests__/app.test.tsx
@@ -5,6 +5,7 @@ import { SourceMapConsumer, SourceMapGenerator } from 'source-map'
import { describe, expect, test, vi } from 'vitest'
import App from '../app.tsx'
+import { SourceMap } from '../source-map.ts'
import { regular } from './fixtures'
import { mockPrefersColorScheme } from './setup.ts'
@@ -333,16 +334,16 @@ describe('source maps', () => {
})
})
-describe('source map lifecycle', () => {
- // destroy() lives on the prototype of the concrete consumer class, which the
- // library does not export, so grab it from a throwaway instance.
- async function spyOnConsumerDestroy() {
- const consumer = await new SourceMapConsumer(regular.sourcemaps[0].content)
- const prototype = Object.getPrototypeOf(consumer) as { destroy: () => void }
- consumer.destroy()
- return vi.spyOn(prototype, 'destroy')
- }
+// destroy() lives on the prototype of the concrete consumer class, which the
+// library does not export, so grab it from a throwaway instance.
+async function spyOnConsumerDestroy() {
+ const consumer = await new SourceMapConsumer(regular.sourcemaps[0].content)
+ const prototype = Object.getPrototypeOf(consumer) as { destroy: () => void }
+ consumer.destroy()
+ return vi.spyOn(prototype, 'destroy')
+}
+describe('source map lifecycle', () => {
test('destroys the consumer of a rejected duplicate source map', async () => {
const destroySpy = await spyOnConsumerDestroy()
@@ -411,6 +412,86 @@ describe('source map lifecycle', () => {
})
})
+describe('source map input race', () => {
+ // Delays the first SourceMap.create call so that a subsequent input event
+ // can finish parsing before the first one does.
+ function delayFirstSourceMapCreate(delayMs: number) {
+ const originalCreate = SourceMap.create.bind(SourceMap)
+ const createSpy = vi.spyOn(SourceMap, 'create')
+
+ createSpy.mockImplementation(originalCreate)
+ createSpy.mockImplementationOnce(async (text, fileName) => {
+ await new Promise(resolve => setTimeout(resolve, delayMs))
+ return originalCreate(text, fileName)
+ })
+
+ return createSpy
+ }
+
+ test('discards a stale successful parse instead of wiping newer input', async () => {
+ const createSpy = delayFirstSourceMapCreate(100)
+ const destroySpy = await spyOnConsumerDestroy()
+
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ // Slow parse of a valid source map...
+ sourcemapTextarea.focus()
+ await user.paste(regular.sourcemaps[0].content)
+
+ // ...superseded by newer input before it finishes.
+ await user.clear(sourcemapTextarea)
+ await user.paste('lorem ipsum')
+
+ expect(sourcemapTextarea).toHaveValue('lorem ipsum')
+
+ // Let the delayed parse resolve.
+ await new Promise(resolve => setTimeout(resolve, 150))
+
+ // The stale result must not clear the textarea or add the source map,
+ // and its consumer must be destroyed.
+ expect(sourcemapTextarea).toHaveValue('lorem ipsum')
+ expect(screen.getByText(/provided text is not a source map/i)).toBeInTheDocument()
+ expect(screen.queryByRole('list', { name: /sourcemaps list/i })).not.toBeInTheDocument()
+ expect(destroySpy).toHaveBeenCalledOnce()
+
+ destroySpy.mockRestore()
+ createSpy.mockRestore()
+ })
+
+ test('discards a stale failed parse instead of overriding a newer success', async () => {
+ const createSpy = delayFirstSourceMapCreate(100)
+
+ render()
+ const user = userEvent.setup()
+
+ const sourcemapTextarea = screen.getByRole('textbox', { name: /source map/i })
+
+ // Slow parse of text that is not a source map...
+ sourcemapTextarea.focus()
+ await user.paste('lorem ipsum')
+
+ // ...superseded by a valid source map.
+ await user.clear(sourcemapTextarea)
+ await user.paste(regular.sourcemaps[0].content)
+
+ const sourcemapList = await screen.findByRole('list', { name: /sourcemaps list/i })
+ expect(sourcemapTextarea).toHaveValue('')
+
+ // Let the delayed parse resolve.
+ await new Promise(resolve => setTimeout(resolve, 150))
+
+ // The stale failure must not show the error for the accepted source map.
+ expect(sourcemapList).toBeInTheDocument()
+ expect(sourcemapTextarea).toHaveValue('')
+ expect(screen.queryByText(/provided text is not a source map/i)).not.toBeInTheDocument()
+
+ createSpy.mockRestore()
+ })
+})
+
describe('column numbers', () => {
// Generated line 1 has mappings at 0-based columns 0, 10, and 11 which lead
// to different original lines, so an off-by-one in the column conversion
diff --git a/src/app.tsx b/src/app.tsx
index 1d7d1e9..09ed84a 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,5 +1,5 @@
import cx from 'clsx'
-import { type ChangeEvent, useState } from 'react'
+import { type ChangeEvent, useRef, useState } from 'react'
import { GitHubLogo } from './git-hub-logo.tsx'
import { transform } from './lib.ts'
@@ -20,6 +20,8 @@ export default function App() {
const [isSourceMapInputError, setIsSourceMapInputError] = useState(false)
const [isSourceMapFileInputError, setIsSourceMapFileInputError] = useState(false)
+ const latestSourceMapRequestRef = useRef(0)
+
const theme = useTheme()
const stackTrace = StackTrace.create(stackTraceInputValue)
@@ -46,6 +48,8 @@ export default function App() {
}
async function handleSourceMapTextAreaChange(event: ChangeEvent) {
+ const requestId = ++latestSourceMapRequestRef.current
+
let text = event.target.value
if (base64PrefixRegex.test(text)) {
@@ -64,6 +68,12 @@ export default function App() {
const sourceMap = await SourceMap.create(text)
+ // A newer input event superseded this one while the source map was parsing.
+ if (requestId !== latestSourceMapRequestRef.current) {
+ sourceMap?.consumer.destroy()
+ return
+ }
+
if (!sourceMap) {
setIsSourceMapInputError(Boolean(text) && true)
return