Skip to content

Show a loading spinner while an embedded package paints - #76

Merged
erseco merged 8 commits into
mainfrom
feat/embed-loading-spinner
Jul 28, 2026
Merged

Show a loading spinner while an embedded package paints#76
erseco merged 8 commits into
mainfrom
feat/embed-loading-spinner

Conversation

@erseco

@erseco erseco commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

The block renders an iframe that has to download, parse and lay out a whole
package before anything appears. Until then the visitor sees an empty bordered
frame and cannot tell whether it is loading or broken. Large packages make the
gap long enough to look like a failure.

What this changes

A spinner overlays the frame while it loads.

  • Revealed by script, never server-side. The is-loading class that shows it
    is only ever added from JavaScript, so a visitor with JS disabled never gets an
    overlay that nothing could clear. The rendered HTML contains no is-loading.
  • Announced, not just animated. role="status" with aria-live="polite" and
    a real "Loading content…" label, so it is not a silent spinner to assistive tech.
  • Respects prefers-reduced-motion by removing the animation entirely: the
    ring stays as a static shape and the role="status" text carries the message
    on its own.
  • Cleared on the iframe's load event plus a short delay. The eXeLearning
    theme builds its navigation right after load, so clearing immediately can
    uncover a frame that is still blank.
  • A 20 s backstop covers an embed whose load never fires (blocked or failed),
    so the spinner can never sit over the frame forever.

Three implementation choices worth calling out:

One registered asset, enqueued only where it has work. Five embeds on a page
would otherwise ship five uncached copies of the same IIFE, and inline script is
the first thing a hardened Content-Security-Policy drops. The loader binds every
.exelearning-embed-loader on the page in one pass. It is registered from the
hook that enqueues the frontend stylesheet, but enqueued only when
render_block_preview() actually emits a wrapper for it to find — a page with no
eXeLearning embed ships no loader request at all.

Gated on intersection, and guarded by a flag. The iframe is loading="lazy",
so the browser owns the fetch schedule. Arming the spinner at parse time would
leave it over a frame nothing is fetching yet; arming it only once the frame is
nearly visible would leave it over a frame that already finished — the browser
starts lazy fetches well before the frame is on screen, at a viewport-distance
threshold in the low thousands of pixels. So the IntersectionObserver margin is
1500px, wide enough to cover that distance, and a settled flag makes start()
a no-op once the frame has loaded, failed, or outlived the backstop. The flag is
the invariant: it does not depend on guessing another engine's scheduling
correctly. Where IntersectionObserver is missing the spinner just starts
immediately.

The frame is asked whether it already loaded, not only listened to. The
loader runs from the footer and binds no earlier than DOMContentLoaded, so an
embed near the top of a long page — a cached one especially — can fire load
before bind() ever runs, and load is not re-emitted. The listeners are
attached first, then the frame's document is inspected: a readyState of
complete on a real (non-about:blank) document marks the frame settled
immediately. The about:blank test matters because a lazy frame whose fetch has
not started yet also reports complete, on its placeholder document — treating
that as loaded would suppress the spinner in exactly the case it exists for. A
document the script cannot read (cross-origin) falls back to the normal event
flow.

Not applied to the shortcode path. public/class-shortcodes.php renders its
own .exelearning-iframe without the loader wrapper, so [exelearning] embeds
keep their current behavior. Its poster mode defers the iframe src and hides
the frame until the visitor clicks, so it needs a different trigger than
intersection rather than the same wrapper; that is deliberately left for a
follow-up.

Accessibility limitation. role="status" announces the spinner when it
appears, but nothing announces completion — the overlay is simply hidden. A
screen-reader user hears "Loading content…" and then silence rather than a
"content ready" message. Worth a follow-up; not introduced by this PR, since
before it there was no announcement at all.

