Skip to content

Skip loading Bootstrap when the active skin provides it#113

Closed
malberts wants to merge 2 commits into
masterfrom
fix/skip-bootstrap-for-skins-providing-it
Closed

Skip loading Bootstrap when the active skin provides it#113
malberts wants to merge 2 commits into
masterfrom
fix/skip-bootstrap-for-skins-providing-it

Conversation

@malberts

@malberts malberts commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

For #70

Skins that put Bootstrap on the page themselves ended up loading a second copy
from Extension:Bootstrap, once for the stylesheet and once for the script. This
fixes both.

Bootstrap CSS

Extension:Bootstrap's ext.bootstrap.styles and ext.bootstrap.scripts were
added in ParserAfterParse. That is the parse phase, so the decision is baked
into the skin-agnostic parser cache and cannot depend on the skin. They are now
added in the OutputPageParserOutput hook, where the active skin is known, and
skipped for skins that put Bootstrap on the page themselves. The list of those
skins is internal (Chameleon, Medik, Tweeki) rather than configurable, since
whether a skin loads its own Bootstrap is a fact about the skin, not a per-wiki
policy.

Extension:Bootstrap stays a Composer require. That keeps a useful lever: the
version constraint on it (^6.0, i.e. Bootstrap 5.3) pins the Bootstrap this
extension loads for skins that get it from Extension:Bootstrap, and rejects a
future Extension:Bootstrap that moves to a newer Bootstrap. Self-bundling skins
declare no such dependency, so matching their bundled Bootstrap stays the
operator's responsibility (see the known issues). The init gate is untouched,
and SetupAfterCache / addAllBootstrapModules() is untouched: that call only
configures what Extension:Bootstrap compiles into ext.bootstrap.styles, it
puts nothing on a page. Not skipping remains the safe default, so skipping is an
optimisation rather than a load-bearing correctness decision.

Evidence (CSS)

Distinct stylesheets delivering the Bootstrap library, per skin.

Skin Before After
chameleon ext.bootstrap.styles + zzz.ext.bootstrap.styles (2) zzz.ext.bootstrap.styles (1)
medik ext.bootstrap.styles + skins.medik (2) skins.medik (1)
tweeki ext.bootstrap.styles + skins.tweeki.styles (2) skins.tweeki.styles (1)
vector ext.bootstrap.styles (1) ext.bootstrap.styles (1)
vector-2022 ext.bootstrap.styles (1) ext.bootstrap.styles (1)
monobook ext.bootstrap.styles (1) ext.bootstrap.styles (1)
timeless ext.bootstrap.styles (1) ext.bootstrap.styles (1)

Chameleon's double request, the case reported in the issue, is gone:

before:  load.php?...modules=ext.bootstrap.styles&only=styles&skin=chameleon
         load.php?...modules=zzz.ext.bootstrap.styles&only=styles&skin=chameleon
after:   load.php?...modules=zzz.ext.bootstrap.styles&only=styles&skin=chameleon

Bootstrap JavaScript

The CSS skip alone does not keep Extension:Bootstrap's JavaScript off a
self-bundling skin. ext.bootstrapComponents.carousel.fix, .modal.fix,
.popover.fix and .tooltip.fix each ship an init script that calls the
Bootstrap JavaScript global (new bootstrap.Carousel( ... ),
bootstrap.Modal.getOrCreateInstance( ... ), and so on), so each declared
ext.bootstrap.scripts as a ResourceLoader dependency to guarantee Bootstrap
loads first. That dependency is load-bearing, dropping it would let the init
script run before Bootstrap exists, but it also forced Extension:Bootstrap's
copy onto every skin. On a skin that bundles its own Bootstrap the page then
carried two copies, and because each registers Bootstrap's data-api on the
document, one click was handled twice: a navbar or action dropdown opened and
immediately closed.

A ResourceLoader dependency cannot be varied per skin in static
extension.json, but it can in a module class. The four modules now use
BootstrapDependentModule and declare no Bootstrap dependency of their own; its
getDependencies( ?Context ) supplies one per skin: the skin's own scripts
module when it bundles Bootstrap, otherwise ext.bootstrap.scripts. The map
currently gives medik its skins.medik.js. Other skins get
ext.bootstrap.scripts; Chameleon adds it itself under the same name, so
ResourceLoader deduplicates it and its JavaScript was never doubled.

The map is a skin list rather than automatic detection because a skin's bundled
Bootstrap version is not knowable server-side, where the module-loading decision
is made. It is only visible in the browser (window.bootstrap.VERSION), which is
too late to change what loads.

Evidence (JS)

Verified in a browser (automated, Playwright) on Vector, Chameleon, Medik and
Tweeki:

  • Medik: ext.bootstrap.scripts is no longer loaded (its module state stays
    registered); the page carries one Bootstrap, Medik's own, delivered with
    skins.medik.js. The three skin-emitted chrome dropdowns, user menu, Actions
    (edit / history / source) and Tools, open and stay open. Force-loading
    ext.bootstrap.scripts to recreate the previous double makes all three open
    and immediately close again, so the single-Bootstrap state is what fixes them.
    Carousel, modal, popover, tooltip and accordion all work, and no
    bootstrap ... is not available warning appears, so the init scripts still
    reach the global.
  • Vector and Chameleon: unchanged, one Bootstrap, components and dropdowns
    working.

