Skip to content

feat: Add explanatory loader text is during first load and handle potential loading failure modes - #240

Merged
jat255 merged 32 commits into
posit-dev:mainfrom
jat255:feat/loader-status
Aug 14, 2026
Merged

jat255 merged 32 commits into
posit-dev:mainfrom
jat255:feat/loader-status

Conversation

@jat255

@jat255 jat255 commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On a first visit to a site using shinylive, the app-loading overlay shows the pulsing hex animation with no text. Behind the scenes, a multi-megabyte Pyodide or webR engine is downloaded plus package installs, which can take tens of seconds (or longer on slow connections), so a slow first load doesn't give any feedback about if something useful is happening during that time. Generally speaking, I wanted to address "could the shinylive loader show a bit more help text on first load?"

Related: #192 (long loading times) describes the underlying slowness, which this does not attempt to fix.

What changed

Two things, primarily:

1. The overlay says what it is doing. Under the existing hex animation:

  • Downloading Python… → Starting Python… → Loading packages and starting app…
  • and the R equivalents (Downloading R…, Starting R…)

The text only appears after a 3 second delay, so a fast load (from cache) looks essentially as it did before.

A store implemented per engine in src/load-status.ts keeps track of the various loading stages.

2. Failures produce an error screen instead of just endlessly pulsing.

The following conditions were tested:

failure before after
Engine wasm missing, or host unreachable spun forever, no error error in ~0.2s, naming the URL and the status
Worker reports an init failure (e.g. pyodide-lock.json 404) discarded; surfaced later as an unrelated error the real cause, in under a second
R app fails to start (e.g. syntax error) app marked running, blank frame, error only in the terminal — and nothing at all in an embed Error starting app!, naming the line and showing the offending source line
any of the above traceback only leads with hard-refresh / clear-cache advice

Specifically, these limitations came from:

  • Engine wasm. Neither loadPyodide() nor webR's init() settles when the core wasm app cannot be instantiated; its promise is neither resolved nor rejected, so there were no error signals the page can catch. src/engine-load-guard.ts checks that at least the wasm file is fetchable before the engine starts, and fails early if not.
  • WebWorkerPyodideProxy.init() discarded the worker's error reply, so a Pyodide that never started would appear successful.
  • R app startup. .start_app (in useWebR.tsx) is evaluated with captureConditions = FALSE: with this setting an R error makes evalR resolve rather than reject, so a raised error would go to the terminal and never reach JavaScript. .start_app therefore reports its outcome by returning a value. It returns list(status = "ok"), or list(status = "error", message, class, call) on failure. An earlier revision of this PR returned "" for success and the message for failure, which made a condition carrying an empty message indistinguishable from success; the status list removes that. Viewer.tsx reads it with evalR + toJs() through the shelter it already opens and purges, rather than evalRString. The condition's class now reaches the console for diagnosis, and call is reported only when it names the author's own code — shiny wraps every app body in ..stacktraceon..(), so a top-level failure's raw call is the whole app source, and that wrapper is filtered out. Flipping captureConditions to TRUE would also have fixed the original problem, but it changes error handling for every R call rather than just app startup. Package installation stays non-fatal on purpose — see the R note under Testing.
  • R syntax errors get a line number. shiny's sourceUTF8 catches the parse error and raises only Error sourcing <file>, discarding the detail. .start_app now quickly parses the app's top-level .R files before shiny sees them, so the real parse error including line, column, offending line and caret, reaches the viewer. It runs before the VFS mount and package installs, so a typo fails in a couple of seconds instead of after work the app cannot use.
  • User-facing advice. There is deliberately no reload button: a reload triggered from JS cannot bypass the cache, so the error instead suggests the user to hard-refresh, as that is a possible solution in some cases.

One known failure-mode limitation

An engine asset that downloads but cannot be instantiated, for example a truncated file, or an HTML error page served with 200, will still cause the loading hexes to spin with no error screen. Closing it needs either byte-level validation or a timeout. I had a timeout implemented, but removed it because it was a messy catch for a rare problem and definitely overengineered

Architecture

The load-status.ts observable store controls the "status" of the engine and app load. App.tsx writes into the store via initPyodide or initWebR, and then Viewer.tsx responds to changes in the store to control what's shown to the user:

flowchart TB
    A["<b>App.tsx</b><br/>starts engine init, once per page"]
    B["<b>initPyodide / initWebR</b><br/>guard the asset, then boot the engine"]
    S["<b>load-status.ts</b><br/>one store per engine"]
    H["<b>useLoadStatus.ts</b><br/>useSyncExternalStore"]
    V["<b>Viewer.tsx</b>"]
    L["LoadingStatus.tsx<br/>stage text, after 3s"]
    E["ViewerError<br/>failure screen"]

    A --> B
    B -- "set(stage)" --> S
    S -- "subscribe" --> H
    H --> V
    V -- "loading" --> L
    V -- "failed" --> E

    classDef store fill:#fde8c8,stroke:#b8860b
    class S store
Loading

