Skip to content

fix(playground): wait for the editor, and stop the E2E gate breaking its own publish - #652

Merged
pal-tamas merged 5 commits into
mainfrom
worktree-playground-editor-ready-gate
Aug 8, 2026
Merged

fix(playground): wait for the editor, and stop the E2E gate breaking its own publish#652
pal-tamas merged 5 commits into
mainfrom
worktree-playground-editor-ready-gate

Conversation

@pal-tamas

@pal-tamas pal-tamas commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Two fixes, both found while verifying the deployed playground after #643. Closes #647.

1. Picking a chapter before the editor mounts silently kept the starter code

Run and Reset were gated on the editor having mounted. The controls that load code — the Tutorial/Examples tabs, the chapter list, the gallery list, the brief's prev/next — were not; they only guarded against a compile being in flight.

Loading a chapter is a round-trip to setEditorValue, and before mountEditor has run there is no editor registered for the host, so that call is a silent no-op. mountEditor then completes and installs the starter over the selection that never landed. The reader is left with the brief and the chapter highlight showing one chapter while the editor holds another — and Run compiles the starter, then ticks the chapter off as done because it succeeded.

Not theoretical: on the live Pages bundle the editor takes seconds to mount (a boot probe shows models=0 well after first paint), so a first-time reader clicking straight into Tutorial lands inside the window. It is how I first mis-read the live deploy as broken.

Fix: one shared CanInteract => !_busy && _editorReady, used by all seven controls. The bug was two copies of that condition disagreeing, so the fix is to stop having copies.

The regression guard asserts the sharing rather than the wording: it fails if any control gates on a bare Disabled: _busy, and requires every Disabled: site to go through CanInteract. I checked it actually bites by reintroducing the bug on one control — it fails with 1 control(s) still gate only on _busy, naming the race.

The browser E2E cannot catch this as written: its first action is page.ClickAsync(".pg-run"), and Playwright's actionability check waits for that button to be enabled — supplying exactly the readiness wait the selection controls were missing. Any E2E covering it would have to interact with the pane before touching Run, which is inherently racy. The unit-level guard is the right level, and is where #647 recommended it.

2. The E2E gate was broken by my own change in #643

More serious, and currently on main.

#643 made the gate publish the playground with the native relink while the preceding solution build compiles it with -p:WasmBuildNative=false — through the same obj/. My comment there claimed this didn't reintroduce the drift the file warns about, because "the fixture serves this publish output, which is internally consistent". That reasoning was wrong.

The casualty is the scoped-asset bake. Staged under obj/Release/net10.0-browser/rask-scoped/_rask/a, it is left over from the no-native build and never reaches the publish: wwwroot/_rask/ is simply absent, the page 404s on the PlaygroundView.js that owns mountEditor, the editor never mounts, and every journey dies waiting on a permanently disabled Run button.

The failure mode is the nasty part — it reads as a hang, names no cause, and reproduces only depending on which mode wrote obj/ last, so it looks like flakiness. A single clean run passes, which is exactly what hid it from me: I ran the gate manually, saw 57 green, and the hook then failed on the very next run.

Fix: clear only obj/Release/net10.0-browser before the native publish. obj/project.assets.json survives, so the re-restore the publish already does stays incremental.

3. A missing editor module now says so instead of looking hung

Mounting the editor is an interop call into the scoped PlaygroundView.js. If that module never loaded, the call never settles — which is not the same as failing, so mountEditor's documented textarea fallback never gets a chance and _editorReady is never set. Every control then sits disabled forever with nothing said, which is what makes §2 present as "the playground is broken" and sends you to debug Roslyn or Monaco.

