Skip to content

fix: Reorder bundle so bslib wins cascade ties against shiny.scss#2256

Merged
elnelson575 merged 2 commits into
mainfrom
fix/scss-cascade-order
May 21, 2026
Merged

fix: Reorder bundle so bslib wins cascade ties against shiny.scss#2256
elnelson575 merged 2 commits into
mainfrom
fix/scss-cascade-order

Conversation

@elnelson575
Copy link
Copy Markdown
Contributor

@elnelson575 elnelson575 commented May 21, 2026

Summary

Reorder layers in bs_full_theme() so R-shiny's shiny.scss emits before bslib in the compiled bootstrap.min.css. This matches the cascade order an R-shiny + bslib app sees (where shiny.css is added as its own <link> before bslib's bundle), and lets bslib andbs3compat rules win same-specificity ties against shiny.scss.

Solution

bs_bundle() requires theme first (its first argument is asserted to be a bs_theme), so drop down to sass::sass_bundle() directly and reapply the bs_theme class — exactly mirroring bs_bundle()'s implementation, just without the required order.

Shiny & bslib versions were pinned to their last vendored version for the purpose of making sure this diff is just changes from the order change, not any of the other bs5 changes that will be covered in later PRs or the shiny changes that will be covered in later PRs.

Visible effect

NOTE: These effects are intended!

Element Before After
Vertical ui.input_radio_buttons() / ui.input_checkbox_group() label-to-options gap -10px (shiny.scss base) calc(-.15em - var(--bs-border-width)) (bslib bs3compat, ~ -3.4px)
Inline radio/checkbox label-to-options gap -1px (shiny.scss inline) -1px (shiny.scss inline) — unchanged; bslib's bs3compat rule is at 0-2-1 specificity and still loses to shiny's 0-3-1 inline rule. Will flip too once rstudio/bslib#1308 (which lifts bslib's rule to 0-3-1 with a doubled-class compound) merges.

Why this works: cascade-order analysis

Click to expand

The bug

bslib bs3compat is meant to override R-shiny's shiny.scss for several Bootstrap 5 backward-compatibility cases. In py-shiny those overrides were losing same-specificity ties because the vendored bootstrap.min.css placed bslib's bs3compat before shiny.scss in the file — the opposite of an R-shiny + bslib app's head order.

Where the order came from

bs_full_theme() in scripts/_functions_sass.R called bs_bundle(theme, bslib = ..., shiny = ..., ...). bslib's bs3compat is baked into the theme argument, and bs_bundle() always stacks named layers on top of theme. So bs3compat's rules emitted first, shiny.scss later. Same-specificity ties went to shiny.scss by source order.

Byte-level confirmation

In the regenerated shiny/www/shared/bootstrap/bootstrap.min.css:

Rule Before After
shiny.scss -10px (.shiny-input-radiogroup label ~ .shiny-options-group) byte 360,395 byte 5,138
shiny.scss -1px inline (.shiny-input-radiogroup.shiny-input-container-inline label ~ .shiny-options-group) byte 360,546 byte 5,289
bslib bs3compat calc (.shiny-input-radiogroup label ~ .shiny-options-group) byte 278,779 byte 286,617
File size 399,349 B 399,547 B (~200 B drift)

shiny.scss now sits before bslib in the file; bslib wins all same-specificity ties.

Collateral-damage audit

Only one selector pair has same-specificity + same-element overlap between bs3compat and shiny.scss in BS5 mode:

.shiny-input-{checkboxgroup,radiogroup} label ~ .shiny-options-group (0-2-1 in both).

  • bs3compat sets margin-top: calc(-.15em - var(--bs-border-width)).
  • shiny.scss base sets margin-top: -10px.
  • Previously bslib emitted first → shiny won the tie → -10px.
  • With the reorder, bslib emits last → bslib wins → calc.
  • This is the only winner-flip.

Other classes that look shared but don't actually collide:

Class shiny.scss usage Conflict?
.checkbox, .radio always under .qt5 or .qtmac (Qt browser scopes) no — different selector
.well only as .well .shiny-input-container (descendant) no — different element
.active, .show, .in, .nav substring matches only (e.g. &-active, comments) no — false positives
.shiny-options-group only as descendant of .shiny-input-{kind} covered by the rule above

shiny.scss's .radio/.checkbox usages are all prefixed with .qt5/.qtmac (Shiny Server browser scopes — different element scope). bs3compat's .radio/.checkbox styling is wrapped in @if $bootstrap-version == 4 and doesn't compile for BS5.

Net effect: only the vertical radio/checkbox label-margin changes (and to the value bs3compat intended); no other styles are affected.

Relationship to other PRs

Test plan

  • Visual: vertical ui.input_radio_buttons() / ui.input_checkbox_group() use the calc gap (slightly tighter than the previous -10px)
  • Visual: inline variants render unchanged
  • No unrelated style regressions across themes (the collateral audit predicts none)

….scss

Drop down from `bs_bundle()` to `sass::sass_bundle()` directly so
`shiny.scss` can be listed before `theme` in the bundle. bslib's
`bs3compat` is baked into `theme`, and `bs_bundle()` always stacks
named layers on top of `theme`, putting bslib's BS5 overrides earlier
in the compiled CSS than R-shiny's shiny.scss — the opposite of an
R-shiny + bslib app, where shiny.css loads as its own <link> before
bslib's bundle. Same-specificity ties therefore went to shiny.scss
by source order, defeating bslib's bs3compat overrides.

After the reorder, bslib's bs3compat emits later in the file and
wins cascade ties. The only same-specificity, same-element conflict
between bs3compat and shiny.scss in BS5 mode is the radio/checkbox
label-margin rule; the audit found no other rules whose winner
flips. Net effect: vertical radio/checkbox groups now use bslib's
calc value instead of shiny.scss's `-10px`; inline groups still use
shiny.scss's `-1px` (specificity 0-3-1 beats bs3compat's 0-2-1
without rstudio/bslib#1308's doubled-class compound).

Pins bslib and shiny in `scripts/_pkg-sources.R` to the SHAs that
were used in main's last vendor so the regenerated assets isolate
the cascade-reorder change with no upstream drift.
The previous commit pinned these to specific SHAs so the cascade-
reorder vendor diff was free of upstream drift. Restore the
moving-target `@main` pins so the next `make upgrade-html-deps`
pulls current upstream.
Copy link
Copy Markdown
Collaborator

@gadenbuie gadenbuie left a comment

Choose a reason for hiding this comment

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

LGTM! Thanks for digging so deeply into this one!

@elnelson575 elnelson575 merged commit c28b829 into main May 21, 2026
174 of 175 checks passed
@elnelson575 elnelson575 deleted the fix/scss-cascade-order branch May 21, 2026 21:37
@elnelson575 elnelson575 self-assigned this May 21, 2026
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