Stages are linear and one-way:
engine-download → engine-start → ready, or failed, which is terminal and causes an error to be displayed in the UI.

How a failure reaches the screen

Failures were previously just silently discarded and the loading icon would endlessly pulse. Now, error sources fan into two distinct types of errors:

flowchart LR
    G["<b>guard</b><br/>wasm 404, host unreachable,<br/>not a wasm module"]
    W["<b>worker</b><br/>pyodide init error reply<br/><i>pyodide-proxy.ts:388</i>"]
    P["<b>engine init</b><br/>load_python_pre / load_r_pre,<br/>initShiny / initRShiny throws"]
    PA["<b>Python app</b><br/>_start_app throws<br/><i>Viewer.tsx:381</i>"]
    RA["<b>R app</b><br/>.start_app returns a status list<br/><i>Viewer.tsx:331</i>"]
    C["<b>cosmetic</b><br/>terminal clear, start banner"]

    F["stage = <b>failed</b><br/><i>terminal</i>"]
    X["appRunningState<br/>= <b>errored</b>"]
    LOG(["console.error only<br/><i>app keeps running</i>"])

    E1["<b>ViewerError kind='engine'</b><br/><i>+ recovery hint</i>"]
    E2["<b>ViewerError kind='app'</b><br/><i>traceback only</i>"]

    G --> F
    W --> F
    P --> F
    PA --> X
    RA --> X
    C --> LOG
    F --> E1
    X --> E2

    classDef prior fill:#f2f2f2,stroke:#aaa,color:#666
    classDef dead fill:#eef6ee,stroke:#8ab88a,color:#456
    class PA prior
    class LOG dead
Loading

The python app failure was the only one that already reached the screen before this PR. Everything else was silent.

The engine/app split determines the advice that gets shown to the user. The hard-refresh / clear-cache hint appears only on the engine side. On the app side the engine already loaded fine, so the cache isn't the suspect and the hint would point the reader away from the traceback sitting right below it (commit 6f99001).

Example Recordings

The following screen recordings demonstrate what this looks like in a few different scenarios, both with throttling to simulate a slow connection, and on a fast connection. There are both R and Python examples here, in an "embed"/Quarto type display, as well as the full screen height version like on https://shinylive.io/py/examples/

PythonR
Full page — throttled
python-full_example-throttled.mp4
r-full_example-throttled.mp4
Full page — unthrottled
python-full_example-nonthrottled.mp4
r-full_example-nonthrottled.mp4
Embedded (Quarto) — throttled
python-quarto_embed-throttled.mp4
r-quarto_embed-throttled.mp4
Embedded (Quarto) — unthrottled
python-quarto_embed-nonthrottled.mp4
r-quarto_embed-nonthrottled.mp4

Testing

Both test layers this PR needed landed while it was open: #246 added the jest unit harness and a tsc --noEmit CI gate, and #247 moved browser testing to pytest + Playwright under tests/. This PR uses both, so the manual verification below is now automated.

Unit tests — 37, jest. src/load-status.test.ts covers the status store: subscription semantics that useSyncExternalStore depends on, per-engine isolation, and failed being terminal. src/engine-load-guard.test.ts covers the reachability and wasm-magic-number checks against a stubbed fetch, including a magic number split across chunks, a body that cannot be cancelled, and one that throws mid-read. Coverage over the modules these import is 100% on all four metrics.

Browser tests — 19, pytest + Playwright, -m loader. tests/test_loader_status.py drives every mode in the table below end to end against the built site, for both engines, in both the full-page and embedded layouts. App source travels in the URL hash, so app-level failures need no interception; engine-level ones use page.route(). Two choices worth a reviewer's eye: the staged-loading test records transitions with an in-page MutationObserver rather than polling assertions, because a delay injected in a sync route handler blocks Playwright's own connection thread; and the delay is a --loader-delay option rather than CDP throttling, so assertions are on the observed order of stages and never on elapsed time.

scripts/loader-demo.ts, the local script behind the recordings above, is deleted. tests/README.md documents the equivalent recipes — --headed --slowmo, --video on, --tracing on with playwright show-trace, and turning --loader-delay up to watch the stages by hand.

The table below was the original manual verification; every row now has a test.

mode Python R
healthy load app runs image app runs image
core wasm 404 error in 0.2s, names URL + status image same image
connection dropped for the core wasm error in 0.2s, reported as unreachable image same image
syntax error in app source Error starting app! + traceback image Error starting app! + app.R:7:1: unexpected symbol with the offending line and caret (new — previously silent in an embed) image
unresolvable dependency Error starting app! + micropip's ValueError image warnings only; the app genuinely runs — see below image

On the R dependency case. R has no requirements.txt; dependencies are inferred from the source by renv::dependencies(), and a missing one is tolerated. library() is shimmed by webR (environmentName reports webr) and returns normally instead of erroring, so the app really does start. That appears to be upstream's intended behaviour, which may also be why .start_app does not treat a failed install as fatal — renv::dependencies() reports packages that are never used (a library() call inside if (FALSE) is reported, and that app runs fine), so failing on it could break working apps.

