Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Sourcemaps #515

Merged
merged 23 commits into from
Nov 14, 2017
Merged

Fix Sourcemaps #515

merged 23 commits into from
Nov 14, 2017

Conversation

schneems
Copy link
Member

@schneems schneems commented Nov 13, 2017

Sorry for the large PR, these issues all cropped up at the same time.

Problem: Concat JS adds a newline after semi-colon

Solution: Don't add a newline. If one exists put semi-colon before newline

Problem: Directive Processor adds newline between assets

This was done after the "Default Sourcemap" processor so it wasn't encoded in the asset's "map"

Solution: Move the default sourcemap processor to be last

Problem: Default source map processor doesn't preserve source maps

Solution: Add the map back to the returned result

Problem: Assets with sourcemap comment do not end in newline

Solution: Add newline after sourcemap comment

(Not required, but it makes things nicer)

Problem: Concatenating Source Maps is broken

To concatenate 2 source maps you can use multiple "sections" and give each section an offset. To calculate the offset, you need to know the previous offset, and the length of the last generated file. The offset can be pulled from the last map. The size of the prior generated file was calculated using the number of semicolons in the "mappings"

offset = a["sections"].last["map"]["mappings"].count(';') +

This behavior is not explicitly stated in the spec, but is implied and confirmed that it should work: https://groups.google.com/forum/#!topic/mozilla.dev.js-sourcemap/gp_ULp-h1fQ. However it appears that coffeescript generated sourcemaps do not always have the same number of semicolons as the generated files. I've opened an issue and with coffeescript jashkenas/coffeescript#4786

Solution: To get around this, I've added an x_sprockets_linecount key to the sourcemaps that includes the line count of the generated file. This key is added in the DefaultSourceMap which all assets will be processed through.

The downside of this approach is this now means that the concat source map function will only work on files that have been passed through the DefaultSourceMap processor.

The directive processor can add a newline to assets. If it comes after the default source map then this line is not accounted for when determining line offsets.
Adding a new line with semi-colons can cause off by 1 errors with source maps. Instead we want to add a semicolon. If the last character was whitespace such as a newline, we want to make sure that there are the same number of `str.lines` before and after, so we replace the whitespace character with a semicolon followed by the same whitespace character.
The calculation of length of asset is done by counting semicolons, however not all mappings end with a semicolon. When they do we must increment the offset.

The next part is harder to explain. 

We concatenate two assets A.js and B.js. A has 10 lines B has 5 lines.

After we’ve done this we want an offset that looks like this:

```
A.js: {"line"=>0, "column"=>0}
B.js: {"line"=>11, "column"=>0}
```

To get 11 we take the prior length of mappings for A.js which is 10 (from the lines) and increment by one to get to 11.

Now we add another asset C.js which has 2 lines. We want an offset that looks like this

```
A.js: {"line"=>0, "column"=>0}
B.js: {"line"=>11, "column"=>0}
C.js: {"line"=>16, "column"=>0}
```

If we add the last offset which is 11, to the length of the asset of B (which is 5) then we get the correct offset which is 16. We do not have to add an extra +1. 

I think this was accidentally working previously based on unwanted behavior from adding semi-colons and newlines.
This patch preserves an original source maps if one was present.
```
Warming up --------------------------------------
              count
   317.318k i/100ms
              length   329.512k i/100ms
              size     329.953k i/100ms
Calculating -------------------------------------
              count       9.806M (± 7.7%) i/s -     48.867M in   5.014627s
              length     11.696M (± 6.3%) i/s -     58.324M in   5.008040s
              size       11.074M (± 7.5%) i/s -     55.102M in   5.005626s
```
Based on the spec it is not guaranteed that there will be a mapping for each and every line https://groups.google.com/forum/#!topic/mozilla.dev.js-sourcemap/gp_ULp-h1fQ

We were previously using this behavior to know the length of a previous asset when concatenating source maps. 

We can get around this by storing the length of the previous asset in an “x_sprockets_linecount” key. This is guaranteed to be on the asset since it is added by the `DefaultSourceMap`.


We no longer need to offset the first asset by one line. This was accidentally working before.
We no longer add a newline if one did not previously exist.
Previous test was failing intermittently with results like `["0", "0", "1", "1", "0", "0", "1", “1”]`
@schneems schneems force-pushed the schneems/fix-newline-sourcemaps branch from c97c91d to f2bbb41 Compare November 14, 2017 17:31
We need this because `concat_source_maps` expects a `x_sprockets_linecount` key in the map metadata. It wasn’t put there until this PR, so if a map gets pulled out of the old cache and attempts to get loaded into memory then it will fail.

By rev-ing the cache we can ensure the key will be there, since it’s added by `DefaultSourceMap`.
Copy link
Member

@jeremy jeremy left a comment

