Skip to content

Commit 81f6cb4

Browse files
authored
Ensure Issue Overlay sourcemaps externals in Turbopack (#73439)
Closes https://linear.app/vercel/issue/NDX-473/
1 parent b2219d6 commit 81f6cb4

File tree

7 files changed

+198
-45
lines changed

7 files changed

+198
-45
lines changed

packages/next/src/client/components/react-dev-overlay/server/middleware-turbopack.ts

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import path from 'path'
1414
import url from 'url'
1515
import { launchEditor } from '../internal/helpers/launchEditor'
1616
import type { StackFrame } from 'next/dist/compiled/stacktrace-parser'
17+
import { SourceMapConsumer } from 'next/dist/compiled/source-map08'
1718
import type { Project, TurbopackStackFrame } from '../../../../build/swc/types'
1819
import { getSourceMapFromFile } from '../internal/helpers/get-source-map-from-file'
19-
import { findSourceMap } from 'node:module'
20+
import { findSourceMap, type SourceMapPayload } from 'node:module'
2021

2122
function shouldIgnorePath(modulePath: string): boolean {
2223
return (
@@ -33,7 +34,10 @@ export async function batchedTraceSource(
3334
project: Project,
3435
frame: TurbopackStackFrame
3536
): Promise<{ frame: IgnorableStackFrame; source: string | null } | undefined> {
36-
const file = frame.file ? decodeURIComponent(frame.file) : undefined
37+
const file = frame.file
38+
? // TODO(veil): Why are the frames sent encoded?
39+
decodeURIComponent(frame.file)
40+
: undefined
3741
if (!file) return
3842

3943
const sourceFrame = await project.traceSource(frame)
@@ -108,11 +112,155 @@ function createStackFrame(searchParams: URLSearchParams) {
108112
} satisfies TurbopackStackFrame
109113
}
110114

115+
/**
116+
* https://tc39.es/source-map/#index-map
117+
*/
118+
interface IndexSourceMapSection {
119+
offset: {
120+
line: number
121+
column: number
122+
}
123+
map: ModernRawSourceMap
124+
}
125+
126+
// TODO(veil): Upstream types
127+
interface IndexSourceMap {
128+
version: number
129+
file: string
130+
sections: IndexSourceMapSection[]
131+
}
132+
133+
interface ModernRawSourceMap extends SourceMapPayload {
134+
ignoreList?: number[]
135+
}
136+
137+
type ModernSourceMapPayload = ModernRawSourceMap | IndexSourceMap
138+
139+
/**
140+
* Finds the sourcemap payload applicable to a given frame.
141+
* Equal to the input unless an Index Source Map is used.
142+
*/
143+
function findApplicableSourceMapPayload(
144+
frame: TurbopackStackFrame,
145+
payload: ModernSourceMapPayload
146+
): ModernRawSourceMap | undefined {
147+
if ('sections' in payload) {
148+
const frameLine = frame.line ?? 0
149+
const frameColumn = frame.column ?? 0
150+
// Sections must not overlap and must be sorted: https://tc39.es/source-map/#section-object
151+
// Therefore the last section that has an offset less than or equal to the frame is the applicable one.
152+
// TODO(veil): Binary search
153+
let section: IndexSourceMapSection | undefined = payload.sections[0]
154+
for (
155+
let i = 0;
156+
i < payload.sections.length &&
157+
payload.sections[i].offset.line <= frameLine &&
158+
payload.sections[i].offset.column <= frameColumn;
159+
i++
160+
) {
161+
section = payload.sections[i]
162+
}
163+
164+
return section === undefined ? undefined : section.map
165+
} else {
166+
return payload
167+
}
168+
}
169+
170+
async function nativeTraceSource(
171+
frame: TurbopackStackFrame
172+
): Promise<{ frame: IgnorableStackFrame; source: string | null } | undefined> {
173+
const sourceMap = findSourceMap(
174+
// TODO(veil): Why are the frames sent encoded?
175+
decodeURIComponent(frame.file)
176+
)
177+
if (sourceMap !== undefined) {
178+
const traced = await SourceMapConsumer.with(
179+
sourceMap.payload,
180+
null,
181+
async (consumer) => {
182+
const originalPosition = consumer.originalPositionFor({
183+
line: frame.line ?? 1,
184+
column: frame.column ?? 1,
185+
})
186+
187+
if (originalPosition.source === null) {
188+
return null
189+
}
190+
191+
const sourceContent: string | null =
192+
consumer.sourceContentFor(
193+
originalPosition.source,
194+
/* returnNullOnMissing */ true
195+
) ?? null
196+
197+
return { originalPosition, sourceContent }
198+
}
199+
)
200+
201+
if (traced !== null) {
202+
const { originalPosition, sourceContent } = traced
203+
const applicableSourceMap = findApplicableSourceMapPayload(
204+
frame,
205+
sourceMap.payload
206+
)
207+
208+
// TODO(veil): Upstream a method to sourcemap consumer that immediately says if a frame is ignored or not.
209+
let ignored = false
210+
if (applicableSourceMap === undefined) {
211+
console.error(
212+
'No applicable source map found in sections for frame',
213+
frame
214+
)
215+
} else {
216+
// TODO: O(n^2). Consider moving `ignoreList` into a Set
217+
const sourceIndex = applicableSourceMap.sources.indexOf(
218+
originalPosition.source!
219+
)
220+
ignored = applicableSourceMap.ignoreList?.includes(sourceIndex) ?? false
221+
}
222+
223+
const originalStackFrame: IgnorableStackFrame = {
224+
methodName:
225+
originalPosition.name ||
226+
// default is not a valid identifier in JS so webpack uses a custom variable when it's an unnamed default export
227+
// Resolve it back to `default` for the method name if the source position didn't have the method.
228+
frame.methodName
229+
?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default')
230+
?.replace('__webpack_exports__.', '') ||
231+
'<unknown>',
232+
column: (originalPosition.column ?? 0) + 1,
233+
file: originalPosition.source?.startsWith('file://')
234+
? path.relative(
235+
process.cwd(),
236+
url.fileURLToPath(originalPosition.source)
237+
)
238+
: originalPosition.source,
239+
lineNumber: originalPosition.line ?? 0,
240+
// TODO: c&p from async createOriginalStackFrame but why not frame.arguments?
241+
arguments: [],
242+
ignored,
243+
}
244+
245+
return {
246+
frame: originalStackFrame,
247+
source: sourceContent,
248+
}
249+
}
250+
}
251+
252+
return undefined
253+
}
254+
111255
export async function createOriginalStackFrame(
112256
project: Project,
113257
frame: TurbopackStackFrame
114258
): Promise<OriginalStackFrameResponse | null> {
115-
const traced = await batchedTraceSource(project, frame)
259+
const traced =
260+
(await nativeTraceSource(frame)) ??
261+
// TODO(veil): When would the bundler know more than native?
262+
// If it's faster, try the bundler first and fall back to native later.
263+
(await batchedTraceSource(project, frame))
116264
if (!traced) {
117265
return null
118266
}
@@ -193,6 +341,8 @@ export function getSourceMapMiddleware(project: Project) {
193341
return badRequest(res)
194342
}
195343

344+
// TODO(veil): Always try the native version first.
345+
// Externals could also be files that aren't bundled via Webpack.
196346
if (
197347
filename.startsWith('webpack://') ||
198348
filename.startsWith('webpack-internal:///')

test/development/app-dir/dynamic-error-trace/index.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,15 @@ describe('app dir - dynamic error trace', () => {
4141
)
4242

4343
const codeframe = await getRedboxSource(browser)
44-
// TODO(NDX-115): column for "^"" marker is inconsistent between native, Webpack, and Turbopack
4544
expect(codeframe).toEqual(
4645
process.env.TURBOPACK
4746
? outdent`
48-
app/lib.js (4:12) @ Foo
47+
app/lib.js (4:13) @ Foo
4948
5049
2 |
5150
3 | export function Foo() {
5251
> 4 | useHeaders()
53-
| ^
52+
| ^
5453
5 | return 'foo'
5554
6 | }
5655
7 |

test/development/app-dir/owner-stack-invalid-element-type/invalid-element-type.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ const isOwnerStackEnabled =
6666
if (process.env.TURBOPACK) {
6767
expect(stackFramesContent).toMatchInlineSnapshot(`""`)
6868
expect(source).toMatchInlineSnapshot(`
69-
"app/rsc/page.js (5:10) @ Inner
69+
"app/rsc/page.js (5:11) @ Inner
7070
7171
3 | // Intermediate component for testing owner stack
7272
4 | function Inner() {
7373
> 5 | return <Foo />
74-
| ^
74+
| ^
7575
6 | }
7676
7 |
7777
8 | export default function Page() {"

test/development/app-dir/owner-stack-invalid-element-type/owner-stack-invalid-element-type.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,15 @@ const isOwnerStackEnabled =
7070

7171
if (process.env.TURBOPACK) {
7272
expect(stackFramesContent).toMatchInlineSnapshot(
73-
`"at Page (app/rsc/page.js (11:7))"`
73+
`"at Page (app/rsc/page.js (11:8))"`
7474
)
7575
expect(source).toMatchInlineSnapshot(`
76-
"app/rsc/page.js (5:10) @ Inner
76+
"app/rsc/page.js (5:11) @ Inner
7777
7878
3 | // Intermediate component for testing owner stack
7979
4 | function Inner() {
8080
> 5 | return <Foo />
81-
| ^
81+
| ^
8282
6 | }
8383
7 |
8484
8 | export default function Page() {"

test/development/app-dir/owner-stack-react-missing-key-prop/owner-stack-react-missing-key-prop.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ const isOwnerStackEnabled =
2929
`"at Page (app/rsc/page.tsx (6:13))"`
3030
)
3131
expect(source).toMatchInlineSnapshot(`
32-
"app/rsc/page.tsx (7:9) @ <anonymous>
32+
"app/rsc/page.tsx (7:10) @ <anonymous>
3333
3434
5 | <div>
3535
6 | {list.map((item, index) => (
3636
> 7 | <span>{item}</span>
37-
| ^
37+
| ^
3838
8 | ))}
3939
9 | </div>
4040
10 | )"

test/integration/edge-runtime-streaming-error/test/index.test.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,22 @@ function createContext() {
5050
return ctx
5151
}
5252

53-
;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
54-
'development mode',
55-
() => {
56-
const context = createContext()
53+
// TODO(veil): Missing `cause` in Turbopack
54+
;(process.env.TURBOPACK ? describe.skip : describe)('development mode', () => {
55+
const context = createContext()
5756

58-
beforeAll(async () => {
59-
context.appPort = await findPort()
60-
context.app = await launchApp(appDir, context.appPort, {
61-
...context.handler,
62-
env: { __NEXT_TEST_WITH_DEVTOOL: '1' },
63-
})
57+
beforeAll(async () => {
58+
context.appPort = await findPort()
59+
context.app = await launchApp(appDir, context.appPort, {
60+
...context.handler,
61+
env: { __NEXT_TEST_WITH_DEVTOOL: '1' },
6462
})
63+
})
6564

66-
afterAll(() => killApp(context.app))
65+
afterAll(() => killApp(context.app))
6766

68-
it('logs the error correctly', test(context))
69-
}
70-
)
67+
it('logs the error correctly', test(context))
68+
})
7169
;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
7270
'production mode',
7371
() => {

0 commit comments

Comments
 (0)