CI placement. The loader tests carry both the site and loader markers, and the Make targets split on that: make test-site is -m "site and not loader", make test-loader is -m loader. Between them they cover every site test, so running one is not running them all.

make test-site stays where it was, in build.yml's build job, so a failure in the editor, URL-loading or static-export tests still stops the deploy that follows. The loader tests move to a new test-loader.yml, for the same reason test-apps.yml exists: they are slow — nearly every one boots a real Pyodide or webR instance, so the suite costs about as much as all the other site tests together — and they should not sit in front of an S3 deploy. Being a separate workflow also gets them a cancel-in-progress concurrency group, which build.yml cannot have without risking a cancelled deploy.

They therefore do not gate anything yet, which is deliberate and worth revisiting once they have some green history. CI has already earned that caution: the first run of this branch failed on an intermittent console-error assertion that passes locally and passed on the next run, which is exactly what gating would have turned into a blocked deploy. That bug is fixed — the test was collecting console errors itself after opting out of fail_on_page_errors, but had reimplemented the collection without the fixture's forgiveness for the tag-manager requests block_analytics aborts, which only exist where GOOGLE_TAG_MANAGER_ID is set. The predicate now lives in one place and both callers share it.

Moving the gate later means either folding the marker back into make test-site, or triggering the deploy from test-loader.yml's success via workflow_run.

Verification. make type-check clean; npm run test:unit 328/328; npm run test:unit:coverage 100% on all four metrics; venv/bin/pytest tests -m site 34/34.

@jat255 jat255 changed the title Explain what the loader is doing on a slow first load Add explanatory loader text is during slow first load Jul 29, 2026
@jat255
jat255 marked this pull request as ready for review July 29, 2026 19:31
@jat255

jat255 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Local Claude review found a few issues worth fixing, and a couple "low" findings I think weren't worth the effort:

Fixed

  • Python failing partway through startup hung the page forever (f6a7ec8) — if the code that reports such errors crashed while composing the message, nothing was ever sent back and the loader span indefinitely. It now reports the error instead. With the failure forced, the old build sat on "Downloading Python…" with no error; the new one reports it in one.
  • A corrupted or substituted engine download wasn't detected (64651ed) — the check only looked at the HTTP status, but a proxy sign-in page or a truncated file could also comes back as "200 OK". The bad file was then handed to the engine, which hangs rather than complaining. It now also confirms the response really is a WebAssembly file by checking the first few magic bytes of the file and cancelling the rest of the download. This was a bit of a nitpick from Claude, but probably worth fixing.
  • Cache-clearing advice appeared for ordinary app errors (6f99001) — if a user's app had a typo or syntax error, the hard-refresh hint was shown which isn't actually useful in that case. The hard refresh advice now shows only when the engine itself fails to load.
  • A cosmetic startup step could kill a working app (da9365a) — tidying the terminal and printing the startup banner were covered by the same error handling as engine startup, so a failure in either permanently replaced a running app with an error screen. Only steps that determine whether the engine actually works can do that now.

Not addressed — two minor points in the R engine code: a comment describing a failed package install as non-fatal when it actually stops startup (the comment is wrong, the behaviour is unchanged from main), and the syntax pre-check only looks at R files in the top-level folder.

@jat255
jat255 requested a review from cpsievert August 6, 2026 16:44
@jat255 jat255 changed the title Add explanatory loader text is during slow first load feat: Add explanatory loader text is during first load and handle potential loading failure modes Aug 6, 2026

@cpsievert cpsievert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a few things I noticed from just a quick sniff test. Also, I think the scope is large enough here that I would feel more comfortable shipping this if we had decent test coverage. Is that something you've looked into already?

Comment thread src/hooks/usePyodide.tsx Outdated
Comment thread src/hooks/useWebR.tsx
Comment thread src/pyodide-worker.ts Outdated
@jat255

jat255 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Is that something you've looked into already?

We can (and I think we should) add some unit test coverage. I've been running a suite of tests locally (not included in the PR) for this set of changes in particular, but since the repo wasn't already setup for running unit tests, I didn't want to blow up the scope as part of this PR. Happy to do that either here or in a separate PR (probably more appropriate).

@cpsievert

Copy link
Copy Markdown
Contributor

Would it make sense to add to the ./playwright tests?

@jat255

jat255 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@cpsievert after talking with @schloerke and @karangattu, I'm going to make a separate PR to setup the unit test infrastructure with some baselines so this PR will have something to run against once getting rebased. I'll set it back to draft for now.

@jat255
jat255 marked this pull request as draft August 7, 2026 16:49
jat255 added a commit that referenced this pull request Aug 12, 2026
`playwright/` held four specs that nothing ran: `make test` called an npm
script that does not exist, and two of the four shelled out to `shinylive
export` from the Python shinylive package -- which depends on this
repository, the circular dependency that kept the playwright job in
build.yml commented out for years.