assets/js/exelearning-embed-loader.js is plain DOM — no jQuery, no WordPress
globals, no build step — so the same file is reusable by the Moodle, Omeka-S,
Nextcloud and Procomún integrations. Its only contract with the host is the
markup: a .exelearning-embed-loader wrapper around an .exelearning-iframe,
plus the stylesheet. One caveat for anyone porting it: bindAll() runs once, on
DOMContentLoaded, so a host that injects embeds later (a single-page app) has
to call it again — the exeLoaderBound guard makes that idempotent.

Tests

Five cases in ElpUploadBlockTest: the preview is wrapped in the loader with the
spinner and its ARIA attributes; the is-loading class is absent from the
server-rendered HTML (the no-JavaScript guarantee); the loader is registered but
not enqueued by the stylesheet hook; rendering a preview enqueues it; and a page
that renders no preview leaves it unenqueued.

The "not inlined" concern is covered by asserting against the loader's own
distinctive tokens rather than "the markup contains no <script>" — the opt-in
fullscreen button still prints an inline script of its own, which this PR does
not touch.

This PR also adds the plugin's first JavaScript test harness (Vitest + happy-dom,
npm run test:js) with nine cases against exelearning-embed-loader.js itself:
a frame whose document already finished is never covered; a lazy about:blank
placeholder and a mid-flight frame still are; an unreadable document falls back
to the event flow; the no-IntersectionObserver path arms immediately but still
respects an already-loaded frame; and the normal order — cover on intersection,
clear on load, and no spinner after a load that arrived first.

Verification

  • vendor/bin/phpcs --standard=.phpcs.xml.dist -q . — exit 0
  • npm run test:js — 9 tests pass
  • phpunit runs in CI (lint_and_test) on the branch head
  • make check-translations — "Translations are up to date and deterministic."
  • Checked in Chromium across the fold band that the earlier 200px margin got
    wrong: the spinner is now armed before load rather than after it, and clears
    shortly afterwards, with slow-loading controls confirming a legitimate spinner
    is still shown.

erseco added 2 commits July 28, 2026 16:15
The block renders an iframe that has to download, parse and lay out a whole
package before anything appears, so a visitor sees an empty bordered frame
for a noticeable moment and cannot tell whether it is loading or broken.
Large packages make it worse.

Cover that gap with a spinner overlaying the frame. It is revealed by the
script itself, never server-side, so a visitor without JavaScript never gets
an overlay that nothing could clear. It is announced with role="status" so
it is not a silent animation, respects prefers-reduced-motion, and clears on
the iframe's load event plus a short delay, because the theme builds its
navigation right after load and clearing immediately can uncover a frame
that is still blank. A 20 s timeout is the backstop for an embed whose load
event never fires.

The behavior ships as one enqueued asset rather than an inline <script> per
block: five embeds on a page would otherwise ship five uncached copies of
the same IIFE, and inline script is the first thing a hardened Content-
Security-Policy drops. The plugin already enqueues its frontend CSS from the
same hook.

The spinner is gated on intersection. The iframe is loading="lazy", so a
block below the fold has not begun loading yet -- starting the spinner at
parse time would leave it spinning over an idle frame until the backstop.
An IntersectionObserver with a 200px margin starts it when the browser is
about to fetch, and where that API is missing the spinner just starts
immediately.

The catalogs under languages/ carry the one new string, "Loading content…",
translated in all ten locales; CI regenerates them and fails on a stale tree.
The intersection gate was armed at rootMargin 200px on the premise that
this is roughly when the browser starts fetching a loading="lazy" frame.
It is not: Chrome's lazy-loading distance threshold is in the low
thousands of pixels. For an embed sitting in the band between the two --
past 200px, inside the browser's fetch distance -- the frame was fetched
and its load event fired while the observer had not triggered yet. The
spinner then started on scroll, dropping a white overlay over content the
visitor was already reading, and held it for the full 20 s backstop.

Two changes, because the margin alone cannot be trusted:

- A `settled` flag. Once the frame has loaded, failed, or outlived the
  backstop, start() is a no-op. The load handler sets it synchronously
  rather than after the 150 ms grace delay, so a scroll landing in that
  window cannot start the spinner either. This is the invariant; it does
  not depend on guessing another engine's scheduling.
- The margin goes to 1500px, wide enough to cover the browser's fetch
  distance so the spinner is armed before the fetch rather than after it.

The comment explaining the gate is rewritten: it argued from a premise
that was simply wrong.

Also narrow the "not inlined" assertion. Asserting the rendered block
contains no `<script` promised more than the PR delivers -- the opt-in
fullscreen button still prints an inline script, untouched here. It now
asserts against the loader's own distinctive tokens.
@github-actions

Copy link
Copy Markdown
Contributor

Test in WordPress Playground

Test the plugin with the code from this branch:

Preview in WordPress Playground

ℹ️ The eXeLearning editor is fetched from the shared release and unpacked into the plugin when the playground boots, so the first load may take a few extra seconds. ELP upload, shortcode, Gutenberg block and preview work normally.

erseco added 4 commits July 28, 2026 17:59
The loader is enqueued in the footer and binds no earlier than
DOMContentLoaded. An embed near the top of a long page -- a cached one
especially -- can finish before bind() ever runs, and `load` is not
re-emitted. settled therefore stayed false forever, and the moment the
observer fired, start() dropped the overlay over content the visitor was
already reading and held it for the full 20 s backstop. The previous round
closed this race from the load side; this closes it from the bind side.

Reproduced in headless Chromium against the real loader file, with the
sequence forced (wait for the embed to complete, then inject the loader):
the spinner appeared and never cleared. With the fix it never appears, over
three consecutive runs, while a slow embed bound before load still shows and
clears normally.

bind() now asks the frame directly. The embed is same-origin -- the REST
content proxy on this host -- and keeps allow-same-origin in its sandbox, so
its document is readable, with a try/catch for the cross-origin case where it
is not. The about:blank test is the part that matters: a lazy frame whose
fetch has NOT started also reports readyState 'complete', on its placeholder
document, and treating that as loaded would suppress the spinner in exactly
the case it exists for. Verified both ways in the harness.

Also drop the spinner animation under prefers-reduced-motion rather than
slowing it to 3s. A slower spin is still a spin; the visible label and
role="status" carry the message without it.
The scripts under assets/js/ had no unit tests at all: the only JS the CI
pipeline exercised was through Playwright, which needs a full WordPress
environment and cannot say why a handler misbehaved.

Vitest with happy-dom covers the gap. The admin scripts are plain jQuery
IIFEs loaded by wp_enqueue_script, so the harness gives them a window, a
document and a global jQuery, then drives them through the DOM the way
WordPress does -- no build step and no changes to the scripts themselves.

CI runs `npm run test:js` next to PHPCS, before wp-env starts: neither
needs a WordPress runtime, so both fail fast and cheap.
Six cases, all about when the spinner may and may not appear, because the
expensive failure is not a missing spinner but one that covers content.

The first fails against the loader as published: with a frame that already
reported a completed real document, intersection still armed the overlay.
The other five are the guards that keep the fix from overcorrecting -- a lazy
frame whose fetch has not started reports readyState 'complete' on
about:blank and must still get a spinner, as must a mid-flight frame and one
whose document cannot be read at all.

IntersectionObserver is stubbed so intersection is a decision the test makes
rather than a layout accident, and contentDocument is stubbed per case
because no test environment reproduces a real lazy frame's placeholder.
The loader was enqueued from wp_enqueue_scripts, so every frontend page of
the site fetched it -- including the overwhelming majority with no
eXeLearning block on them, where it binds nothing and exits.

It is now registered on that hook and enqueued by render_block_preview(),
at the point that emits the wrapper the loader looks for. A footer script
enqueued while the content renders still prints, and in a REST render the
handle was never registered, so the call is a harmless no-op.

