Skip to content

Apply Clusterize spacer heights via CSSOM so CSP cannot drop them - #1499

Merged
timothysmith0609 merged 1 commit into
Shopify:mainfrom
brianwarsing:fix/csp-clusterize-spacer-heights
Aug 19, 2026
Merged

Apply Clusterize spacer heights via CSSOM so CSP cannot drop them#1499
timothysmith0609 merged 1 commit into
Shopify:mainfrom
brianwarsing:fix/csp-clusterize-spacer-heights

Conversation

@brianwarsing

Copy link
Copy Markdown
Contributor

Summary

Fixes the task-log flickering reported in
Shopify/continuous-deployment#2208:
output streams fine, then flickers, then stops updating, sometimes disappearing.

Reported 2025-10-29 against Shipit, open since 2025-11-06.

#gsd:48220

Root cause

renderExtraTag sets tag.style.height on a detached node and returns tag.outerHTML.
insertToDOM joins those strings and html() assigns them with innerHTML, so the height
reaches the document as a style attribute parsed from markup:

// vendor/assets/javascripts/clusterize.js
renderExtraTag: function(class_name, height) {
  var tag = document.createElement(this.options.tag), ...
  height && (tag.style.height = height + 'px');   // detached
  return tag.outerHTML;                            // -> serialized to markup
}

Under a style-src policy without 'unsafe-inline', browsers refuse to apply such an attribute.
Shipit serves exactly that policy:

$ curl -sI https://shipit.shopify.io/
content-security-policy: ... style-src 'self' https:

So the spacers render at height 0. Total scroll height collapses to just the rendered cluster
regardless of how many rows exist, scrollTop is clamped into that range, getClusterNum()
derives a different cluster from the clamped value, insertToDOM swaps in different rows, and the
height changes again.

The console fills with Applying inline style violates the following Content Security Policy directive: "style-src 'self' https:".

Measured

Shipit's task page with the real assets under the verbatim production CSP, 10 seconds at 1
line/sec:

without CSP with CSP with CSP + this fix
html() swaps 10 601 14
re-entrant swaps 0 145 0
repaints/sec ~8 83 ~8
scrollHeight at 800 rows 19316 4844 19316
first visible line number 236 237 238 239 … 173 173 239 173 242 173 … 236 237 238 239 …

The viewport alternates between two regions of the log roughly eighty times a second.

Two details that match the bug report precisely:

  • Below rows_in_block no spacers are emitted, so the page starts out fine and only degrades
    once the log grows — matching "start out fine, but soon begin flickering".
  • The only_bottom_offset_changed fast path already uses lastChild.style.height on a live
    node, which CSP permits. That asymmetry is why the failure presents as oscillation rather than a
    static break.

The fix

CSSOM writes are not blocked by CSP — only markup-parsed style attributes and inline <style>
elements are. Verified under the policy above:

how the height is applied rendered
detached node → outerHTMLinnerHTML (current behaviour) 0px
el.style.height on an inserted node (CSSOM) 500px
<style> element injected at runtime 0px

So carry the height through the markup round trip in a data attribute and apply it via CSSOM once
the nodes are live. 15 lines, confined to the vendored library.

Alternatives considered

  • Add 'unsafe-inline' to style_src. Works, but weakens the policy application-wide to
    accommodate one 2016 vendored library, and reverts a posture that was deliberately tightened.
    Grepping every engine asset, this is the only real offender.
  • Add style-src to content_security_policy_nonce_directives. Does not help — nonces apply
    to <style> elements, not style attributes.
  • Upgrade Clusterize. Unmaintained since 2018; upstream still contains the same code.

Testing

The engine has no browser/system test harness, so this cannot be covered by the existing suite.
It was verified in a standalone rig that loads the real compiled assets, serves the verbatim
production CSP, and emulates TasksController#tail byte-for-byte. Detection is a
requestAnimationFrame sampler recording the first visible line number each frame; flicker is
that sequence moving backwards while scroll position does not.

Happy to attach the rig or a screen recording if useful.

Co-authored-by: AI (Pi/anthropic/claude-opus-5) noreply@pi.dev

Fixes the task-log flickering reported in
Shopify/continuous-deployment#2208 (#gsd:48220): output streams fine, then
flickers, then stops updating.

`renderExtraTag` sets `tag.style.height` on a DETACHED node and returns
`tag.outerHTML`. `insertToDOM` joins those strings and `html()` assigns them
with `innerHTML`, so the height reaches the document as a style ATTRIBUTE
parsed from markup.

Under a `style-src` policy without 'unsafe-inline' the browser refuses to
apply such an attribute. Shipit serves exactly that:

    $ curl -sI https://shipit.shopify.io/
    content-security-policy: ... style-src 'self' https:

The spacers therefore render at height 0. Total scroll height collapses to
just the rendered cluster no matter how many rows exist, scrollTop is clamped
into that range, getClusterNum() derives a different cluster from the clamped
value, insertToDOM swaps in different rows, and the height changes again.

Measured on Shipit's task page with the real assets under the verbatim
production CSP, 10 seconds at 1 line/sec:

                            without CSP   with CSP   with CSP + fix
    html() swaps                     10        601               14
    re-entrant swaps                  0        145                0
    repaints/sec                     ~8         83               ~8
    scrollHeight at 800 rows      19316       4844            19316

The first visible line number goes from `236 237 238 239 ...` to
`173 173 239 173 242 173 ...` - the viewport alternating between two regions
of the log about eighty times a second.

Below `rows_in_block` no spacers are emitted, which is why the page starts out
fine and only degrades once the log grows. Note that the
`only_bottom_offset_changed` fast path already uses `lastChild.style.height`
on a LIVE node, which CSP permits; that asymmetry is why the failure presents
as oscillation rather than a static break.

CSSOM writes are not blocked by CSP, only markup-parsed style attributes and
inline <style> elements. Verified under the policy above:

    detached node -> outerHTML -> innerHTML    0px
    el.style.height on an inserted node      500px
    <style> element injected at runtime         0px

So carry the height through the markup round trip in a data attribute and
apply it via CSSOM once the nodes are live.

This keeps the fix inside the vendored library rather than weakening
`style-src` for the whole application. Clusterize 0.16.0 has been unmaintained
since 2018 and upstream still contains the same code, so there is no newer
release to move to.

Co-authored-by: AI (Pi/anthropic/claude-opus-5) <noreply@pi.dev>
Assisted-By: devx/346d79f1-a786-419e-ae33-9ffc08a2bc88
@timothysmith0609
timothysmith0609 merged commit e308889 into Shopify:main Aug 19, 2026
8 checks passed
brianwarsing added a commit that referenced this pull request Aug 20, 2026
Documents #1499 (e308889), which fixed the long-standing task log flickering
reported in Shopify/continuous-deployment#2208.

Co-authored-by: AI (Pi/anthropic/claude-opus-5) <noreply@pi.dev>
Assisted-By: devx/346d79f1-a786-419e-ae33-9ffc08a2bc88
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.

3 participants