All four are now pytest tests in `tests/`, alongside the example suite,
and they run in CI.

Groundwork, which #240 also needs:

- `fail_on_example_errors` becomes `fail_on_page_errors`, and takes an
  opt-out: a test that provokes an error on purpose marks itself
  `allow_page_errors` and asserts on the failure instead.
- Every test now carries an `examples` or a `site` marker. The example
  targets select on `examples`, so the site tests are not swept into
  their engine x shard matrix, and `make site-test` runs them in one job.

The export fixture, `tests/export_app.py`: an export is `app.json`,
`export_template/index.html` with its variables filled in, `edit/index.html`
as-is, and the shinylive bundle -- all of which `make all` already produces
under `build/`. Nothing here needs `pip install shinylive` any more.

The migrated tests:

- `test_site_editor.py` and `test_site_url_loading.py` from
  examples-viewer.spec.ts and load-from-url.spec.ts, against the
  `_shinylive/` build the session fixture already serves.
- `test_site_export.py` from shiny-static.spec.ts and editor-cell.spec.ts,
  against the export fixture. The app mode comes from the
  `?_shinylive-mode=` query param, replacing a `sed` in playwright.config.ts
  that had not matched anything in `export_template/index.html` in a long
  time.

Two fixes the migration turned up: load-from-url's "app view can show
header bar" test navigated to `/editor/`, so it never tested the app view;
and Mod-Enter in the editor is Cmd on a Mac, which the old spec worked
around by pressing both modifiers.

Removals: `playwright.config.ts`, the four specs and their helpers, the
`cypress:open` script (cypress is not a dependency), the `@playwright/test`
devDependency, the broken `test` target, and the commented-out playwright
job in build.yml with its now-obsolete rationale.
jat255 added a commit that referenced this pull request Aug 12, 2026
`playwright/` held four specs that nothing ran: `make test` called an npm
script that does not exist, and two of the four shelled out to `shinylive
export` from the Python shinylive package -- which depends on this
repository, the circular dependency that kept the playwright job in
build.yml commented out for years.

All four are now pytest tests in `tests/`, alongside the example suite,
and they run in CI.

Groundwork, which #240 also needs:

- `fail_on_example_errors` becomes `fail_on_page_errors`, and takes an
  opt-out: a test that provokes an error on purpose marks itself
  `allow_page_errors` and asserts on the failure instead.
- Every test now carries an `examples` or a `site` marker. The example
  targets select on `examples`, so the site tests are not swept into
  their engine x shard matrix, and `make site-test` runs them in one job.

The export fixture, `tests/export_app.py`: an export is `app.json`,
`export_template/index.html` with its variables filled in, `edit/index.html`
as-is, and the shinylive bundle -- all of which `make all` already produces
under `build/`. Nothing here needs `pip install shinylive` any more.

The migrated tests:

- `test_site_editor.py` and `test_site_url_loading.py` from
  examples-viewer.spec.ts and load-from-url.spec.ts, against the
  `_shinylive/` build the session fixture already serves.
- `test_site_export.py` from shiny-static.spec.ts and editor-cell.spec.ts,
  against the export fixture. The app mode comes from the
  `?_shinylive-mode=` query param, replacing a `sed` in playwright.config.ts
  that had not matched anything in `export_template/index.html` in a long
  time.

Two fixes the migration turned up: load-from-url's "app view can show
header bar" test navigated to `/editor/`, so it never tested the app view;
and Mod-Enter in the editor is Cmd on a Mac, which the old spec worked
around by pressing both modifiers.