Tweeki is not covered yet

Tweeki bundles its own Bootstrap but exposes no window.bootstrap global, only
Bootstrap's jQuery plugin bridge. The four init scripts reach Bootstrap only
through the global, so pointing Tweeki's dependency at its own module would leave
them unable to initialise those components. Tweeki therefore stays on
ext.bootstrap.scripts and still double-loads Bootstrap's JavaScript (confirmed
in the browser: two live copies, seen as two modal backdrops). Making Tweeki
clean needs the init scripts to fall back to the jQuery bridge when the global is
absent, which is a separate change.

Known behaviour change: action=parse without useskin

ext.bootstrap.styles and ext.bootstrap.scripts used to sit on the
ParserOutput, which travels with the parsed content. They now sit on the
OutputPage, which only exists when a page is rendered for a skin. So
api.php?action=parse without a useskin parameter no longer lists them. The
scripts still reach any caller that loads the carousel, modal, popover or
tooltip fix modules, since with no skin those resolve to ext.bootstrap.scripts.
The styles have no such dependency, so they simply go missing.

With useskin the hook runs and the answer is correct per skin, which is an
improvement on master: master answered the same way for every skin, so on a
Chameleon wiki it told callers to load Bootstrap that Chameleon already provides.

Ordinary page views are unaffected and no known caller depends on this, so it is
left as-is for now.

Tests

OutputPageParserOutputTest asserts the CSS outcome on a real OutputPage:
ext.bootstrap.styles and ext.bootstrap.scripts are present in its module
lists for a skin that does not load Bootstrap, and absent for chameleon, medik
and tweeki. Asserting the resulting module list rather than the calls made to a
mock keeps the tests meaningful across a refactor of how the modules get added.

BootstrapDependentModuleTest asserts the outcome directly on the module:
getDependencies adds skins.medik.js under the medik skin and
ext.bootstrap.scripts for vector, chameleon, tweeki and monobook and when no
skin context is available, alongside any other dependencies the module declares.

AI-authored — Claude Code, Opus 4.8; agreed design with @malberts, the JavaScript half steered across many rounds; code diff not yet human-reviewed; verified in a browser (CSS across seven skins; JavaScript across vector/chameleon/medik/tweeki, including Medik's real navbar/action dropdowns against a force-loaded second Bootstrap) plus unit tests; phpcs not run locally, pending on CI.

@malberts
malberts force-pushed the fix/skip-bootstrap-for-skins-providing-it branch from 9b26c13 to 4ac37e6 Compare July 16, 2026 21:45
Extension:Bootstrap's ext.bootstrap.styles and ext.bootstrap.scripts were added
in ParserAfterParse, which is baked into the skin-agnostic parser cache. Move
them to the OutputPageParserOutput hook, where the active skin is known, and skip
them for skins that put Bootstrap on the page themselves.

Chameleon registers Extension:Bootstrap's styles under the name
zzz.ext.bootstrap.styles, which ResourceLoader cannot deduplicate against
ext.bootstrap.styles, so a Chameleon page requested the same Bootstrap CSS twice.
Medik and Tweeki bundle their own Bootstrap, so they received a second, different
build. Skins that do not load Bootstrap keep getting it from Extension:Bootstrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@malberts
malberts force-pushed the fix/skip-bootstrap-for-skins-providing-it branch from 4ac37e6 to 23f5c68 Compare July 17, 2026 09:08
@malberts
malberts force-pushed the fix/skip-bootstrap-for-skins-providing-it branch 2 times, most recently from 4f3329f to 313c456 Compare July 20, 2026 13:01
The carousel, modal, popover and tooltip fix modules each ship an init script that calls the Bootstrap JavaScript global, so they must load after Bootstrap. The static ext.bootstrap.scripts dependency guaranteed that ordering but forced Extension:Bootstrap's Bootstrap onto every skin, including skins that bundle their own. On those skins the page carried two copies of Bootstrap, and because each registers Bootstrap's data-api on the document, events fired twice.

Route the four modules through BootstrapDependentModule, whose getDependencies() follows the active skin: a skin that bundles its own Bootstrap depends on that skin's module instead of ext.bootstrap.scripts, keeping the load-order guarantee without the duplicate. The map currently swaps medik to skins.medik.js. Other skins keep ext.bootstrap.scripts; Chameleon loads it itself under the same name, so ResourceLoader deduplicates it.

Tweeki also bundles Bootstrap but exposes no JavaScript global for the init scripts to reach, so it stays on ext.bootstrap.scripts until the init scripts learn the jQuery plugin bridge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@malberts
malberts force-pushed the fix/skip-bootstrap-for-skins-providing-it branch from 313c456 to a739d8e Compare July 20, 2026 13:20
@malberts malberts closed this Jul 21, 2026
@malberts
malberts deleted the fix/skip-bootstrap-for-skins-providing-it branch July 21, 2026 15:48
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.

1 participant