Skip to content

[2.x] fix: add xslt polyfill so forum continues to boot on browsers without XSLT#4359

Merged
imorland merged 12 commits into2.xfrom
im/xslt-polyfill
May 8, 2026
Merged

[2.x] fix: add xslt polyfill so forum continues to boot on browsers without XSLT#4359
imorland merged 12 commits into2.xfrom
im/xslt-polyfill

Conversation

@imorland
Copy link
Copy Markdown
Member

@imorland imorland commented Feb 9, 2026

Summary

Adds an XSLT polyfill for browsers that have removed native XSLT support, so Flarum continues to work end-to-end on those browsers.

Chrome disabled XSLT by default in the Beta channel from version 145 (Dec 2025) and is removing it from Stable in version 158 (Nov 2026). Firefox has signalled it will follow. Without a polyfill, forum.js fails to initialize in those browsers — s9e/TextFormatter references XSLTProcessor during parser init, throws a ReferenceError, and takes the entire app boot down with it. Concretely this means:

  • The forum doesn't render at all (blank page or broken layout)
  • The Mithril store never finishes registering types — every extension that pushes a payload (tags, links, ip_info, fof-terms-policies, etc.) throws in the console
  • Composer live preview never gets a chance to run
  • Extensions don't initialize

This is a hard breakage, not a degraded UX, and applies to all users on the affected browser versions — not just those who open the composer.

How it works

A small inline detector (~200 bytes) is emitted in the document <head> before the JS asset tags. It tries new XSLTProcessor(); on success it returns immediately and the document parser proceeds normally. On failure (browser has removed XSLT) it uses document.write to synchronously insert a <script src="…/xslt-polyfill.min.js"> tag — a long-supported mechanism for blocking polyfill bootstrap during HTML parsing. The browser parser blocks until the polyfill loads and executes (installing window.XSLTProcessor), then continues to parse and execute forum.js with XSLT available.

This timing matters: s9e/TextFormatter calls new XSLTProcessor at module top-level when its bundle parses, so async polyfill loading (e.g. via document.createElement('script') + appendChild) loses the race. The document.write pattern is the documented usage in the xslt-polyfill README.

The polyfill URL is resolved against the flarum-assets disk, so it's correct on installs that serve assets locally and on those backed by a remote bucket / CDN. The polyfill (xslt-polyfill ^1.0.21) is published into the assets disk during assets:publish, alongside its lazily-fetched dist/xslt-wasm.js companion. Browsers with working native XSLT never download either file — only the inline detector.

Why this approach (vs. inlining)

The polyfill is large (~510 KB gzipped). Inlining it into forum.js for every visitor — including the majority on browsers with working XSLT — would roughly triple the JS payload on every page load. Conditional document.write insertion restricts the cost to affected browsers and keeps the artifact cacheable as a separate file.

Operator note: CORS for remote flarum-assets

Operators serving the flarum-assets disk from a different origin (S3, CDN, etc.) must allow CORS from their forum origin to the assets origin. The polyfill's bootstrap loads via <script src> (not CORS-checked), but its WASM sibling (dist/xslt-wasm.js) is fetched via fetch() from inside the polyfill, which is subject to CORS. Without the right CORS headers on the assets bucket, the WASM fetch fails and the polyfill silently doesn't install. Same requirement that already applies to web fonts and any other cross-origin asset; just worth being explicit since this PR adds a new file that uses fetch.

Temporary measure

The polyfill is a stop-gap until s9e/TextFormatter ships an XSLT-free implementation: s9e/TextFormatter#250. Once that lands, the detector and asset publishing can be removed.

Test plan

  • Unit tests on XsltPolyfill helper — findSource() locates the vendored bundle, version() reads the semver from package.json
  • Integration test on Document head injection — emits the detector script, references XSLTProcessor, uses document.write, escapes the closing </script>
  • Integration test on assets:publish — both polyfill files copied with correct relative layout
  • Full core test suite passes (665 integration, 296 unit)
  • Manual: Chrome 148 Beta with XSLT disabled — forum.js boots, store types register, extensions initialize, live preview renders

@imorland imorland force-pushed the im/xslt-polyfill branch from e931e79 to be49432 Compare May 8, 2026 11:42
imorland and others added 2 commits May 8, 2026 13:52
Replaces the inline polyfill injection with a small (~250 byte) detector
that loads the polyfill via a <script src> tag only when the browser has
no working XSLTProcessor. Browsers with working native XSLT pay nothing
beyond the detector.

The polyfill (^1.0.21) is published into public/assets/xslt-polyfill/
during assets:publish. Both xslt-polyfill.min.js and the lazily-fetched
dist/xslt-wasm.js companion are copied, preserving the relative layout
the polyfill's currentScript-based wasm loader needs.