Removals: `playwright.config.ts`, the four specs and their helpers, the
`cypress:open` script (cypress is not a dependency), the `@playwright/test`
devDependency, the broken `test` target, and the commented-out playwright
job in build.yml with its now-obsolete rationale.
jat255 added a commit that referenced this pull request Aug 12, 2026
* test: Migrate the orphaned playwright specs to pytest (#243)

`playwright/` held four specs that nothing ran: `make test` called an npm
script that does not exist, and two of the four shelled out to `shinylive
export` from the Python shinylive package -- which depends on this
repository, the circular dependency that kept the playwright job in
build.yml commented out for years.

All four are now pytest tests in `tests/`, alongside the example suite,
and they run in CI.

Groundwork, which #240 also needs:

- `fail_on_example_errors` becomes `fail_on_page_errors`, and takes an
  opt-out: a test that provokes an error on purpose marks itself
  `allow_page_errors` and asserts on the failure instead.
- Every test now carries an `examples` or a `site` marker. The example
  targets select on `examples`, so the site tests are not swept into
  their engine x shard matrix, and `make site-test` runs them in one job.

The export fixture, `tests/export_app.py`: an export is `app.json`,
`export_template/index.html` with its variables filled in, `edit/index.html`
as-is, and the shinylive bundle -- all of which `make all` already produces
under `build/`. Nothing here needs `pip install shinylive` any more.

The migrated tests:

- `test_site_editor.py` and `test_site_url_loading.py` from
  examples-viewer.spec.ts and load-from-url.spec.ts, against the
  `_shinylive/` build the session fixture already serves.
- `test_site_export.py` from shiny-static.spec.ts and editor-cell.spec.ts,
  against the export fixture. The app mode comes from the
  `?_shinylive-mode=` query param, replacing a `sed` in playwright.config.ts
  that had not matched anything in `export_template/index.html` in a long
  time.

Two fixes the migration turned up: load-from-url's "app view can show
header bar" test navigated to `/editor/`, so it never tested the app view;
and Mod-Enter in the editor is Cmd on a Mac, which the old spec worked
around by pressing both modifiers.

Removals: `playwright.config.ts`, the four specs and their helpers, the
`cypress:open` script (cypress is not a dependency), the `@playwright/test`
devDependency, the broken `test` target, and the commented-out playwright
job in build.yml with its now-obsolete rationale.

* test: harden the site-test groundwork from the review

Four follow-ups to the playwright migration, none of them changing what the
migrated tests assert.

The console-error gate would have failed on CI. build.yml is the only workflow
that sets GOOGLE_TAG_MANAGER_ID, so `make all` there templates a Google Tag
Manager loader into the site's pages (scripts/build.ts) -- and the site tests
are the only suite that runs against that build. A tag manager that fails to
load reaches the console as an error, exactly the way the missing favicon the
gate already forgives does, so the suite that now gates the deploy would have
depended on an outside host being reachable. `block_analytics` aborts those
requests, which also keeps the async loader from holding up the page's load
event, and the gate forgives the abort.

A test with neither the `examples` nor the `site` marker runs in no CI job.
`pytest.ini` says every test carries one, but nothing made that true; collection
now refuses a test that has neither rather than let it silently never run.

`--shard` was dealing out the whole of `tests/`, not the selected engine. A
conftest's `pytest_collection_modifyitems` runs before the one in `_pytest.mark`
-- which is what lets `-m py` see the engine markers added there -- so the
sharding at the end of it was splitting all 91 tests and only then dropping the
other engine's. The shards come out the same size today by coincidence; moving
the sharding into a `trylast` plugin makes that hold whatever else lands in
tests/.

Also: `make help` never listed `test-deps`, because the `##` line has to sit
directly above the target, and README now tells people to run it.

* docs: Reconcile the README with the jest harness

The rebase left both PRs' test sections side by side, and the surviving one
still pointed at `make examples-test-deps`, the `playwright/` specs and the
`make test` target, none of which exist after this branch. One section now,
covering unit tests, examples and site tests, with the split by marker
described where the app tests are introduced.

* docs: Cut the history lesson from the export comments

Four places retold how the playwright job came to be commented out. The
comments only need to say why the tests build their own export: depending on
the Python shinylive package would be circular. One of them had also gone
stale, since this branch removes that commented-out job.

* chore: Give the test targets a common `test-` prefix

Review asked for prefix matching, so `make test-<TAB>` completes the whole
family rather than three separate stems: test-unit, test-unit-coverage,
test-deps, test-examples-smoke, test-examples-intent, test-site.

The workflow job ids and artifact names follow their targets. Renaming a job
id renames its status check, which is safe here -- main has no branch
protection, no rulesets, and no required checks -- and nothing downloads the
artifacts. deploy_gh_pages goes with them, as the last underscore left in
build.yml.

type-check and examples-check-index keep their names: they are checks rather
than test suites, and type-check mirrors the npm script it runs.

Also adds type-check and the two unit-test targets to the README's `make help`
listing, which had not been updated for them.
jat255 added 13 commits August 12, 2026 13:10
The app-loading overlay showed a bare animation with no text. On a first visit
the browser is downloading a multi-megabyte Pyodide or webR runtime plus
packages, which can take tens of seconds, and nothing said so — a slow load was
indistinguishable from a broken app.

Under the animation, the overlay now names the current stage: "Downloading
Python…", "Starting Python…", then "Loading packages and starting app…" (or the
R equivalents). The text appears only after a delay, so a fast load looks exactly
as it did before.

A small per-engine store carries the stage from the engine init functions to the
overlay. It is module-level because initialization begins before any component
mounts, and per-engine because a page can mix Python and R blocks and run both
at once.

Also: if the engine fails to load, the overlay now shows an error instead of
animating forever.
When loadPyodide() itself threw, the catch block tested `e instanceof
pyodide.ffi.PythonError` while `pyodide` was still undefined. That raised a
second error from inside the catch, so the reply was never posted and the
caller's promise never settled — leaving the loading overlay up indefinitely.

Guard the check so the original error is reported.
…nothing

Four failure paths were either silent or unbounded.

A missing engine wasm hung the loader forever. Neither loadPyodide() nor webR's
init() settles in that case — not resolved, not rejected — and neither leaves an
error the page can catch. engine-load-guard.ts now checks that the core wasm can
be fetched before the engine starts, reporting a 404 or an unreachable host in
about 0.2s and naming the URL. Reading only the status line costs roughly one
round trip rather than the tens of seconds the file itself takes. An asset that
downloads but cannot be instantiated, such as a truncated one, still spins with
no error screen; that gap is left open deliberately.

