Skip to content

fix(nextjs): preserve directive prologues in turbopack loaders#20103

Open
igz0 wants to merge 4 commits intogetsentry:developfrom
igz0:fix/turbopack-multi-directives
Open

fix(nextjs): preserve directive prologues in turbopack loaders#20103
igz0 wants to merge 4 commits intogetsentry:developfrom
igz0:fix/turbopack-multi-directives

Conversation

@igz0
Copy link
Copy Markdown

@igz0 igz0 commented Apr 4, 2026

Fixes #20101.

Summary

When _experimental.turbopackApplicationKey is enabled, the Next.js Turbopack loaders inject code for metadata/value propagation.

The previous implementation relied on a regex that only handled a single directive prologue entry. For modules that start with multiple directives, such as:

"use strict";
"use client";

the injected code could end up between the directives and break the "use client" classification.

This replaces the regex-based insertion point detection with a small linear scanner that:

  • skips leading whitespace and comments
  • walks consecutive directive prologue entries
  • returns the insertion point immediately after the last directive

Tests

  • added coverage for multiple directives
  • added coverage for comments between directives
  • added coverage for semicolon-free directives
  • added coverage for non-directive string literals that must not be skipped

Verification

Using the reproduction from #20101 with the patched @sentry/nextjs tarball:

  • npm run build completes successfully
  • /_not-found prerendering succeeds
  • the previous TypeError: (0, g.useEffect) is not a function no longer occurs

@sdk-maintainer-bot sdk-maintainer-bot bot added missing-maintainer-discussion Used for automated community contribution checks. violating-contribution-guidelines Used for automated community contribution checks. labels Apr 4, 2026
@sdk-maintainer-bot
Copy link
Copy Markdown

This PR has been automatically closed. The referenced issue does not show a discussion between you and a maintainer.

To avoid wasted effort on both sides, please discuss your proposed approach in the issue first and wait for a maintainer to respond before opening a PR.

Please review our contributing guidelines for more details.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

Semver Impact of This PR

🟢 Patch (bug fixes)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

Core

  • Support registerTool/registerResource/registerPrompt in MCP integration by betegon in #20071
  • Support embeddings in langchain by nicohrubec in #20017

Deps

  • Bump lodash.template from 4.5.0 to 4.18.1 by dependabot in #20085
  • Bump @xmldom/xmldom from 0.8.3 to 0.8.12 by dependabot in #20066

Other

  • (cloudflare) Support basic WorkerEntrypoint by JPeer264 in #19884
  • (core, node) Portable Express integration by isaacs in #19928
  • (deno) Add denoRuntimeMetricsIntegration by chargome in #20023
  • (node, bun) Enforce minimum collection interval in runtime metrics integrations by chargome in #20068

Bug Fixes 🐛

Core

  • Replace regex with string check in stack parser to prevent main thread blocking by chargome in #20089
  • Set span.status to error when MCP tool returns JSON-RPC error response by betegon in #20082

Other

  • (aws-serverless) Add timeout to _endSpan forceFlush to prevent Lambda hanging by logaretm in #20064
  • (cloudflare) Ensure every request instruments functions by JPeer264 in #20044
  • (gatsby) Fix errorHandler signature to match bundler-plugin-core API by JPeer264 in #20048
  • (nextjs) Preserve directive prologues in turbopack loaders by igz0 in #20103

Internal Changes 🔧

Core

  • Do not emit spans for chats.create in google-genai by nicohrubec in #19990
  • Unify .do* span ops to gen_ai.generate_content by nicohrubec in #20074
  • Simplify addResponseAttributes in openai integration by nicohrubec in #20013
  • Extract shared endStreamSpan for AI integrations by nicohrubec in #20021
  • Remove provider-specific AI span attributes in favor of gen_ai attributes in sentry conventions by nicohrubec in #20011

Deps

  • Bump mshick/add-pr-comment from dd126dd8c253650d181ad9538d8b4fa218fc31e8 to e7516d74559b5514092f5b096ed29a629a1237c6 by dependabot in #20078
  • Bump getsentry/craft/.github/workflows/changelog-preview.yml from 2.24.1 to 2.25.2 by dependabot in #20081

Other

  • (node) Add node integration tests for Vercel ToolLoopAgent by nicohrubec in #20087
  • (nuxt) Make Nuxt 5 (nightly) E2E optional by s1gr1d in #20113
  • Update validate-pr workflow by stephanie-anderson in #20072
  • Remove unused tsconfig-template folder by mydea in #20067

🤖 This preview updates automatically when you update the PR.

@andreiborza andreiborza reopened this Apr 7, 2026
@andreiborza andreiborza removed violating-contribution-guidelines Used for automated community contribution checks. missing-maintainer-discussion Used for automated community contribution checks. labels Apr 7, 2026
Copy link
Copy Markdown
Member

