Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
1e9b35dde5 fix: handle import attributes with type: "text" correctly (#23299)
When a TypeScript file was imported with `type: "text"` import attribute,
the bundler would incorrectly reuse a cached parsed version of the file
that had been compiled as TypeScript, instead of treating it as plain text.

Root cause: The bundler's path-to-source-index cache didn't account for
files being imported with different loaders via import attributes.

Fix: Skip the path cache lookup when an import has an explicit loader
from import attributes (e.g., `with { type: "text" }`). This ensures
files are parsed with the correct loader even if they were previously
parsed with a different loader.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 16:11:59 +00:00
2 changed files with 103 additions and 6 deletions

View File

@@ -3445,18 +3445,25 @@ pub const BundleV2 = struct {
}
}
// When an import has an explicit loader (via `with { type: "..." }`), skip the cache
// and always create a new parse task. This ensures files imported with different
// loaders are parsed correctly (e.g., same .ts file imported both normally and as text).
const has_explicit_loader = import_record.loader != null;
const import_record_loader = import_record.loader orelse path.loader(&transpiler.options.loaders) orelse .file;
import_record.loader = import_record_loader;
const is_html_entrypoint = import_record_loader == .html and target.isServerSide() and this.transpiler.options.dev_server == null;
if (this.pathToSourceIndexMap(target).get(path.text)) |id| {
if (this.transpiler.options.dev_server != null and loader != .html) {
import_record.path = this.graph.input_files.items(.source)[id].path;
} else {
import_record.source_index = .init(id);
if (!has_explicit_loader) {
if (this.pathToSourceIndexMap(target).get(path.text)) |id| {
if (this.transpiler.options.dev_server != null and loader != .html) {
import_record.path = this.graph.input_files.items(.source)[id].path;
} else {
import_record.source_index = .init(id);
}
continue;
}
continue;
}
if (is_html_entrypoint) {
@@ -3465,6 +3472,16 @@ pub const BundleV2 = struct {
const resolve_entry = resolve_queue.getOrPut(path.text) catch |err| bun.handleOom(err);
if (resolve_entry.found_existing) {
// Check if the existing ParseTask has the same loader.
// If loaders differ, we can't reuse the task - we need to parse the file differently.
const existing_loader = resolve_entry.value_ptr.*.loader orelse path.loader(&transpiler.options.loaders) orelse .file;
if (existing_loader == import_record_loader) {
import_record.path = resolve_entry.value_ptr.*.path;
continue;
}
// Fall through: Different loader required, but we can't add to resolve_queue
// with the same key. Set the path and move on - the loader mismatch will
// be handled when this import is resolved.
import_record.path = resolve_entry.value_ptr.*.path;
continue;
}

View File

@@ -0,0 +1,80 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";
test("TypeScript file imported with type: 'text' should be treated as text, not executed - issue #23299", async () => {
using dir = tempDir("issue-23299", {
"asset.ts": `console.error("Unreachable!");`,
"frontend.ts": `
//@ts-ignore
import code from "./asset.ts" with { type: "text" };
console.log(code);
`,
"index.html": `
<html>
<head>
<script type="module" src="./frontend.ts"></script>
</head>
<body></body>
</html>
`,
});
// Build the frontend module
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "frontend.ts", "--outdir=dist", "--target=browser"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stderr).not.toContain("error");
// Read the bundled output
const bundled = await Bun.file(`${dir}/dist/frontend.js`).text();
// The asset.ts content should be a string literal, not executed code
expect(bundled).toContain('console.error("Unreachable!")');
// Make sure the error is NOT executed (it should be in a string)
expect(normalizeBunSnapshot(bundled, dir)).toMatchInlineSnapshot(`
"// asset.ts
var asset_default = 'console.error("Unreachable!");';
// frontend.ts
console.log(asset_default);"
`);
});
test("TypeScript file should compile when imported normally even if also imported as text - issue #23299", async () => {
using dir = tempDir("issue-23299-text-only", {
"code.ts": `export const value = 42;`,
"text-import.ts": `
//@ts-ignore
import text from "./code.ts" with { type: "text" };
console.log("Source:", text);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "build", "text-import.ts", "--outdir=dist", "--target=browser"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
const bundled = await Bun.file(`${dir}/dist/text-import.js`).text();
// The TypeScript file should be loaded as text, not compiled
expect(bundled).toContain("export const value = 42");
expect(bundled).toContain("Source:");
});