WebWorkerPyodideProxy.init() discarded the worker's error reply, so a Pyodide
that never started looked like one that did. A 404 on pyodide-lock.json now
surfaces in under a second rather than being swallowed.

R app startup errors never reached JavaScript. .start_app is evaluated with
captureConditions = FALSE, so a syntax error left the viewer displaying an app
that had not started: a blank frame, the error only in the terminal, and
nothing at all in a viewer-only embed. It now returns the message instead, and
the caller uses evalRString so webR marshals the string before the shelter
holding it is purged. Package installation stays non-fatal, because
renv::dependencies() also reports packages that are never used and those apps
run fine today.

Errors offered no advice. The alert now leads with a hard refresh and clearing
cached data, ahead of the traceback. There is deliberately no reload button: a
reload triggered from JS cannot bypass the cache, so it would do the one thing
that has already failed.

The alert is also laid out in one fixed-width column, so its position no longer
depends on how long the traceback happens to be.
shiny's sourceUTF8 catches the parse error with try() and then raises a bare
"Error sourcing <file>", discarding the line, the offending source line and the
caret. In the full editor that detail still reached the terminal, but a
viewer-only embed has no terminal, so a typo gave the reader nothing but a file
path.

.start_app now parses the app's .R files before shiny sees them, so the real
parse error travels the return-status path added in the previous commit:

  /home/web_user/app_.../app.R:7:1: unexpected symbol
  6:
  7: server
     ^

It runs before the VFS mount and the package installs, so a typo fails in about
two seconds rather than after work the app cannot use.

Non-recursive on purpose. Recursing would also cover shiny's auto-sourced R/
directory, but it would risk failing an app over a stray .R file that shiny never
sources; a syntax error below the top level still falls back to the old message,
which is no worse than before.

Runtime errors during sourcing were never affected: parse() succeeds for those,
so their own message already propagated.
medium finding from PR Claude code review

setupPythonEnv() runs once loadPyodide() has resolved, so an error from it
reached the worker's catch with pyodide defined but pyUtils still undefined.
Formatting the message via pyUtils threw a TypeError out of the handler, so
the reply was never posted and postMessageAsync — which has no reject path
and no timeout — waited forever. That is the hang 7ad6bd8 removed, one step
later in init.

Guard on pyUtils as well, and reset pyodideStatus when init throws so a
later init starts over rather than reporting success with a pyodide that
was never built.
Code review flagged that the reachability check treats any HTTP 200 as a
usable engine. A response that arrives but cannot be instantiated is handed
to loadPyodide() / webR.init(), neither of which settles, so the loader spins
with no error and no way out — the failure the check exists to prevent. A
captive portal's sign-in page, a single-page app's index.html fallback and a
truncated file all reach it as 200.

Read the first four bytes and require the wasm magic number. The transfer is
cancelled as soon as they arrive, so this still costs a round trip rather
than the 10-18 MB file. Anything unreadable — no body, no getReader, a
failure mid-read — returns no opinion rather than reporting a load failure,
since this runs on the path that decides whether loading may proceed and
must not become a new way for it to stop.

A valid module that is the wrong build, such as a stale cached one, has the
right magic number and still hangs. Closing that needs a load timeout and is
left open.
Code review on the loader-status work flagged that RecoveryHint rendered
for app-level errors as well as engine errors: a typo in the user's own
app produced a prominent panel telling them to hard-refresh and clear
their cached site data. The engine had already loaded in that case, so
the cache is not the suspect, and the advice points away from the
traceback sitting right below it.

Pull the failure screen out into ViewerError, which takes the failure
class as a prop, and gate the hint on the engine case. The headline was
already conditional; the hint just wasn't.
Code review flagged that the try/catch around engine init also spanned
initShiny, terminalInterface.clear() and the showStartBanner print, so a
throw from any of them was recorded as stage "failed". That state is
terminal in the store and outranks every other viewer state, so a cosmetic
step failing on an otherwise healthy engine permanently replaced a working
app with "Error loading Python!". Latent only because showStartBanner is
hardcoded false.

Split on readiness rather than on the init call alone. initShiny and
initRShiny stay inside the catch — initRShiny runs library(shiny), and an
engine that cannot load shiny is genuinely unusable, so moving it out would
trade a wrong error screen for a silent hang when the rejection is swallowed
by the caller. The terminal clear and the banner print move out, into a
catch that only logs.

Verified with a browser against both structures, forcing showStartBanner on
and making the banner raise: before, the error screen; after, the app runs
with the error logged.
_files() and sabotage() fell through to WORKING_APP / a no-op for any
mode string that didn't match a known case, so a typo in a later
positive-assertion test would exercise nothing and pass for the wrong
reason. Guard both against MODES and add a test pinning the failure.
jat255 added 13 commits August 12, 2026 14:18
Adds test_slow_load_announces_its_stages, parametrized over engine, asserting
the loader announces Downloading -> Starting -> ready in order and that the
text stays hidden for the first ~3s (STATUS_DELAY_MS).