The mount now has a deadline (60s — generous, so a slow connection fetching Monaco can't trip it) and on expiry reports the module as missing and flips the IDE badge to unavailable. Controls stay disabled, because without the module the editor's contents can't be read either; the change is that the page names the cause.

Verified by deleting wwwroot/_rask from a real publish and serving it: the phase line reads "The editor module (PlaygroundView.js) did not load — try reloading the page." Credit to the session that filed #650 for pointing out that a missing module isn't a mount failure — that framing is what made this fixable.

4. The pre-commit gate now covers samples/ and docs/

Its change filter was ^(src/|tests/|benchmarks/|Rask\.slnx$|Directory\.). So a commit touching only samples or only docs printed "no code changes staged" and skipped both dotnet format and the unit suite.

That is worse than a missed format run, because the suite genuinely gates both:

  • Rask.Example.Shared.Tests compiles samples/Rask.Example.Shared and owns a committed markup golden (DemoMarkup.golden.txt) — a samples-only commit could break it with nothing objecting.
  • DocsIndexTests walks docs/**/*.md on disk for reachability from docs/README.md, and GuidesTests holds the GuideCatalog parity guard in both directions — a page committed on its own skipped exactly the checks written to catch it.

Noticed when §3 — a real change to the playground's C# — skipped the gate. The playground tutorial in #643 was largely a samples/ + docs/ change, so most of it went through ungated; it stayed clean only because I ran the gate by hand each time, which a contributor has no reason to do. The docs/ half and the golden/parity consequences were pointed out by the session on #650, and I confirmed both against the test sources.

Testing

  • Full E2E gate run twice back to back: 57 passed, then 57 passed. One run is not evidence here — that is the trap this fixes.
  • Missing-module diagnostic verified against a real publish with wwwroot/_rask deleted.
  • dotnet format + warnings-as-errors + full unit suite green (41 playground tests, +1 new guard).

…xample

Run and Reset were gated on the editor having mounted; the controls that LOAD
code were not -- they only guarded against a compile being in flight. Loading a
chapter is a round-trip to setEditorValue, and before mountEditor has run there
is no editor registered for the host, so the call is a silent no-op and
mountEditor then installs the starter over the selection.

The reader was left with the brief and the chapter highlight showing one chapter
while the editor held another, and Run compiled the wrong code and ticked the
chapter off as done. On a cold load that window is seconds wide, which is exactly
when a first-time reader clicks Tutorial -- it is how I first mis-read the live
deploy as broken.

Every control now shares one condition, because the bug was two copies of it
disagreeing. The regression test asserts the sharing rather than the wording: a
new control written with a bare 'Disabled: _busy' fails the fast gate with a
message naming the race.

The browser E2E cannot catch this as written -- its first action is clicking
.pg-run, and Playwright's actionability wait supplies exactly the readiness wait
the selection controls were missing.

Closes #647
…publish

The E2E gate builds the whole solution with -p:WasmBuildNative=false and then
publishes the playground WITH the native relink, which #643 introduced and whose
comment claimed was safe because the fixture serves the publish output. It is
not: that makes the playground the one project built two ways into one obj/,
exactly what the note further up the file warns about.

The casualty is the scoped-asset bake. Staged under
obj/Release/net10.0-browser/rask-scoped/_rask/a, it is left over from the
no-native build and never reaches the publish: wwwroot/_rask/ is absent, the page
404s on the PlaygroundView.js that owns mountEditor, the editor never mounts, and
every journey then dies waiting on a permanently disabled Run button.

It reads as a hang, names no cause, and reproduces only depending on which mode
wrote obj/ last -- so it looks like flakiness. A single clean run passes, which is
what hid it; the gate has to be run twice in a row to see it.

Clearing only obj/Release/net10.0-browser keeps obj/project.assets.json, so the
re-restore the publish already does stays incremental. Verified by running the
full gate twice back to back: 57 passed, then 57 passed.
… a disabled button

Mounting the editor is an interop call into the scoped PlaygroundView.js. If that
module never loaded, the call never SETTLES -- which is not the same as failing,
so mountEditor's textarea fallback never gets a chance and _editorReady is never
set. Every control then sits disabled forever with nothing said.

That is not hypothetical: a build that bakes no scoped assets produces exactly
this bundle, and it reads as 'the playground is broken', sending you to debug
Roslyn or Monaco rather than the build (#650, and the previous commit here).

The mount now has a deadline -- 60s, generous enough that a slow connection
fetching Monaco cannot trip it -- and on expiry says the module did not load and
flips the IDE badge to unavailable. Controls stay disabled, because without the
module the editor's contents cannot be read either; the change is that the page
now names the cause.

Verified against a bundle with wwwroot/_rask deleted: the phase line reads 'The
editor module (PlaygroundView.js) did not load' within the deadline.
The hook's change filter listed src/, tests/, benchmarks/ and the build files, so
a sample-only commit printed 'no code changes staged' and skipped both dotnet
format and the unit suite -- despite samples being compiled, analyzer-checked,
warnings-as-errors code with their own test projects.

Noticed when the previous commit here, a real change to the playground's C#,
skipped the gate. The playground tutorial feature was largely a samples/ change,
so most of it went through ungated; it only stayed clean because the gate was run
by hand each time, which a contributor has no reason to do.
Extends the previous commit. docs/ was missing from the filter for the same
reason samples/ was, and with the same consequence: DocsIndexTests walks
docs/**/*.md on disk for reachability from docs/README.md, and GuidesTests holds
the GuideCatalog parity guard in both directions, so a page committed on its own
skipped exactly the checks written to catch it.

The samples/ case is likewise worse than a missed format run:
Rask.Example.Shared.Tests compiles samples/Rask.Example.Shared and owns
DemoMarkup.golden.txt, so a samples-only commit could break a committed golden
with nothing objecting until someone else pushed.

The comment now states the reason for each rather than listing directories,
since the next person to add one needs the principle, not the list.
@pal-tamas
pal-tamas merged commit 2aaee7a into main Aug 8, 2026
10 checks passed
@pal-tamas
pal-tamas deleted the worktree-playground-editor-ready-gate branch August 8, 2026 08:31
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…lish

#652 found that a project built two ways into one obj/ can publish without its scoped-asset bake:
the solution build runs with -p:WasmBuildNative=false, the publish runs without it, and the staged
obj/Release/net10.0-browser/rask-scoped/_rask/a left over from the first mode does not make it into
the second. wwwroot/_rask/ is simply absent, the page 404s, and it reproduces only on some runs —
whichever mode wrote obj/ last.

samples/Rask.Example.Wasm.Jobs is the same shape: the slnx build compiles it no-native, then this
script publishes it WITH the relink, because SQLite is a native library and skipping it yields a
bundle that boots and then fails on every database call.

It cannot bite today — that sample has no scoped .css/.js, so there is no bake to lose, which is
precisely why this is worth doing before someone adds one and spends an afternoon on a 404 that names
nothing. Same fix as the playground, and clearing only the TFM directory keeps project.assets.json so
--no-restore below stays valid.

--no-restore is kept, unlike the playground's line: nothing here gates a PackageReference on the
build mode, so the package graph is identical in both.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…ver re-runs

Follow-up to #652, which cleared only obj/Release/net10.0-browser. With the no-native build left in
bin/Release/net10.0-browser the publish treats the compile as up to date and never re-runs the
scoped-asset bake -- and the staged copy under obj/ has just been deleted, so nothing reaches
publish/wwwroot/_rask at all. Clearing obj alone is worse than clearing neither.

Measured on one worktree: obj only -> 0 files under publish/wwwroot/_rask; obj + bin -> 6.

This is why the gate still failed after #652 merged, with the same symptom it was meant to fix: a
permanently disabled Run button and a 30s click timeout that names nothing. It also cannot be caught by
running the gate twice back to back -- both runs clear obj and leave the same stale bin, so both are
wrong in the same way.

Refs #650.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…with no cloud SDK (#653)

* feat(objectstore): add Rask.ObjectStore, an S3 and Azure Blob client with no cloud SDK

The AWS and Azure SDKs are large, reflection-heavy, and not usable from a browser, which rules
them out for the place this is most needed: a WASM app talking to a bucket with no backend in
between. Signing SigV4 is a few dozen lines of HMAC and an Azure SAS needs no signing at all,
so the client does both itself and runs unchanged server-side and in the browser.

One IObjectStore covers S3, Cloudflare R2, Google Cloud Storage (via its S3 interop keys),
MinIO, Backblaze B2, DigitalOcean Spaces and Azure Blob. It is standalone -- Microsoft.Extensions.*
only, no Rask.Core -- so Rask.SQLite.Snapshots, which today has only a DirectorySnapshotStore,
can be given a cloud store built on it.

- Ranged reads, streamed writes. Object storage charges per byte moved, so GetRangeAsync asks
  for a range rather than an object, and PutAsync(key, Stream, length) uploads without
  buffering, keeping object size and memory use unrelated.
- A missing object returns null; a range past the end returns a short read. Those stay
  distinguishable on purpose: anything walking an append-only log has to tell "gone" from
  "nothing new yet", and collapsing them is how a sync client silently decides its peers
  vanished.
- TryCreateAsync is mutual exclusion without a lock service -- an atomic compare-and-create
  (If-None-Match: *) that S3, Azure Blob and GCS all support. Preferred over an Azure blob
  lease, which exists on one provider, needs renewal, and strands the resource if the holder
  disappears.
- Credentials are asked for per request, so an expiring STS session or SAS refreshes without
  rebuilding the store. InMemoryObjectStoreCredentials, the browser case, holds one for the
  life of the process and offers no persistence option: a credential that survives a reload is
  one any later script injection can read back, so getting there has to be deliberate.
- Clock skew is handled rather than assumed away. SigV4 rejects a request more than 15 minutes
  off the service's clock and device clocks are genuinely wrong; the service's own Date is read
  from the rejected response and later requests sign against corrected time.

On verifying the signer: the expected Authorization headers in the tests come from a separate
implementation of the algorithm written from the AWS specification, not from recording this
code's own output. That reference self-checks against a published vector first, and the check
failed on its first run -- a misremembered constant rather than a wrong algorithm -- so the
derivation and every encoding rule were then confirmed against the specification directly. The
remaining tests assert the rules it names individually: %20 rather than +, no double-encoding,
slashes preserved in a key, query parameters sorted after encoding.

Range and If-None-Match are signed although only host and x-amz-* are required. They say which
bytes are read and whether an existing object may be overwritten, so unsigned they would let
anything in the middle change the meaning of the request; they are safe to sign because this
client sets them itself, unlike hop-by-hop headers.

One limitation, found by a test that failed: System.Uri normalises %2F back to a real separator
while parsing, so a key whose name contains an encoded slash cannot be addressed. Such keys are
legal in S3 and unreachable here. Documented and pinned.

Item 2 of #642.

* fix(e2e): clear the playground's bin too, or the scoped-asset bake never re-runs

Follow-up to #652, which cleared only obj/Release/net10.0-browser. With the no-native build left in
bin/Release/net10.0-browser the publish treats the compile as up to date and never re-runs the
scoped-asset bake -- and the staged copy under obj/ has just been deleted, so nothing reaches
publish/wwwroot/_rask at all. Clearing obj alone is worse than clearing neither.

Measured on one worktree: obj only -> 0 files under publish/wwwroot/_rask; obj + bin -> 6.

This is why the gate still failed after #652 merged, with the same symptom it was meant to fix: a
permanently disabled Run button and a 30s click timeout that names nothing. It also cannot be caught by
running the gate twice back to back -- both runs clear obj and leave the same stale bin, so both are
wrong in the same way.

Refs #650.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…lish

#652 found that a project built two ways into one obj/ can publish without its scoped-asset bake:
the solution build runs with -p:WasmBuildNative=false, the publish runs without it, and the staged
obj/Release/net10.0-browser/rask-scoped/_rask/a left over from the first mode does not make it into
the second. wwwroot/_rask/ is simply absent, the page 404s, and it reproduces only on some runs —
whichever mode wrote obj/ last.

samples/Rask.Example.Wasm.Jobs is the same shape: the slnx build compiles it no-native, then this
script publishes it WITH the relink, because SQLite is a native library and skipping it yields a
bundle that boots and then fails on every database call.

It cannot bite today — that sample has no scoped .css/.js, so there is no bake to lose, which is
precisely why this is worth doing before someone adds one and spends an afternoon on a 404 that names
nothing. Same fix as the playground, and clearing only the TFM directory keeps project.assets.json so
--no-restore below stays valid.

--no-restore is kept, unlike the playground's line: nothing here gates a PackageReference on the
build mode, so the package graph is identical in both.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…ax (#648)

Background jobs now run in a browser WASM app with the server's syntax. Rask.Jobs itself needed zero
changes: JobQueue<TContext> and JobProcessor<TContext> were already EF-generic, so the work was
entirely in the host.

- feat(wasm): run registered hosted services in the browser host. AddHostedService always compiled on
  WASM but nothing ever started it, so a BackgroundService registered fine, resolved fine, and
  silently never ran. Started at the end of boot; drained from pagehide, skipping bfcache suspends.
- feat(core): store raw bytes in IndexedDB. Base64 on the wire, a real Uint8Array in storage.
- feat(sqlite): Rask.SQLite.Browser — restore from IndexedDB in StartAsync, snapshot back via the
  Online Backup API, one owner tab via a Web Lock.
- feat(samples): the browser-jobs sample plus E2E coverage.
- fix(cli): rask generate job printed server-only next steps inside a WASM app (#646).
- test(e2e): cover the pagehide drain and its bfcache guard.
- feat(sqlite): BrowserSqliteOwnership, so a second tab can say why its data is missing instead of
  showing an empty page.
- build(e2e): clear the sample's obj before its native publish, the same hazard #652 fixed.

Verified in a real headless browser: job queued, claimed with its lease, handler writes a row, the
page repaints out of band from the poll loop, snapshot to IndexedDB, reload, the data survives.

Three constraints documented rather than hidden: EF Core does not survive the trimmer in a browser
build (PublishTrimmed=false; raw Microsoft.Data.Sqlite does); the durability window is the snapshot
interval, not the page-hide flush, because the browser does not wait for that handler; and one tab
owns the database, with promotion and write-proxying left unimplemented.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…tive publish

Reported and measured on another machine: clearing only the intermediates still
produced a publish with zero scoped assets, and adding bin/Release/net10.0-browser
to the clean fixed it. The reported mechanism is that a stale no-native bin lets
the publish treat the compile as up to date, so the bake never re-runs and the
just-deleted staging is never rebuilt.

That mechanism is NOT confirmed, and the comment says so: the same sequence
reproduces cleanly here in three variations, including the full solution build,
each time re-running the bake and shipping 6 files. The deciding variable is still
unknown.

Taking the superset clean anyway: the cost is one recompile of a project whose
native relink dominates this step regardless, and the failure it guards against is
silent and has now cost several sessions. The publish-time verification added in
this branch turns the same failure into a build error either way, so this is belt
as well as braces.

Also worth recording: dotnet publish does not clean its output directory, so an
earlier good publish can leave _rask in place and make a broken publish look fine.
That confound probably invalidated the twice-back-to-back verification in #652.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…ipped

The bake stages scoped CSS/JS into obj/.../rask-scoped and registers them as
computed static web assets in the build pass, trusting them to flow into the
publish manifest. When that link breaks -- most reliably by building one project
in both WasmBuildNative modes through a single obj/ -- the published bundle has no
/_rask/a/ at all and nothing says so. The app builds, publishes, boots and
renders; only its scoped CSS/JS is missing, every scoped URL 404s, and for an app
whose scoped JS owns something load-bearing it presents as a hung page. That cost
two people a debugging session each (#650, #652).

The publish now compares what was staged against what shipped. Both halves are
load-bearing: checking only the published side would fail every project that
legitimately has no scoped assets, and checking only staging would never fire.
The error names the cause and the fix.

BakeScopedAssetsTask.FailOnEmpty could not have caught this, and despite its own
documentation claiming it is 'wired to true on the dotnet run hook', nothing has
ever set it -- the single call site leaves it at its default. It fires only when
the bake RUNS and writes zero, whereas here the bake runs fine and the break is
downstream. Its docs now say both things.

Verified against a real publish in all three states: staged-but-not-published
errors with the message, neither passes, both passes.

Closes #650
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…tive publish

Reported and measured on another machine: clearing only the intermediates still
produced a publish with zero scoped assets, and adding bin/Release/net10.0-browser
to the clean fixed it. The reported mechanism is that a stale no-native bin lets
the publish treat the compile as up to date, so the bake never re-runs and the
just-deleted staging is never rebuilt.

That mechanism is NOT confirmed, and the comment says so: the same sequence
reproduces cleanly here in three variations, including the full solution build,
each time re-running the bake and shipping 6 files. The deciding variable is still
unknown.

Taking the superset clean anyway: the cost is one recompile of a project whose
native relink dominates this step regardless, and the failure it guards against is
silent and has now cost several sessions. The publish-time verification added in
this branch turns the same failure into a build error either way, so this is belt
as well as braces.

Also worth recording: dotnet publish does not clean its output directory, so an
earlier good publish can leave _rask in place and make a broken publish look fine.
That confound probably invalidated the twice-back-to-back verification in #652.
pal-tamas added a commit that referenced this pull request Aug 10, 2026
…oped-asset bake

MSBuild keeps its worker nodes alive between invocations, and the scoped-asset bake is not safe across
them: it loads bundle assemblies with Assembly.LoadFrom, so a node that already loaded one of that
simple name from ANOTHER project's output throws FileLoadException, the assembly is skipped, and the
bake produces an empty bundle (#650). Since the guard added in #652 that is a build ERROR rather than
a silently 404-ing app -- which is the right trade, but it means publishing two different WASM samples
in a row breaks the second one, and an ordinary solution build after a publish can break too.

A repository-level Directory.Build.rsp passes -nodeReuse:false, so every build from the repo root
starts with fresh workers and the collision cannot arise. Verified against the deterministic
reproduction rather than by repeated green runs, which have misled three times on this bug: warm the
pool, then publish two DIFFERENT wasm bundles. Before, the guard fires on every publish and
Rask.Example.Site bakes 0 files despite having a real App.js; after, no guard fires and Site bakes its
1 file. A parallel `dotnet build Rask.slnx -warnaserror` now also succeeds without -m:1.

Measured cost, so the trade is stated rather than assumed: a no-op incremental build of Rask.Core goes
0.68s to 0.89s (average of 3, cold pool each side), since every invocation now pays worker startup.
About a fifth of a second to not have the build fail.

This is a MITIGATION, not the fix, and only for people working in this repository -- a consumer
building their own app never reads this response file. Recorded on #650 along with what was ruled out:
Assembly.Load(byte[]) and a bare AssemblyLoadContext both LOOK like they fix it -- the byte-loading
version passes the MSBuild reproduction 3/3 -- and neither is correct. AppDomain.AssemblyResolve is
last-chance, so the default context binds a freshly-loaded assembly's dependencies to an ALREADY-LOADED
matching assembly before the hook is ever asked. Rask.Example.Shared's RefreshAll then writes into the
pre-loaded Rask.Core's registry while the task reads the new one: split registry, zero assets, the
exact symptom being fixed. tests/Rask.Wasm.Tasks.Tests catches it immediately (Expected 2 / Actual 0)
because that process has the assemblies loaded normally, where the MSBuild reproduction endorses it.
A real fix has to intercept DEPENDENCY resolution, which needs Rask.Wasm.Tasks multi-targeted off
netstandard2.0 -- the TFM that exists so full-framework MSBuild in Visual Studio can load the task.

docs/development-workflow.md explains why the response file is there and the trap that a reused node
pins the TASK assembly too, so an edit to Rask.Wasm.Tasks is invisible until the build server is shut
down -- which is how the byte-loading attempt first appeared to change nothing.
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.

playground: picking a chapter before the editor mounts silently keeps the starter code

1 participant