The polyfill is a temporary measure until s9e/TextFormatter ships an
XSLT-free implementation (see s9e/TextFormatter#250).
@imorland imorland changed the title [2.x] chore: add xslt polyfill [2.x] feat: add xslt polyfill so forum continues to boot on browsers without XSLT May 8, 2026
@imorland imorland changed the title [2.x] feat: add xslt polyfill so forum continues to boot on browsers without XSLT [2.x] fix: add xslt polyfill so forum continues to boot on browsers without XSLT May 8, 2026
@imorland imorland added this to the 2.0.0-rc.2 milestone May 8, 2026
imorland and others added 8 commits May 8, 2026 14:08
The polyfill is published to a stable path (assets/xslt-polyfill/...),
so without a cache-busting parameter, browsers would keep using the
previously cached version after an upgrade until heuristic revalidation
kicked in. Append ?v=<package-version> read from the polyfill's
package.json so a Flarum upgrade that bumps the polyfill version
invalidates the browser cache immediately.

Extract the source-locating + version-reading logic into Flarum\Formatter\XsltPolyfill
so AssetsPublishCommand and FormatterServiceProvider share one
implementation.
Backend CI runs PHP tests without yarn install, so node_modules is empty
there. Operator installs of flarum/core get a Composer tarball that also
doesn't include node_modules. So locating the polyfill via node_modules
worked locally but not in CI or production.

Vendor the polyfill into framework/core/js/dist/xslt-polyfill/ — the same
directory that already ships forum.js, admin.js, etc. The split into
flarum/flarum-core mirrors js/dist/ wholesale, so the polyfill files
travel through with no extra plumbing.

Add a copy-xslt-polyfill yarn script chained after webpack build, so
bumps to the npm dep version flow into the vendored dist on the next
yarn build (which the build bot runs on every push).

XsltPolyfill::findSource() now looks at one path, relative to its own
__DIR__, so it works in both the monorepo (framework/core/js/dist/...)
and the split layout (js/dist/... at the flarum-core repo root).
…watch

The previous approach resolved UrlGenerator inside the flarum.formatter
singleton closure. That caused the URL collection to be touched the first
time a formatter was used, which broke unrelated tests in flarum-mentions
that interleave formatter usage with route registration.

Move the URL wiring to FormatterServiceProvider::boot() — by then all
extender-added routes are registered. Add a setXsltPolyfillUrl() setter
on Formatter so the URL can be applied to the already-constructed
singleton without changing its constructor contract.

Also update .bundlewatch.config.json to scope the per-chunk size cap to
the actual webpack-emitted directories (admin, common, forum) instead of
the unbounded `*/**/*.js` glob, so the vendored polyfill (~500KB gzipped)
isn't checked against the 30KB cap intended for code-split chunks.
…stalls

UrlGenerator->path() always builds a forum-relative URL. On installs
that relocate the flarum-assets disk to S3 / a CDN, the polyfill files
end up at the cloud URL but the script tag pointed at the forum URL,
404'ing.

Use the disk's own ->url() — same pattern MailServiceProvider uses for
the email logo.
Resolving the polyfill URL during boot() was eagerly resolving the
flarum-assets disk on the FilesystemManager. That memoised the disk
before tests (FilesystemTest::disk_uses_custom_adapter_*) could swap
the adapter, which broke them.

Switch from a string URL to a closure resolver. The Formatter now
holds a resolver and calls it lazily on the first getJs() invocation,
caching the result. Boot is back to just registering the resolver
function — no disk resolution, no URL generation, no surprises during
app startup.

Side effect: configurations with no public-URL adapter (e.g. the
NullFilesystemDriver / InMemoryFilesystemAdapter used in tests) now
silently skip the polyfill loader instead of throwing during boot.
The polyfill must install window.XSLTProcessor *before* forum.js runs
— s9e calls `new XSLTProcessor` at module top level, so any race that
lets the s9e bundle execute before the polyfill is installed throws
a ReferenceError and kills app boot. Earlier attempts in this PR
prepended a detector to the formatter JS that was bundled into
forum.js, but that puts the detector in the same script execution
window as the s9e top-level code — too late.

Move the polyfill loading to a document.write-driven inline script
in <head>, emitted by Frontend\Document::makeHead() before the JS
asset tags. document.write of a <script src> tag during HTML parsing
inserts it at the parser position, which the parser then loads and
executes synchronously before continuing — guaranteeing the polyfill
is installed before forum.js. This is the pattern documented in the
xslt-polyfill README's HTML usage example.

Browsers with native XSLT pay only the cost of the inline detector
(~200 bytes); affected browsers fetch the polyfill itself. The
Formatter is back to returning just the s9e bundle from getJs() —
no detector, no polyfill URL injection, no resolver.

Add XsltPolyfill::publicUrl() helper that encapsulates the
flarum-assets disk lookup + version cache-bust, returning null when
the disk has no public URL (in-memory test disks, etc.). Both
AssetsPublishCommand-the-publishing-side (via findSource) and
Document-the-emission-side (via publicUrl) live in the same helper.
@imorland imorland marked this pull request as ready for review May 8, 2026 15:29
@imorland imorland requested a review from a team as a code owner May 8, 2026 15:29
@imorland imorland added type/bug prio/high javascript Pull requests that update Javascript code dependencies Pull requests that update a dependency file labels May 8, 2026
@imorland imorland merged commit 34e0d17 into 2.x May 8, 2026
25 checks passed
@imorland imorland deleted the im/xslt-polyfill branch May 8, 2026 15:31
imorland added a commit that referenced this pull request May 8, 2026
… XSLT (#4644)

Backports the 2.x polyfill (#4359) for the 1.8 line. Chrome disabled XSLT
in Beta from 145 (Dec 2025) and Stable from 158 (Nov 2026); without the
polyfill, s9e/TextFormatter's `new XSLTProcessor` at parser-init time
throws a ReferenceError that prevents forum.js from booting on those
browsers.

A ~200-byte detector is emitted into the document <head>. It tries to
construct an XSLTProcessor; on failure it document.write()s a script
tag pointing at the polyfill bundle. document.write of a script tag
during HTML parsing blocks the parser until the polyfill loads, so
window.XSLTProcessor is in place before forum.js runs.

The polyfill is published into the flarum-assets disk by assets:publish
and resolved through the disk's own ->url(), so installs that serve
assets from a remote bucket / CDN get the right URL.

To stay patch-release-safe, the FilesystemFactory is resolved via the
container inside makeXsltPolyfillLoader() rather than added to the
Document constructor — same pattern Document already uses for
TitleDriverInterface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file javascript Pull requests that update Javascript code prio/high type/bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants