Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/runtime/bake/dev_server/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1018,14 +1018,26 @@ pub(super) fn finalize_bundle(
// Send the JS chunk
if dev.client_graph.current_chunk_len > 0 {
let script_id = 'h: {
// Matches the bundler's `ContentHasher` hash (XxHash64).
// Hash every field `Entry::render_json` emits, with a tag
// byte per `Shared` variant.
Comment thread
robobun marked this conversation as resolved.
Resolved
let mut source_map_hash = bun_hash::XxHash64Streaming::new(0x4b12);
let keys = dev.client_graph.bundled_files.keys();
let values = dev.client_graph.bundled_files.values();
for part in &dev.client_graph.current_chunk_parts {
source_map_hash.update(&keys[part.get() as usize]);
if let Some(map) = values[part.get() as usize].source_map.get() {
source_map_hash.update(map.vlq());
match &values[part.get() as usize].source_map {
packed_map::Shared::Some(map) => {
source_map_hash.update(&[0]);
source_map_hash.update(map.vlq());
source_map_hash.update(map.quoted_contents());
Comment thread
robobun marked this conversation as resolved.
Resolved
}
packed_map::Shared::LineCount(count) => {
source_map_hash.update(&[1]);
source_map_hash.update(&count.get().to_le_bytes());
}
packed_map::Shared::None => {
source_map_hash.update(&[2]);
}
}
}
// Set the bottom bit.
Expand Down
96 changes: 96 additions & 0 deletions test/bake/dev/sourcemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,102 @@ devTest("source map emitted for hmr chunk", {
},
});

devTest("hmr chunk source map follows the source when mappings do not change", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import other from "./App";
console.log("Hello, " + other + "!");
import.meta.hot.accept();
`,
"App.tsx": `
export default "AAA";
import.meta.hot.accept();
`,
},
async test(dev) {
await using c = await dev.client("/", { storeHotChunks: true });
await c.expectMessage("Hello, AAA!");
// Each edit changes only a string literal. The VLQ mappings stay the
// same, so the source map must be keyed by the source content too.
const mapUrls = new Set<string>();
let lastSourceMapUrl: string | undefined;
for (const word of ["BBB", "CCC", "DDD"]) {
const source = `export default "${word}";\nimport.meta.hot.accept();`;
await dev.write("App.tsx", source);
const chunk = await c.getMostRecentHmrChunk();
expect(chunk).toContain(`"${word}"`);
const sourceMapUrl = chunk.match(/\n\/\/# sourceMappingURL=(\S+)/)?.[1];
expect(sourceMapUrl).toBeString();
mapUrls.add(sourceMapUrl!);
lastSourceMapUrl = sourceMapUrl;
const sourceMap = await dev.fetch(sourceMapUrl!).json();
expect(sourceMap.sources.slice(1).map(Bun.fileURLToPath)).toEqual([dev.join("App.tsx")]);
expect(sourceMap.sourcesContent[1]).toBe(source);
}
expect(mapUrls.size).toBe(3);

// A re-save with the same bytes produces the same source map, so it
// reuses the same key.
await dev.writeNoChanges("App.tsx");
const chunk = await c.getMostRecentHmrChunk();
expect(chunk).toContain(`"DDD"`);
const sourceMapUrl = chunk.match(/\n\/\/# sourceMappingURL=(\S+)/)?.[1];
expect(sourceMapUrl).toBe(lastSourceMapUrl);
},
});

devTest("hmr chunk source map follows the line count of a part without mappings", {
files: {
"index.html": emptyHtmlFile({
scripts: ["index.ts"],
}),
"index.ts": `
import other from "./App";
import text from "./data.txt";
console.log("Hello, " + other + "! " + text.length);
import.meta.hot.accept();
`,
"App.tsx": `
export default "AAA";
import.meta.hot.accept();
`,
"data.txt": "x",
},
async test(dev) {
await using c = await dev.client("/", { storeHotChunks: true });
await c.expectMessage("Hello, AAA! 1");
// A text file prints no mappings, so the store keeps only its line
// count. The line count offsets every part after it in the chunk. Each
// save changes only that count, so each save needs its own map.
const mapUrls = new Set<string>();
const mappings = new Set<string>();
for (const text of ["BBB\n", "BBB\n\n", "BBB\n\n\n"]) {
{
await using _batch = await dev.batchChanges({ errors: null });
await dev.write("data.txt", text, { dedent: false });
await dev.writeNoChanges("App.tsx");
}
const chunk = await c.getMostRecentHmrChunk();
const sourceMapUrl = chunk.match(/\n\/\/# sourceMappingURL=(\S+)/)?.[1];
Comment thread
robobun marked this conversation as resolved.
Resolved
expect(sourceMapUrl).toBeString();
mapUrls.add(sourceMapUrl!);
const sourceMap = await dev.fetch(sourceMapUrl!).json();
// The bundler does not order the parts of the chunk.
expect(sourceMap.sources.slice(1).map(Bun.fileURLToPath).sort()).toEqual(
[dev.join("index.ts"), dev.join("data.txt"), dev.join("App.tsx")].sort(),
);
mappings.add(sourceMap.mappings);
// index.ts re-runs on each update. Its output is not under test.
c.messages.length = 0;
Comment thread
robobun marked this conversation as resolved.
Resolved
}
expect(mapUrls.size).toBe(3);
expect(mappings.size).toBe(3);
Comment thread
robobun marked this conversation as resolved.
Resolved
},
});

type SourceMap = (BasicSourceMapConsumer | IndexedSourceMapConsumer) & {
/** Original script generated */
script: string;
Expand Down
Loading