Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,19 @@ if [ "${RASK_SKIP_UNIT:-}" = "1" ]; then
exit 0
fi

if ! git diff --cached --name-only | grep -qE '^(src/|tests/|benchmarks/|Rask\.slnx$|Directory\.)'; then
# samples/ and docs/ are in here for the same reason src/ is: the suite genuinely gates them.
#
# samples/ is compiled, analyzer-checked, warnings-as-errors code with its own test projects. More than
# that, 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.
#
# docs/ is an input to the tests, not just prose: DocsIndexTests walks docs/**/*.md on disk for
# reachability from docs/README.md, and GuidesTests holds the GuideCatalog parity guard in both
# directions. A new page committed on its own skipped exactly the checks written to catch it.
#
# Leaving these out meant a whole feature could land ungated — the playground tutorial was largely a
# samples/ + docs/ change, and reported "no code changes staged".
if ! git diff --cached --name-only | grep -qE '^(src/|samples/|docs/|tests/|benchmarks/|Rask\.slnx$|Directory\.)'; then
echo "pre-commit: no code changes staged — skipping the format + unit gate."
exit 0
fi
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ them until tagged releases begin.

## [Unreleased]

### Fixed
- **Playground: picking a chapter or an example before the editor had mounted silently kept the starter
code.** Run and Reset waited for the editor; the controls that *load* code did not — they only guarded
against a compile being in flight. Loading a chapter is a round-trip to `setEditorValue`, and before the
editor exists that call is a no-op, so the editor then came up holding the starter instead. 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 the window is seconds
wide, which is exactly when a first-time reader clicks "Tutorial". Every control now shares one gate
(`CanInteract`), since the bug was two copies of the condition disagreeing. Closes #647.
- **Playground: a bundle whose scoped assets are missing now says so, instead of looking hung.** Mounting
the editor is an interop call into `PlaygroundView.js`; if that module never loaded, the call never
*settles* — which is not the same as failing, and the textarea fallback never gets a chance. Every
control then sat disabled forever with no explanation, which reads as "the playground is broken" and
sends you to debug Roslyn or Monaco rather than the build that dropped the assets. The mount now has a
deadline (generous, so a slow connection fetching Monaco can't trip it) and reports the module as
missing. See #650 for the build-side glitch that produces such a bundle.
- **The pre-commit gate now covers `samples/` and `docs/`.** Its change filter listed `src/`, `tests/`,
`benchmarks/` and the build files, so a commit touching only samples or only docs reported "no code
changes staged" and skipped both formatting and the unit suite. That is not merely a missed format run:
`Rask.Example.Shared.Tests` compiles `samples/Rask.Example.Shared` and owns a committed markup golden,
and `DocsIndexTests` / `GuidesTests` read `docs/**/*.md` off disk for reachability and catalog parity —
so a samples-only or docs-only commit could break a golden or a docs invariant with nothing objecting
until somebody else's push. An entire feature could land ungated; the playground tutorial was largely a
`samples/` + `docs/` change.

### Added
- **`Mount` — give a component you built yourself the lifecycle it was missing.** A component normally
enters the tree through its generated factory, and that factory is what registers the instance with its
Expand Down
61 changes: 48 additions & 13 deletions samples/Rask.Example.Playground/PlaygroundView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ public sealed class PlaygroundView : Component
// Progress of the background reference download that powers the IDE features (diagnostics + IntelliSense).
private IdeState _ide = IdeState.Loading;

// Set once the editor (Monaco, or the textarea fallback) has mounted. Run stays disabled until then so
// a click can't read an empty editor and compile nothing — and Playwright's actionability wait means
// the E2E naturally waits for the editor without a bespoke sleep.
// Set once the editor (Monaco, or the textarea fallback) has mounted. Every control is disabled until
// then (see CanInteract) so a click can't read an empty editor and compile nothing, or push code into
// an editor that doesn't exist yet — and Playwright's actionability wait means the E2E naturally waits
// for the editor without a bespoke sleep.
private bool _editorReady;

// Bumps every compile so the preview subtree (and its ErrorBoundary) is keyed fresh — a new run mounts
Expand Down Expand Up @@ -109,9 +110,29 @@ protected override async Task OnRenderedAsync(bool firstRender)

// The editor host div now exists in the DOM; create the Monaco editor inside it. mountEditor never
// throws (it falls back to a textarea), so reaching here means the editor is usable.
await _js.InvokeVoidAsync("Rask.PlaygroundView.mountEditor", _editorHost, PlaygroundSamples.Starter);
_editorReady = true;
_phase = "Press Run to compile.";
//
// The timeout covers the case that promise never SETTLES, which is different from failing: if the
// scoped PlaygroundView.js module didn't load at all, there is nothing to resolve the invoke, so
// without a deadline _editorReady stays false forever and every control sits disabled with no
// explanation. That has happened for real — a build that silently baked no scoped assets — and it
// reads as "the playground is broken", sending you to debug Roslyn or Monaco rather than the build
// (#650). Generous, because a slow connection fetching Monaco must not trip it.
try
{
await _js.InvokeVoidAsync(
"Rask.PlaygroundView.mountEditor", TimeSpan.FromSeconds(60), _editorHost,
PlaygroundSamples.Starter);
_editorReady = true;
_phase = "Press Run to compile.";
}
catch (Exception ex) when (ex is TaskCanceledException or JSException or JSDisconnectedException)
{
// Everything stays disabled — without the module we cannot even read the editor's contents —
// but now it says why instead of looking like a hung page.
_phase = "The editor module (PlaygroundView.js) did not load — try reloading the page.";
_ide = IdeState.Unavailable;
return;
}

// Kick off the (multi-MB) reference download in the background so IntelliSense + live diagnostics come
// alive a few seconds after load — without blocking first paint or the first Run. Fire-and-forget:
Expand Down Expand Up @@ -168,9 +189,9 @@ protected override void OnUnmount()
// Reset / Run — the same Bs* button language as the docs; the pg-run class stays a hook
// for the Ctrl/Cmd+Enter shortcut (PlaygroundView.js) and the E2E.
BsButton(Class: "pg-reset", Color: BsColor.Secondary, Outline: true, Size: BsSize.Sm,
Disabled: _busy || !_editorReady, OnClickAsync: ResetAsync)["Reset"],
Disabled: !CanInteract, OnClickAsync: ResetAsync)["Reset"],
BsButton(Class: "pg-run", Color: BsColor.Primary, Size: BsSize.Sm,
Disabled: _busy || !_editorReady || IsActiveChapterLocked,
Disabled: !CanInteract || IsActiveChapterLocked,
OnClickAsync: RunAsync)[_busy ? "Running…" : "Run ▸"],
// Cross-app links back to the docs + repo, and the shared light/dark toggle.
BsLink(Href: "https://pal-tamas.github.io/rask/docs/", Target: "_blank", Rel: "noopener",
Expand Down Expand Up @@ -222,7 +243,7 @@ private Component TabButton(PlaygroundTab tab, string label) =>
Class: _tab == tab
? $"{TutorialPaneState.TabClass} {TutorialPaneState.Active}"
: TutorialPaneState.TabClass,
Disabled: _busy,
Disabled: !CanInteract,
OnClickAsync: () => SwitchTabAsync(tab))[label];

