Skip to content

Commit

Permalink
fix #1861, fix #2565: add cssBundle to metafile
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Sep 29, 2022
1 parent c55000d commit 4bd03f3
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 7 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Expand Up @@ -21,6 +21,16 @@

Node has a little-used feature called [subpath imports](https://nodejs.org/api/packages.html#subpath-imports) which are package-internal imports that start with `#` and that go through the `imports` map in `package.json`. Previously esbuild had a bug that caused esbuild to not handle these correctly in packages installed via Yarn's "Plug'n'Play" installation strategy. The problem was that subpath imports were being checked after Yarn PnP instead of before. This release reorders these checks, which should allow subpath imports to work in this case.

* Link from JS to CSS in the metafile ([#1861](https://github.com/evanw/esbuild/issues/1861), [#2565](https://github.com/evanw/esbuild/issues/2565))

When you import CSS into a bundled JS file, esbuild creates a parallel CSS bundle next to your JS bundle. So if `app.ts` imports some CSS files and you bundle it, esbuild will give you `app.js` and `app.css`. You would then add both `<script src="app.js"></script>` and `<link href="app.css" rel="stylesheet">` to your HTML to include everything in the page. This approach is more efficient than having esbuild insert additional JavaScript into `app.js` that downloads and includes `app.css` because it means the browser can download and parse both the CSS and the JS in parallel (and potentially apply the CSS before the JS has even finished downloading).

However, sometimes it's difficult to generate the `<link>` tag. One case is when you've added `[hash]` to the [entry names](https://esbuild.github.io/api/#entry-names) setting to include a content hash in the file name. Then the file name will look something like `app-GX7G2SBE.css` and may change across subsequent builds. You can tell esbuild to generate build metadata using the `metafile` API option but the metadata only tells you which generated JS bundle corresponds to a JS entry point (via the `entryPoint` property), not which file corresponds to the associated CSS bundle. Working around this was hacky and involved string manipulation.

This release adds the `cssBundle` property to the metafile to make this easier. It's present on the metadata for the generated JS bundle and points to the associated CSS bundle. So to generate the HTML tags for a given JS entry point, you first find the output file with the `entryPoint` you are looking for (and put that in a `<script>` tag), then check for the `cssBundle` property to find the associated CSS bundle (and put that in a `<link>` tag).

One thing to note is that there is deliberately no `jsBundle` property mapping the other way because it's not a 1:1 relationship. Two JS bundles can share the same CSS bundle in the case where the associated CSS bundles have the same name and content. In that case there would be no one value for a hypothetical `jsBundle` property to have.

## 0.15.9

* Fix an obscure npm package installation issue with `--omit=optional` ([#2558](https://github.com/evanw/esbuild/issues/2558))
Expand Down
38 changes: 38 additions & 0 deletions internal/bundler/bundler_css_test.go
Expand Up @@ -707,3 +707,41 @@ func TestCSSNestingOldBrowser(t *testing.T) {
`,
})
}

// The mapping of JS entry point to associated CSS bundle isn't necessarily 1:1.
// Here is a case where it isn't. Two JS entry points share the same associated
// CSS bundle. This must be reflected in the metafile by only having the JS
// entry points point to the associated CSS bundle but not the other way around
// (since there isn't one JS entry point to point to). This test mainly exists
// to document this edge case.
func TestMetafileCSSBundleTwoToOne(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/foo/entry.js": `
import '../common.css'
console.log('foo')
`,
"/bar/entry.js": `
import '../common.css'
console.log('bar')
`,
"/common.css": `
body { color: red }
`,
},
entryPaths: []string{
"/foo/entry.js",
"/bar/entry.js",
},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputDir: "/out",
EntryPathTemplate: []config.PathTemplate{
// "[ext]/[hash]"
{Data: "./", Placeholder: config.ExtPlaceholder},
{Data: "/", Placeholder: config.HashPlaceholder},
},
NeedsMetafile: true,
},
})
}
5 changes: 4 additions & 1 deletion internal/bundler/bundler_test.go
Expand Up @@ -160,7 +160,7 @@ func (s *suite) __expectBundledImpl(t *testing.T, args bundled, fsKind fs.MockKi
}

log = logger.NewDeferLog(logKind, nil)
results, _ := bundle.Compile(log, nil, nil)
results, metafileJSON := bundle.Compile(log, nil, nil)
msgs = log.Done()
assertLog(t, msgs, args.expectedCompileLog)

Expand All @@ -184,6 +184,9 @@ func (s *suite) __expectBundledImpl(t *testing.T, args bundled, fsKind fs.MockKi
generated += fmt.Sprintf("---------- %s ----------\n%s", result.AbsPath, string(result.Contents))
}
}
if metafileJSON != "" {
generated += fmt.Sprintf("---------- metafile.json ----------\n%s", metafileJSON)
}
s.compareSnapshot(t, testName, generated)
})
}
Expand Down
30 changes: 24 additions & 6 deletions internal/bundler/linker.go
Expand Up @@ -170,6 +170,9 @@ type chunkReprJS struct {
importsFromOtherChunks map[uint32]crossChunkImportItemArray
crossChunkPrefixStmts []js_ast.Stmt
crossChunkSuffixStmts []js_ast.Stmt

cssChunkIndex uint32
hasCSSChunk bool
}

type chunkReprCSS struct {
Expand Down Expand Up @@ -3160,7 +3163,8 @@ func (c *linkerContext) computeChunks() []chunkInfo {

switch file.InputFile.Repr.(type) {
case *graph.JSRepr:
chunk.chunkRepr = &chunkReprJS{}
chunkRepr := &chunkReprJS{}
chunk.chunkRepr = chunkRepr
jsChunks[key] = chunk

// If this JS entry point has an associated CSS entry point, generate it
Expand All @@ -3169,6 +3173,7 @@ func (c *linkerContext) computeChunks() []chunkInfo {
// discovered in JS source order, where JS source order is arbitrary but
// consistent for dynamic imports. Then we run the CSS import order
// algorithm to determine the final CSS file order for the chunk.

if cssSourceIndices := c.findImportedCSSFilesInJSOrder(entryPoint.SourceIndex); len(cssSourceIndices) > 0 {
externalOrder, internalOrder := c.findImportedFilesInCSSOrder(cssSourceIndices)
cssFilesWithPartsInChunk := make(map[uint32]bool)
Expand All @@ -3186,6 +3191,7 @@ func (c *linkerContext) computeChunks() []chunkInfo {
filesInChunkInOrder: internalOrder,
},
}
chunkRepr.hasCSSChunk = true
}

case *graph.CSSRepr:
Expand Down Expand Up @@ -3226,16 +3232,25 @@ func (c *linkerContext) computeChunks() []chunkInfo {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
jsChunkIndicesForCSS := make(map[string]uint32)
for _, key := range sortedKeys {
sortedChunks = append(sortedChunks, jsChunks[key])
chunk := jsChunks[key]
if chunk.chunkRepr.(*chunkReprJS).hasCSSChunk {
jsChunkIndicesForCSS[key] = uint32(len(sortedChunks))
}
sortedChunks = append(sortedChunks, chunk)
}
sortedKeys = sortedKeys[:0]
for key := range cssChunks {
sortedKeys = append(sortedKeys, key)
}
sort.Strings(sortedKeys)
for _, key := range sortedKeys {
sortedChunks = append(sortedChunks, cssChunks[key])
chunk := cssChunks[key]
if jsChunkIndex, ok := jsChunkIndicesForCSS[key]; ok {
sortedChunks[jsChunkIndex].chunkRepr.(*chunkReprJS).cssChunkIndex = uint32(len(sortedChunks))
}
sortedChunks = append(sortedChunks, chunk)
}

// Map from the entry point file to this chunk. We will need this later if
Expand Down Expand Up @@ -4981,12 +4996,15 @@ func (c *linkerContext) generateChunkJS(chunks []chunkInfo, chunkIndex int, chun
if !isFirstMeta {
jMeta.AddString("\n ")
}
jMeta.AddString("],\n")
if chunk.isEntryPoint {
entryPoint := c.graph.Files[chunk.sourceIndex].InputFile.Source.PrettyPath
jMeta.AddString(fmt.Sprintf("],\n \"entryPoint\": %s,\n \"inputs\": {", helpers.QuoteForJSON(entryPoint, c.options.ASCIIOnly)))
} else {
jMeta.AddString("],\n \"inputs\": {")
jMeta.AddString(fmt.Sprintf(" \"entryPoint\": %s,\n", helpers.QuoteForJSON(entryPoint, c.options.ASCIIOnly)))
}
if chunkRepr.hasCSSChunk {
jMeta.AddString(fmt.Sprintf(" \"cssBundle\": %s,\n", helpers.QuoteForJSON(chunks[chunkRepr.cssChunkIndex].uniqueKey, c.options.ASCIIOnly)))
}
jMeta.AddString(" \"inputs\": {")
}

// Concatenate the generated JavaScript chunks together
Expand Down
84 changes: 84 additions & 0 deletions internal/bundler/snapshots/snapshots_css.txt
Expand Up @@ -267,6 +267,90 @@ console.log("b");
color: blue;
}

================================================================================
TestMetafileCSSBundleTwoToOne
---------- /out/js/UOATE6K4.js ----------
// foo/entry.js
console.log("foo");

---------- /out/css/DIO3TRUB.css ----------
/* common.css */
body {
color: red;
}

---------- /out/js/6ZCNL5VY.js ----------
// bar/entry.js
console.log("bar");
---------- metafile.json ----------
{
"inputs": {
"common.css": {
"bytes": 28,
"imports": []
},
"foo/entry.js": {
"bytes": 54,
"imports": [
{
"path": "common.css",
"kind": "import-statement"
}
]
},
"bar/entry.js": {
"bytes": 54,
"imports": [
{
"path": "common.css",
"kind": "import-statement"
}
]
}
},
"outputs": {
"out/js/UOATE6K4.js": {
"imports": [],
"exports": [],
"entryPoint": "foo/entry.js",
"cssBundle": "out/css/DIO3TRUB.css",
"inputs": {
"common.css": {
"bytesInOutput": 0
},
"foo/entry.js": {
"bytesInOutput": 20
}
},
"bytes": 36
},
"out/css/DIO3TRUB.css": {
"imports": [],
"inputs": {
"common.css": {
"bytesInOutput": 23
}
},
"bytes": 40
},
"out/js/6ZCNL5VY.js": {
"imports": [],
"exports": [],
"entryPoint": "bar/entry.js",
"cssBundle": "out/css/DIO3TRUB.css",
"inputs": {
"common.css": {
"bytesInOutput": 0
},
"bar/entry.js": {
"bytesInOutput": 20
}
},
"bytes": 36
}
}
}

================================================================================
TestPackageURLsInCSS
---------- /out/entry.css ----------
Expand Down
1 change: 1 addition & 0 deletions lib/shared/types.ts
Expand Up @@ -452,6 +452,7 @@ export interface Metafile {
}[]
exports: string[]
entryPoint?: string
cssBundle?: string
}
}
}
Expand Down

0 comments on commit 4bd03f3

Please sign in to comment.