fix(playground): wait for the editor, and stop the E2E gate breaking its own publish - #652
Merged
Merged
Conversation
…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
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.
This was referenced Aug 8, 2026
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.
This was referenced Aug 8, 2026
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.
This was referenced Aug 8, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 beforemountEditorhas run there is no editor registered for the host, so that call is a silent no-op.mountEditorthen 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=0well 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 everyDisabled:site to go throughCanInteract. I checked it actually bites by reintroducing the bug on one control — it fails with1 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 sameobj/. 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 thePlaygroundView.jsthat ownsmountEditor, 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-browserbefore the native publish.obj/project.assets.jsonsurvives, 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, somountEditor's documented textarea fallback never gets a chance and_editorReadyis 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/_raskfrom 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/anddocs/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 bothdotnet formatand the unit suite.That is worse than a missed format run, because the suite genuinely gates both:
Rask.Example.Shared.Testscompilessamples/Rask.Example.Sharedand owns a committed markup golden (DemoMarkup.golden.txt) — a samples-only commit could break it with nothing objecting.DocsIndexTestswalksdocs/**/*.mdon disk for reachability fromdocs/README.md, andGuidesTestsholds theGuideCatalogparity 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. Thedocs/half and the golden/parity consequences were pointed out by the session on #650, and I confirmed both against the test sources.Testing
wwwroot/_raskdeleted.dotnet format+ warnings-as-errors + full unit suite green (41 playground tests, +1 new guard).