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