// Switching tabs loads what that tab is pointing at, so the editor always holds the thing the pane
Expand All @@ -246,7 +267,7 @@ private Component SampleList() =>
Button(
Key: s.Id,
Class: s.Id == _activeSampleId ? "pg-example is-active" : "pg-example",
Disabled: _busy,
Disabled: !CanInteract,
OnClickAsync: () => SelectSampleAsync(s))[
Span(Class: "pg-example-title")[s.Title],
Span(Class: "pg-example-blurb")[s.Blurb]
Expand All @@ -262,7 +283,7 @@ private Component ChapterList() =>
Class: TutorialPaneState.ClassesFor(StateOf(c), _completedChapters.Contains(c.Id)),
// A locked chapter still opens — the code is worth reading even where it can't run.
// Run is what gets disabled for it (see IsActiveChapterLocked).
Disabled: _busy,
Disabled: !CanInteract,
OnClickAsync: () => SelectChapterAsync(c))[
Span(Class: "pg-chapter-no")[c.Number.ToString(CultureInfo.InvariantCulture)],
Span(Class: "pg-chapter-title")[c.Title],
Expand All @@ -282,6 +303,20 @@ private ChapterState StateOf(TutorialChapter chapter)
: ChapterState.Open;
}

// The one gate every control shares: nothing may be clicked while a compile is in flight, and nothing
// may be clicked before the editor exists.
//
// The second half is the subtle one and used to be missing from the selection controls (#647). Loading
// a chapter or an example is a JS 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 finishes and
// installs the starter over the selection. The reader was left with the brief and the highlight showing
// one chapter while the editor held another, and Run cheerfully compiled the wrong code and ticked the
// chapter off. On a cold load the window is seconds wide, which is exactly when a first-time reader
// clicks "Tutorial".
//
// One property rather than six copies of the condition: the bug was two of those copies disagreeing.
private bool CanInteract => !_busy && _editorReady;

// True when the editor holds a chapter this build can't compile. Run is disabled rather than left to
// fill the preview with CS0246s about DbContext — the reader can still read the code.
private bool IsActiveChapterLocked =>
Expand All @@ -307,10 +342,10 @@ private Component Brief()
],
Div(Class: "pg-brief-nav")[
BsButton(Class: "pg-prev", Color: BsColor.Secondary, Outline: true, Size: BsSize.Sm,
Disabled: _busy || chapter.Number == 1,
Disabled: !CanInteract || chapter.Number == 1,
OnClickAsync: () => StepAsync(-1))["← Back"],
BsButton(Class: "pg-next", Color: BsColor.Secondary, Outline: true, Size: BsSize.Sm,
Disabled: _busy || chapter.Number == TutorialChapters.All.Count,
Disabled: !CanInteract || chapter.Number == TutorialChapters.All.Count,
OnClickAsync: () => StepAsync(1))["Next →"]
]
],
Expand Down
13 changes: 10 additions & 3 deletions scripts/run-e2e-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,17 @@ dotnet publish samples/Rask.Example.Shop -c Release --no-build --no-restore --no
# The playground is the one sample published WITH the native relink, and it has to be: its tutorial track
# runs EF Core against SQLite in the browser, which means linking e_sqlite3 (a static archive) into the
# runtime. Passing -p:WasmBuildNative=false here would silently drop the data packages (see
# RaskPlaygroundData in its csproj) and the tutorial half of PlaygroundExampleTests would fail. This does
# not reintroduce the fingerprint/SRI drift the note above warns about: that is one project built two ways
# into one obj/, whereas the fixture serves this publish output, which is internally consistent.
# RaskPlaygroundData in its csproj) and the tutorial half of PlaygroundExampleTests would fail.
#
# Which makes this project the exact case the note above warns about — one project built two ways into one
# obj/ — so it gets the TFM intermediates cleared first. Without this the scoped-asset bake, staged under
# obj/Release/net10.0-browser/rask-scoped/_rask/a, is left over from the no-native build and does NOT make
# it into 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 for a permanently disabled Run
# button. It reads as a hang, names nothing, and reproduces only on some runs — whichever mode wrote obj/
# last. Clearing only obj/Release/net10.0-browser keeps obj/project.assets.json, so the restore below is
# still incremental.
rm -rf samples/Rask.Example.Playground/obj/Release/net10.0-browser
# It also RE-RESTORES (no --no-restore, unlike the publishes above). RaskPlaygroundData gates the EF Core /
# SQLitePCLRaw PackageReferences, so the package graph differs between the two modes — and the build above
# restored in the other one. MSBuild does not error when a PackageReference appears after restore, it
Expand Down
28 changes: 28 additions & 0 deletions tests/Rask.Example.Playground.Tests/TutorialPaneStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ public void The_view_locks_the_data_chapters_when_the_build_ships_without_them()
Assert.Contains("ChapterState.Locked", view, StringComparison.Ordinal);
}

// #647: Run and Reset were gated on the editor having mounted, but the controls that LOAD code were
// not — they only guarded against a compile being in flight. Loading a chapter is a JS round-trip to
// setEditorValue, and before mountEditor has run there is no editor for the host, so the call is a
// silent no-op and mountEditor then installs the starter over the selection: the brief says one
// chapter, the editor holds another, and Run compiles the wrong code and ticks the chapter off.
//
// The fix is one shared condition. This asserts the condition is genuinely shared — a new control
// written with the old bare `Disabled: _busy` is the exact regression, and it re-opens the race.
[Fact]
public void Every_control_is_gated_on_the_editor_being_ready_not_just_on_busy()
{
var view = ReadView();

Assert.Contains("private bool CanInteract => !_busy && _editorReady;", view, StringComparison.Ordinal);

var bare = Regex.Matches(view, @"Disabled: _busy\b").Count;
Assert.True(
bare == 0,
$"{bare} control(s) still gate only on _busy. Loading code into an editor that has not mounted "
+ "is a silent no-op (#647) — use `Disabled: !CanInteract` so the control waits for the editor, "
+ "as Run and Reset already do.");

// And every control really is gated: one per Disabled: site, all of them through CanInteract.
var disabled = Regex.Matches(view, @"Disabled: ").Count;
var gated = Regex.Matches(view, @"Disabled: !CanInteract").Count;
Assert.Equal(disabled, gated);
}

private static string ReadView() =>
File.ReadAllText(Path.Combine(_repoRoot, "samples", "Rask.Example.Playground", "PlaygroundView.cs"));

Expand Down