Choose a reason for hiding this comment

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

Great batch of fixes ✌🏼

@@ -3,7 +3,7 @@ language: ruby
cache: bundler

sudo: false

before_script: "unset _JAVA_OPTIONS"
Copy link
Member

Choose a reason for hiding this comment

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

leaked in?

Copy link
Member Author

Choose a reason for hiding this comment

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

register_preprocessor 'application/javascript', Preprocessors::DefaultSourceMap.new

register_bundle_metadata_reducer 'text/css', :map, proc { |input| { "version" => 3, "file" => PathUtils.split_subpath(input[:load_path], input[:filename]), "sections" => [] } }, SourceMapUtils.method(:concat_source_maps)
register_bundle_metadata_reducer 'application/javascript', :map, proc { |input| { "version" => 3, "file" => PathUtils.split_subpath(input[:load_path], input[:filename]), "sections" => [] } }, SourceMapUtils.method(:concat_source_maps)
Copy link
Member

Choose a reason for hiding this comment

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

👍

@@ -51,7 +51,7 @@ class Cache
# Internal: Cache key version for this class. Rarely should have to change
# unless the cache format radically changes. Will be bump on major version
# releases though.
VERSION = '4.0'
VERSION = '4.0.0'
Copy link
Member

Choose a reason for hiding this comment

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

Unrelated?

Copy link
Member Author

Choose a reason for hiding this comment

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

We need this because concat_source_maps expects a x_sprockets_linecount key in the map metadata. It wasn’t put there until this PR, so if a map gets pulled out of the old cache and attempts to get loaded into memory then it will fail.

By rev-ing the cache we can ensure the key will be there, since it’s added by DefaultSourceMap.

@@ -100,18 +98,18 @@ def string_end_with_semicolon?(str)
#
# Returns buf String.
def concat_javascript_sources(buf, source)
if source.bytesize > 0
buf << source
return buf if source.bytesize < 0
Copy link
Member

Choose a reason for hiding this comment

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

<=

Copy link
Member Author

Choose a reason for hiding this comment

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

👍 💯

elsif source[source.size - 1].ord != 0x0A
buf << "\n"
end
if whitespace = WHITESPACE_ORDINALS[buf[-1].ord]
Copy link
Member

Choose a reason for hiding this comment

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

Indexing into buf defeats the purpose of source encoding above?

Copy link
Member Author

Choose a reason for hiding this comment

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

That is a bug. I'm supposed to index into source thanks and good catch!

if whitespace = WHITESPACE_ORDINALS[buf[-1].ord]
buf[-1] = ";#{whitespace}"
else
buf << ";"
end
Copy link
Member

Choose a reason for hiding this comment

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

This is officially a "there be dragons" method. Your PR commentary would be appropriate for discussion in code comments here, too, so it's clear what conditions are at play and where they're implemented.

Switching from conditional expressions to conditional control flow makes this simpler to read in imperative style, but it's harder (for me) to parse and understand.

@@ -610,7 +610,7 @@ class SlowExporter2 < SlowExporter
@env.register_exporter 'image/png',SlowExporter
@env.register_exporter 'image/png',SlowExporter2
Sprockets::Manifest.new(@env, @dir).compile('logo.png', 'troll.png')
assert_equal %w(0 0 0 0 1 1 1 1), SlowExporter.seq
refute_equal %w(0 1 0 1 0 1 0 1), SlowExporter.seq
Copy link
Member

Choose a reason for hiding this comment

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

Why are we asserting what this isn't instead of what it is?

Copy link
Member Author

Choose a reason for hiding this comment

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

When we're exporting things serially then the second exporter will not start until the first one ends. We are guaranteed to get 0 1 0 1 0 . However when we're running this in parallel, then we do not need to wait for the first exporter to be finished for the second to run, etc. That's why the original code asserted that we got four zeroes first.

However that test is coupled to a sleep and therefore race conditions.

I changed the test to allow for other combinations, as long as they don't perfectly match the "synchronous" case.

@@ -642,7 +642,7 @@ def call(_)
processor = SlowProcessor.new
@env.register_postprocessor 'image/png', processor
Sprockets::Manifest.new(@env, @dir).compile('logo.png', 'troll.png')
assert_equal %w(0 0 1 1), processor.seq
refute_equal %w(0 1 0 1), processor.seq
Copy link
Member

Choose a reason for hiding this comment

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

Ditto.

def deep_dup(object)
Marshal.load( Marshal.dump(object) )
end

Copy link
Member

Choose a reason for hiding this comment

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

👹

Copy link
Member Author

Choose a reason for hiding this comment

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

😇

@schneems schneems merged commit 090ff0b into master Nov 14, 2017
@jeremy jeremy deleted the schneems/fix-newline-sourcemaps branch March 12, 2018 23:10
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.

2 participants