A first pass followed the plan's illustrative sequential expect() calls and
failed on every run: sabotage()'s route delay blocks this whole process's
Playwright connection while it sleeps, the engine hits that sabotaged route
twice before it's up, and a fast local engine can finish booting inside that
blocked window -- so by the time a live expect() got a chance to poll, the
text it wanted was already gone for good. Switched to a MutationObserver
installed via page.add_init_script() before either delay can fire, which
timestamps stage changes in the browser (immune to how blocked this process
gets) for the test to read back once the load completes.
The pre-3s check compared two recorded timestamps (elapsed time between the
loader mounting and the first stage text appearing), which is a wall-clock
duration assertion -- exactly what the spec for this test forbids, and it
would go red on a slow CI runner for reasons unrelated to the behaviour under
test. Replaced with an absence check: the loader's first "mounted" log entry
must show no stage text yet, which states what STATUS_DELAY_MS actually
guarantees without comparing timestamps, and still can't pass vacuously
because next() raises on an empty log.
Adds an embed_page fixture (no built page ships a .shinylive-python/
.shinylive-r block, so none was reusable) that writes a minimal page next to
_shinylive/<engine>/shinylive-sw.js, and two representative failure modes
(app-syntax, engine-load) across both engines -- the failure detection is
identical to the full-page layout, so only the error screen's presentation in
a small block is worth re-checking.
…disk writes

The app-syntax assertions were engine-agnostic (headline, no recovery hint,
non-empty log), so they could not have caught a .shinylive-r block silently
booting Pyodide -- the R fixture's source also fails to compile as Python.
Assert the engine-specific filename the error log actually names instead
(app.R vs app.py), verified by simulating the mixed-engine case directly.

Also replace the embed_page fixture's write into _shinylive/<engine>/ (live
build output, non-cleaning overlay copy) with page.route() fulfilling a
virtual URL under the same origin, so a crash mid-test can no longer leave a
stray file to ride into a later build or deploy.
scripts/loader-demo.ts was a 1073-line local-only script for watching and
recording the loader's stages and failure modes by hand; tasks 4-10 replaced
its scenarios with 19 automated tests in tests/test_loader_status.py. Delete
the script and its .loader-demo/ scratch directory, and document the
Playwright-native equivalents (--headed/--slowmo, --video, --tracing) in
tests/README.md so the capability isn't lost with the script.
Seven comment/one-token fixes from the final whole-branch review: treat an
empty R error message as absent in Viewer's message fallback, rename the
app-syntax fixture's second file so .start_app's parse guard actually
iterates over it, replace dead scripts/loader-demo.ts citations with the
real requirements they were standing in for, drop a false claim about R's
own error printing, correct a docstring that overclaimed no browser is used,
rewrite a test rationale comment to match what the store actually
guarantees, and fence three bare command blocks in tests/README.md.
@jat255
jat255 force-pushed the feat/loader-status branch from da9365a to 1016582 Compare August 13, 2026 02:24
@jat255

jat255 commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor Author

@cpsievert this should be ready for re-review.

The PR is now covered using the infrastructure setup in #246 and #247.

Added 37 jest unit tests covering the pure logic: src/load-status.test.ts for the status store (including the subscription semantics useSyncExternalStore depends on, per-engine isolation, failed being terminal) and src/engine-load-guard.test.ts for the reachability and wasm-magic-number checks, including a magic number split across chunks, a body that cannot be cancelled, and one that throws mid-read. 100% on all four coverage metrics over the modules they import (this PR didn't get the coverage comment because it's from my fork -- a vestige from before I had write access):

Coverage output (100%)

npm run test:unit:coverage

shinylive@0.10.14 test:unit:coverage
node testing-helpers/coverage-note.js

PASS src/Components/codeMirror/language-server/diagnostics.test.ts
PASS src/parse-codeblock.test.ts
PASS src/messageportwebsocket.test.ts
PASS src/Components/ResizableGrid/DragToResizeHelpers.test.ts
PASS src/r-status.test.ts
PASS src/engine-load-guard.test.ts
PASS src/load-status.test.ts
PASS src/Components/share.test.ts
PASS src/fileio.test.ts
PASS src/utils.test.ts
PASS src/examples.test.ts
PASS src/Components/codeMirror/utils.test.ts
PASS src/Components/codeMirror/language-server/positions.test.ts
PASS src/postable-error.test.ts
PASS src/Components/gist.test.ts
PASS src/Components/utils.test.ts
PASS src/Components/filecontent.test.ts
PASS src/awaitable-queue.test.ts
PASS src/Components/codeMirror/language-server/regexp-util.test.ts
PASS src/Components/codeMirror/language-server/names.test.ts
-------------------------------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------------------------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
src | 100 | 100 | 100 | 100 |
awaitable-queue.ts | 100 | 100 | 100 | 100 |
engine-load-guard.ts | 100 | 100 | 100 | 100 |
examples.ts | 100 | 100 | 100 | 100 |
fileio.ts | 100 | 100 | 100 | 100 |
load-status.ts | 100 | 100 | 100 | 100 |
messageportwebsocket.ts | 100 | 100 | 100 | 100 |
parse-codeblock.ts | 100 | 100 | 100 | 100 |
postable-error.ts | 100 | 100 | 100 | 100 |
r-status.ts | 100 | 100 | 100 | 100 |
utils.ts | 100 | 100 | 100 | 100 |
src/Components | 100 | 100 | 100 | 100 |
filecontent.ts | 100 | 100 | 100 | 100 |
gist.ts | 100 | 100 | 100 | 100 |
share.ts | 100 | 100 | 100 | 100 |
utils.ts | 100 | 100 | 100 | 100 |
src/Components/ResizableGrid | 100 | 100 | 100 | 100 |
DragToResizeHelpers.ts | 100 | 100 | 100 | 100 |
src/Components/codeMirror | 100 | 100 | 100 | 100 |
utils.ts | 100 | 100 | 100 | 100 |
src/Components/codeMirror/language-server | 100 | 100 | 100 | 100 |
diagnostics.ts | 100 | 100 | 100 | 100 |
names.ts | 100 | 100 | 100 | 100 |
positions.ts | 100 | 100 | 100 | 100 |
regexp-util.ts | 100 | 100 | 100 | 100 |
-------------------------------------------|---------|----------|---------|---------|-------------------

Test Suites: 20 passed, 20 total
Tests: 342 passed, 342 total
Snapshots: 0 total
Time: 3.394 s
Ran all test suites.

Added 19 pytest + Playwright tests based off how I was manually testing using the demo script (tests/test_loader_status.py, -m loader) driving the failure modes end to end against the built site. These cover engine asset 404, connection dropped for that asset, a post-boot setupPythonEnv() failure, app syntax errors in both engines, an unresolvable Python requirement (fatal) versus an unresolvable R library (non-fatal by design, app still runs), the R condition's message and call reaching the dialog, the staged loading text on a slow load, and the embedded-block error screen. Exercises both Python and R engines, in both full screen and embed layouts.

One thing to flag: The new tests don't gate the deploy. They run in their own workflow, test-loader.yml, following the pattern of test-apps.yml. The other half of the site marker (the old tests re-enabled in #247) stays in build.yml and still stops the deploy. Moving the gate later is either folding the marker back into make test-site, or a workflow_run trigger on this workflow's success.

For reference,tests/README.md documents how to watch or record a scenario through Playwright.

jat255 added 6 commits August 12, 2026 21:00
They cost about as much as every other site test together, and they have no
CI history yet, so they run alongside the build job rather than inside it.
That means they do not gate the deploy; the comment on the job records what
would have to change to make them.

make test-site and make test-loader are now halves of a pair, splitting on
the loader marker.
test_r_unresolvable_library_still_runs opts out of fail_on_page_errors and
collects console errors itself, but reimplemented the collection without the
fixture's forgiveness, so the aborted tag-manager requests failed it.

They only appear where GOOGLE_TAG_MANAGER_ID is set, which build.yml does and
a local run does not, so this was invisible until CI ran it. The predicate now
lives in shinylive_app.py and both callers share it.
Same reasoning test-apps.yml gives for the example tests: slow browser tests
should not sit in front of a deploy. The other half of the site marker stays in
build.yml, where a failure still stops the deploy.

Also gets the tests a cancel-in-progress concurrency group, which build.yml
cannot have without risking a cancelled deploy.
The status-list change added four defensive branches that neither suite could
reach: jest could not, because the function was unexported inside a component
file, and pytest could not, because .start_app always returns a well-formed
list. They are the branches a webR upgrade would break -- Viewer.tsx reads
status through the same function, so a shape change makes a successful app
start report itself as a failure.

Moving it to its own module is enough to reach them. No behaviour change; the
R browser tests are the regression check.
Trims rationale that restated the code, or that justified a decision by
contrasting it with an alternative the reader cannot see -- the CSS centring
notes, the workflow headers, and .start_app's return shape among them.

Corrects two comments that had gone stale rather than merely long:
load-status.test.ts cited a "Fix 1" that exists in no document, and .start_app's
described a "spurious ready transition" that the current code cannot produce.

Also adds a one-line purpose comment to useLoadStatus.ts, moves Callable,
Iterator and Mapping to collections.abc in the two test modules, and formats
r-status.test.ts with prettier. No behaviour change.
@jat255
jat255 marked this pull request as ready for review August 13, 2026 05:43
@jat255
jat255 requested a review from cpsievert August 13, 2026 05:43

@cpsievert cpsievert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work, thanks!

@jat255
jat255 merged commit 9721c2c into posit-dev:main Aug 14, 2026
11 checks passed
@jat255
jat255 deleted the feat/loader-status branch August 14, 2026 18:28
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