The three tests around it assert the split: registered but not enqueued
after the hook alone, enqueued once a preview renders, and left alone by a
block that renders no preview. They dequeue the handle first because
WP_UnitTestCase shares one $wp_scripts across the file and the tests above
render previews -- without that they assert the previous test's leftovers,
which is how two of them failed in the class while passing in isolation.

Also covers the two remaining loader branches in the JS suite: a frame that
reports no document, and the path taken where IntersectionObserver does not
exist.

The catalogs under languages/ move by the line count this adds: only the
`#:` source references shift, no string changes.
@erseco

erseco commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Pushed four commits addressing the review.

The race was real, and reproduced. The loader is a footer script that binds no earlier than DOMContentLoaded, so an embed that finishes before bind() runs never gets its load listener attached — and load is not re-emitted. In a real-browser harness driving the actual loader file, with the sequence forced (wait for the embed to complete, then inject the loader), the overlay went up over a frame that already had content painted and never came down:

iframe before bind: {"readyState":"complete","href":".../embed?id=wrap","bodyText":"PACKAGE CONTENT"}
spinner ever shown : true
still showing at end: true
cleared after (ms) : never

Fixed by asking the frame instead of only listening. After the load and error listeners are attached, bind() checks the frame's own document state; if a real document has already completed, it is treated as settled and the spinner is never armed. The embed is same-origin — the REST content proxy on this host — and keeps allow-same-origin in its sandbox, so the document is readable, with a try/catch for the cross-origin case where it is not.

The about:blank test in that check is load-bearing, not defensive noise: a lazy frame whose fetch has not started also reports readyState: 'complete', on the placeholder document it was created with. Reading that as "loaded" would suppress the spinner in exactly the case it exists for. Confirmed in the harness — a lazy embed below the fold does report complete on about:blank, and it still gets its spinner.

Regression-tested. The branch now carries the Vitest harness (identical to the one in #75, so the two merge cleanly in either order) and nine cases for the loader. The one that matters covers the sequence above — load fires, the loader binds afterwards, intersection happens, and is-loading must not be added. It fails against the loader as it stood before this push and passes after; the other eight are the guards that stop the fix from overcorrecting: a lazy frame on about:blank, a mid-flight frame, a frame whose document cannot be read, a frame reporting no document, and the path taken where IntersectionObserver is absent.

Reduced motion now uses animation: none rather than a 3s duration. A slower spin is still a spin; the visible "Loading content…" label and role="status" carry the message without it.

The loader now ships only where it is used. It was enqueued from wp_enqueue_scripts, so every frontend page of the site fetched it, including the overwhelming majority with no eXeLearning block, where it binds nothing and exits. It is now registered on that hook and enqueued by render_block_preview(), at the point that emits the wrapper. Three tests assert the split: registered but not enqueued after the hook alone, enqueued once a preview renders, and untouched by a block that renders no preview.

Verification: phpunit 801 tests / 1717 assertions, npm run test:js 9/9, phpcs clean at 35/35 files, make check-translations deterministic.

erseco added 2 commits July 28, 2026 20:07
The Vitest suite tests the embed loader, which is plain DOM; no setup file
ever provided the global jQuery the config comment promised. Remove the
dependency and make the comment describe the harness that actually exists.
…inner

# Conflicts:
#	languages/exelearning-ca.mo
#	languages/exelearning-ca_valencia.mo
#	languages/exelearning-de_DE.mo
#	languages/exelearning-eo.mo
#	languages/exelearning-es_ES.mo
#	languages/exelearning-eu.mo
#	languages/exelearning-gl_ES.mo
#	languages/exelearning-it_IT.mo
#	languages/exelearning-pt_PT.mo
#	languages/exelearning-ro_RO.mo
@erseco
erseco merged commit 1bc5880 into main Jul 28, 2026
4 checks passed
@erseco
erseco deleted the feat/embed-loading-spinner branch July 28, 2026 23:30
@erseco erseco self-assigned this Jul 29, 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.

1 participant