Skip to content

Load Bootstrap once per skin#123

Merged
malberts merged 4 commits into
masterfrom
bootstrap-per-skin-module
Jul 21, 2026
Merged

Load Bootstrap once per skin#123
malberts merged 4 commits into
masterfrom
bootstrap-per-skin-module

Conversation

@malberts

@malberts malberts commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

For #70

Skins that bundle their own Bootstrap (for example Medik and Tweeki) also received
Extension:Bootstrap's copy, because BootstrapComponents loaded ext.bootstrap.styles and
ext.bootstrap.scripts unconditionally. Two copies of Bootstrap each registered Bootstrap's
document data-api, so one click was handled twice and the skin's navbar and action dropdowns
opened and immediately closed. Chameleon had the CSS half of the same problem: it registers
Extension:Bootstrap's stylesheet under its own module name (zzz.ext.bootstrap.styles), which
ResourceLoader cannot deduplicate against ext.bootstrap.styles, so pages carried the Bootstrap
CSS twice.

BootstrapComponents is the extension that pulls in Extension:Bootstrap, so it decides when that
copy is needed. From a BeforePageDisplay hook, where the active skin is known, it names the
modules carrying this skin's Bootstrap in a config variable: the script module to wait on
(ext.bootstrap.scripts on skins without their own, skins.medik.js on Medik, or Tweeki's
configured script module, honouring $wgTweekiSkinCustomScriptModule)
and the stylesheet module to load for dynamically rendered content, null on skins that provide
their own stylesheet. The shared
resolver module from #124 (already on master) waits on the script module with mw.loader.using,
then initialises the components on content render. On ordinary page views that module is one the
page already loads, so using() attaches to the in-flight load; on pages whose content arrives
later (live preview on an edit page) it is fetched on demand. On Tweeki, which
exposes no window.bootstrap, the components initialise through Bootstrap's jQuery plugin
bridge.

The stylesheet and the script are decided separately: Chameleon provides its own stylesheet (the
zzz clone) while consuming Extension:Bootstrap's scripts, so only the stylesheet is skipped
there; Medik and Tweeki provide both. The per-skin maps currently know Chameleon, Medik and
Tweeki; other Bootstrap-bundling skins keep today's behaviour until added, since the default
stays "load Extension:Bootstrap's copy". Whether a skin provides Bootstrap scripts is derived
from the same map that names the wait module, so those two decisions cannot drift apart. The
hook runs only where content was parsed, marked by the ext.bootstrapComponents.bootstrap.fix
styles module the parser adds on every parsed page; cache entries have carried that module since
5.2.0, so warm parser caches keep working across the upgrade.

Supersedes #122 and #113; both can be closed.

Behaviour changes

  • api.php?action=parse no longer lists ext.bootstrap.styles / ext.bootstrap.scripts; they
    load at page render, and no transitive dependency path to them remains. Page views,
    VisualEditor and live preview are unaffected.
  • Tweeki pages no longer carry window.bootstrap (it came from Extension:Bootstrap's copy).
    Bootstrap's data-api markup is unaffected; only site scripts doing programmatic Bootstrap calls
    on Tweeki wikis are affected, and none are known.
  • Component initialisation now also runs on re-rendered content (VisualEditor, live preview);
    previously only content already present when the init scripts first executed was initialised.
  • Initialisation scans the rendered content area rather than the whole document. Hand-written
    Bootstrap component markup outside it (sitenotice, skin chrome) is no longer picked up;
    BootstrapComponents' own output always renders inside the content area.
  • Pages served from a warm parser cache keep the old double load until they reparse or expire;
    the fix rolls out per page.

Verification

Unit tests cover the per-skin module resolution (including Tweeki's config-driven module), the
styles and scripts provider checks, and the hook's load and marker decisions; CI (MW 1.43 through
master) is green. Automated browser testing (Playwright) on Vector, Chameleon, Medik and Tweeki:
provider skins load a single Bootstrap (ext.bootstrap.scripts stays unloaded), Chameleon
delivers the Bootstrap stylesheet once (ext.bootstrap.styles stays unloaded, its own copy
serves the page), all eleven components were exercised behaviourally on all four skins (44
checks; modals show exactly one backdrop, accordions fire single show/shown event pairs), every
chrome dropdown on Chameleon, Medik and Tweeki opens and stays open, action=parse output was
confirmed free of the ext.bootstrap.* modules, and the page scope matches released behaviour
(chrome-only special pages stay Bootstrap-free). Live preview was driven on the edit
page: first and repeated previews initialise their components and fetch the Bootstrap stylesheet
on demand, which is what publishing both modules on every page enables (mw.loader.using for
the script module, mw.loader.load for the stylesheet).
A VisualEditor edit on Medik was verified manually: after the in-place save, the accordion,
modal and tooltip in the updated content all function, with a clean console. Each skin was additionally
re-verified as the only installed skin, with identical results.

AI-authored, Claude Code (Fable 5, with Opus 4.8 subagents); design steered by @malberts across many rounds; diff not human-reviewed; verified with unit tests, CI PHPUnit (MW 1.43 through master) and automated browser testing (Playwright) across the four skins above.

@malberts
malberts force-pushed the bootstrap-per-skin-module branch 6 times, most recently from 5750fbc to 94b805c Compare July 21, 2026 12:18
@malberts malberts changed the title Load each skin's own Bootstrap once Load Bootstrap once per skin Jul 21, 2026
@malberts
malberts force-pushed the bootstrap-per-skin-module branch 5 times, most recently from e11f936 to b7a65a6 Compare July 21, 2026 14:26
@malberts
malberts marked this pull request as ready for review July 21, 2026 14:32
malberts and others added 4 commits July 21, 2026 16:35
Skins that bundle their own Bootstrap (for example Medik and Tweeki) also
received Extension:Bootstrap's copy, because BootstrapComponents loaded
ext.bootstrap.styles and ext.bootstrap.scripts unconditionally. Two copies of
Bootstrap each registered Bootstrap's document data-api, so one click was
handled twice and the skin's navbar and action dropdowns opened and
immediately closed.

From a BeforePageDisplay hook, where the active skin is known,
BootstrapComponents now names the modules carrying this skin's Bootstrap in a
config variable: the script module to wait on (ext.bootstrap.scripts on skins
without their own, and only there is Extension:Bootstrap's copy loaded, the
skin's own module on Medik, or Tweeki's configured script module, mirroring
SkinTweeki::initPage) and the stylesheet module to load for dynamically
rendered content, null on skins that provide their own stylesheet. The shared
resolver module waits on the script module with mw.loader.using, loads the
stylesheet on demand, and runs the component init scripts on
wikipage.content, so live preview and VisualEditor re-renders are covered
too. On Tweeki, which exposes no window.bootstrap, the components initialize
through Bootstrap's jQuery plugin bridge.

Whether a skin provides Bootstrap scripts is derived from the same per-skin
map that names the wait module, so the two decisions cannot drift apart. The
hook runs only where content was parsed, marked by the styles fix module the
parser adds on every parsed page; cache entries have carried it since 5.2.0,
so warm parser caches keep working across the upgrade.

The load moved off the ParserOutput (it has to, so the decision can depend on
the skin, which the shared parser cache cannot), so api.php?action=parse no
longer lists ext.bootstrap.styles / ext.bootstrap.scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chameleon registers Extension:Bootstrap's stylesheet under its own module
name (zzz.ext.bootstrap.styles), which ResourceLoader cannot deduplicate
against ext.bootstrap.styles, so pages carried the Bootstrap CSS twice.
Chameleon provides its own stylesheet but consumes Extension:Bootstrap's
scripts, so only the stylesheet is skipped there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tweeki passes $wgTweekiSkinCustomScriptModule straight to
OutputPage::addModules, which also accepts arrays, so a truthy non-string
value works there even though nothing documents it. We mirror only the
string form; any other truthy value falls back to Extension:Bootstrap's
copy, the previously released behavior, instead of fataling on every page
view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Initialization runs on every wikipage.content fire, and new stacks a second
instance on an element initialized in an earlier pass, with the first
instance's listeners left bound. getOrCreateInstance is idempotent, and it
is also order-independent against Bootstrap's own data-api, which
instantiates carousels with data-bs-ride itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@malberts
malberts force-pushed the bootstrap-per-skin-module branch from b7a65a6 to 7c23f6d Compare July 21, 2026 14:35
@malberts
malberts merged commit 2eaf31a into master Jul 21, 2026
5 of 6 checks passed
@malberts
malberts deleted the bootstrap-per-skin-module 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