@JPeer264 JPeer264 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes look good to me overall (I'm a bit far away from Next.js though to verify if the changes are actually valid). I just wonder if all these while loops are the most performant way of doing this.

let index = 0;
let lastDirectiveEndIndex: number | undefined;

while (true) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: This while loop could potentially run forever. Would it make sense to have some return at a specific index number? E.g.

if (index >= 10) return lastDirectiveEndIndex;

Copy link
Copy Markdown
Author

@igz0 igz0 Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I updated the loop to use an explicit index < userCode.length exit condition instead of while (true), and added a small EOF test to lock that behavior in.
dd6204f

I didn't add a fixed index cap, since that could incorrectly stop on valid inputs with longer comments or multiple directives. The scanner still does a single forward pass over the input, so the runtime remains linear in the module length.

@chargome chargome self-assigned this Apr 7, 2026
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dd6204f. Configure here.

Copy link
Copy Markdown
Member

@s1gr1d s1gr1d left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for contributing!

The code is a bit hard to read and reason about. Maybe you can clean it up a bit and add comments when needed.

continue;
}

if (char === '/' && nextChar === '/') {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce bundle size and improve readability of the code, you could use userCode.startsWith('//', index) (also the same with /*).

Then you also don't need the nextChar variable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, updated this to use userCode.startsWith('//', index) / userCode.startsWith('/*', index) and dropped the extra nextChar handling.

Applied in c1d0518.

Comment on lines +46 to +50
index += 2;
while (index < userCode.length && userCode[index] !== '\n' && userCode[index] !== '\r') {
index += 1;
}
continue;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be written in a shorter, more readable way. There is no need for a while loop as we can get the new index from indexOf. And we also don't need to check for /r as Windows-style line endings (\r\n) are already handled with \n.

Suggested change
index += 2;
while (index < userCode.length && userCode[index] !== '\n' && userCode[index] !== '\r') {
index += 1;
}
continue;
const newline = code.indexOf('\n', i + 2);
i = newline === -1 ? code.length : newline + 1;
continue;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even shorter (but maybe less readable):

    if (code.startsWith('//', i)) {
      i = code.indexOf('\n', i);
      if (i === -1) break;
      continue;
    }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I simplified the line-comment handling here to use indexOf('\n', index + 2), which makes this path shorter and easier to read.

Applied in c1d0518.

Comment on lines +14 to +21
const statementStartIndex = skipWhitespaceAndComments(userCode, index);

const nextDirectiveIndex = skipDirective(userCode, statementStartIndex);
if (nextDirectiveIndex === undefined) {
return lastDirectiveEndIndex ?? statementStartIndex;
}

const statementEndIndex = skipDirectiveTerminator(userCode, nextDirectiveIndex);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need three functions here (as all of them are doing similar things). The only thing we want to find is the correct place for injection.

What do you think of something like this? This would just return the number after the last directive. But maybe I am missing something here.

export function findInjectionIndex(code: string): number {
  let i = 0;
  let afterLastDirective = 0;

  while (i < code.length) {
    const char = code[i];

    // Skip whitespace
    if (char && /\s/.test(char)) {
      i++;
      continue;
    }

    // Skip line comments
    if (code.startsWith('//', i)) {
      const newline = code.indexOf('\n', i + 2);
      i = newline === -1 ? code.length : newline + 1;
      continue;
    }

    // Skip block comments
    if (code.startsWith('/*', i)) {
      const end = code.indexOf('*/', i + 2);
      i = end === -1 ? code.length : end + 2;
      continue;
    }

    // Match a directive (a quoted string at statement position)
    if (char === '"' || char === "'") {
      const close = code.indexOf(char, i + 1);
      if (close !== -1) {
        let end = close + 1;
        if (code[end] === ';') end++;
        afterLastDirective = end;
        i = end;
        continue;
      }
    }

    // Hit something anything else that's not a comment, ws, or directive: stop scanning
    break;
  }

  return afterLastDirective;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this direction makes sense.

I refactored the directive scan so the logic now lives in a single scanner flow instead of being split across three helpers. I kept only a small string-literal helper so we still handle escaped/unterminated quote cases correctly.

Applied in c1d0518.

let lastDirectiveEndIndex: number | undefined;

while (index < userCode.length) {
const scanStartIndex = index;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable is purely to use in fallback returns inside the inner loop (return lastDirectiveEndIndex ?? scanStartIndex). But at that point, scanStartIndex is always equivalent to lastDirectiveEndIndex ?? 0:

  • On the first outer iteration: scanStartIndex is 0 and lastDirectiveEndIndex is undefined, so ?? scanStartIndex = ?? 0
  • On subsequent iterations: lastDirectiveEndIndex is already set, so scanStartIndex is never used

I think you can delete scanStartIndex entirely and write return lastDirectiveEndIndex ?? 0.

Comment on lines +57 to +58
// Only a bare string literal followed by a statement terminator counts as a directive.
while (statementEndIndex < userCode.length) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I made a comment in the last review questioning all the helper functions, but that was more about "do we actually need to check the end of the statement or can we do this in a shorter way?" - but the overall goal is better readability.

I understand now, that you specifically want to check if the directive is properly terminated. This loop is deeply nested and could be extracted in its own function (maybe scanDirectiveTerminator(code, i) or something like this)?

const scanStartIndex = index;

// Comments can appear between directive prologue entries, so keep scanning until we reach the next statement.
while (index < userCode.length) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outer loop iterates over directives, the inner loop skips whitespace/comments before each one. But the inner loop's work is identical every iteration and it could just be the same flat loop.

A single flat loop handles this just as well and is much easier to follow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_experimental.turbopackApplicationKey breaks React useEffect during SSR prerendering (Next.js 16 + Turbopack)

5 participants