Repository navigation
🐛 fix: an edit under dotnet watch reaches the browser - #666
Merged
edgarmesquita merged 18 commits intoOct 10, 2026
Merged
Conversation
…ge listens when the server streams Hot reload was on in the Development environment alone, and an app run under dotnet watch without a launch profile is a Production one: the edit reached .NET and never the browser, since eqc never ran again (#627). dotnet watch sets DOTNET_WATCH to 1 on the app it runs, and UIOptions.HotReloads reads it beside the environment and the app's own HotReload. That one decision is read by the stream that announces a rebuild, the cache of the modules a rebuild rewrites, and the client, which the configuration now tells whether the server streams rebuilds. The boot asked __EQ_DEV__ before, which agreed with the server only while the app left HotReload unset.
Every module, map and chunk eqc wrote again under dotnet watch was a file added to the project, and dotnet watch stopped on the first one (dotnet/sdk#55335, measured on a plain web app too). The output folder joins DefaultItemExcludes, the glob dotnet watch ignores a change by, in place of the Content removal it replaces: the default Content glob and the publish's generated content read the same declaration. And the folder was emptied before eqc compiled, so every module answered 404 for the seconds it took, which is where dotnet watch's own refresh of the browser landed. eqc now writes in place, and what it stopped writing is removed after, by its timestamp, as the stale generated sources are.
…reads A command-line build hands a referenced project to the generators as metadata; the workspace of dotnet watch or of an IDE hands it over as another compilation, whose syntax trees the app's does not hold. Taking "has syntax" for "is the app's own", the manifest asked a semantic model of a foreign tree and threw on every hot reload of a page whose base or callee lived in another project (CS8785, #663). CompilationSource answers with the declarations the compilation holds, and every walk asks it.
…y hand DefaultItemExcludes keeps the folder out of the default globs, and an app with the default content items off that includes wwwroot/** itself reached it through none of them: the folder was Content again, registered by the static web assets pipeline at evaluation (#352, #361). The removal after the project's own items stays beside the exclusion.
…s rebuild The stage-one maps carry the app's C# and were mapped with the hot reload stream, which now runs under dotnet watch in any environment; they are served where the error overlay reading them installs, in Development, which also serves them to an app that turned hot reload off there. The rebuild's service lived as long as the process: SIGTERM to the app alone, as dotnet watch restarts one, left eqc writing beside the next build, and an open tab's stream held the shutdown for 14 s. It is disposed when the app stops, which kills a rebuild in flight and ends the streams (3 s). And the decision is settled by its first answer, the one the stream was mapped by.
There was a problem hiding this comment.
🟡 Changes recommended
Shutdown can still leave timed-out rebuilds running or concurrently registering event streams open.
2 open findings
What changed in this PR
Updates eQuantic.UI’s hot-reload pipeline so component edits reach the browser under dotnet watch, including outside Development.
Changes:
- Shares the server’s hot-reload decision with browser boot and module caching.
- Preserves generated modules during rebuilds and excludes output from watch inputs.
- Fixes workspace hydration analysis and adds regression tests and specifications.
| File | Description |
|---|---|
tests/eQuantic.UI.Web.Tests/ServerValueProjectionTests.cs |
Tests workspace/build manifest parity. |
tests/eQuantic.UI.Web.Tests/EqcOutputIsBuildOutputTests.cs |
Tests output exclusions and pruning. |
tests/eQuantic.UI.Server.Tests/HotReloadDecisionTests.cs |
Tests reload decisions, caching, and maps. |
src/eQuantic.UI.Server/UIExtensions.cs |
Integrates reload decisions and shutdown cleanup. |
src/eQuantic.UI.Server/HotReload/HotReloadService.cs |
Tracks rebuilds and closes streams on shutdown. |
src/eQuantic.UI.Server/Client/ClientConfig.cs |
Sends the reload decision to browsers. |
src/eQuantic.UI.Sdk/Sdk/Sdk.targets |
Excludes generated output and prunes after compilation. |
src/eQuantic.UI.Sdk/Resources/boot.ts |
Gates reload behavior on server configuration. |
src/eQuantic.UI.Runtime/src/index.ts |
Adds the client configuration field. |
src/eQuantic.UI.Runtime/src/dev/boot-hot-reload.spec.ts |
Tests configuration-driven browser behavior. |
src/eQuantic.UI.Generators/ServerValueAnalysis.cs |
Limits analysis to the current compilation. |
src/eQuantic.UI.Generators/HydrationManifestGenerator.cs |
Avoids referenced projects’ syntax trees. |
src/eQuantic.UI.Generators/CompilationSource.cs |
Centralizes declaration ownership checks. |
openspec/specs/hydration-contract/spec.md |
Defines workspace manifest parity. |
openspec/specs/hot-reload/spec.md |
Defines hot-reload behavior. |
openspec/specs/generated-files/spec.md |
Defines output preservation and exclusions. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/tasks.md |
Records completed implementation and validation tasks. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/specs/hydration-contract/spec.md |
Archives the hydration requirement. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/specs/hot-reload/spec.md |
Archives hot-reload requirements. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/specs/generated-files/spec.md |
Archives generated-output requirements. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/proposal.md |
Records scope and motivation. |
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser/design.md |
Documents implementation decisions. |
docs/LEDGER.md |
Records the fixes and linked issues. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…s the app stops ends Copilot's first round. Past its two-minute limit, WaitForExit returned false, ExitCode threw, and the finally released the process without ending it: the build went on writing where shutdown could no longer reach it. It is stopped, its whole tree, and waited on now. And a stream that registered after Dispose completed every channel it found waited out the host's shutdown: it completes at once. HotReloadServiceLifetimeTests pins both with a stand-in rebuild that sleeps, and the stop in flight from the review before the pull request opened.
8 of 9 tasks
… stops it The host awaits Dispose and nothing after it. A shutdown that came while the rebuild's process was starting found no process to stop and returned at once, and the kill the rebuild ran afterwards was nobody's to wait for, so the old build could still be writing when the restart's own build began. The process now starts under the gate Dispose takes, so Dispose either comes first and no rebuild starts, or comes after and finds the process to stop. And Dispose returns once the rebuild has ended, which the kill makes quick. The new lifetime test fails on the commit before this (Dispose returned before the process existed) and passes with it.
The client configuration keeps both new fields, whether the server streams rebuilds and whether it serves events, and the hydration contract keeps both new requirements.
…n that comes as a rebuild starts
Copilot's third review of #666. The pull request makes eqc's folder build output replaced in place: a file a compile does not write is removed after it has written, never before, so the folder holds every file while the compile runs and one that fails leaves it as it found it. eqc itself broke that for the maps: ModuleBundler deleted every *.js.map before it started bun, so the maps were gone while bun ran and stayed gone when it failed. The test pinned the SDK's prune with a stand-in compiler and never reached the bundler. The bundler now notes the maps it finds and when each was written, runs bun, and only when bun succeeded removes the ones bun did not write again, which is still what keeps a Debug build's maps out of a Release publish. It composes only the maps bun wrote: an earlier bundle's leads to the C# already. A map written in the two seconds before the bundle started is kept, as the SDK's prune keeps one, since a file system that stores whole seconds cannot tell it from one bun wrote again, and composing it again leaves it as it was. ModuleBundlerMapsTests runs the real bundler and bun: a bundle that fails leaves the earlier map as it was, a bundle removes the map of a module it no longer writes and composes only its own, and a bundle without maps leaves none behind.
…hat fails keeps its maps The generated-files spec gains the scenario the bundler now keeps: a bundle bun refuses leaves the earlier maps as they were, and one that succeeds removes the maps it did not write only after bun has written. The ledger's line for #627 and #663 says what the third round found.
…ok first Merging main left this pull request's entry above #667's two, which landed before it. The list is in the order things land, so it moves to the end, unchanged.
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 8, 2026
…#666's head Merged over #666's head, which carries main 00714cc, the served runtime is 205,023 bytes gzipped, against the 201,883 main records and the 203,119 main serves. The difference is this pull request's: the module that captures a page's state and gives it back after a hot reload, and the spec of each value's type it now writes beside the value (1,376 bytes of it, Copilot's first round). Regenerated with EQ_UPDATE_RUNTIME_BUDGET=1.
The one conflict was the hot reload's endpoint in UIExtensions.cs. This pull request registers the service's disposal with the app's lifetime and moves the source-map route out of the hot reload's block, and #686 made both routes anonymous. The merge keeps both: the lifetime hook, the two blocks, and `.AllowAnonymous()` on each route.
edgarmesquita
deleted the
fix/an-edit-under-dotnet-watch-reaches-the-browser
branch
October 10, 2026 15:28
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 10, 2026
…a-hot-reload-keeps-the-pages-state #666's final head, which main squashed as d62a77f, carrying main up to #661. Two kinds of conflict, both regenerated rather than picked: - The twins this pull request regenerated with `static $record = true` and #661 regenerated with its names (31 of them). They are written again over the merged compiler (EQ_UPDATE_TRANSPILED=1), and each differs from main's by the marker line alone. - The served runtime's budget. Main records 204,101 bytes gzipped (#660), and #661 and #668 grew the runtime within their 1%. With this pull request's hot reload module on top, the merged runtime measures 209,149, so the budget is written again at that (EQ_UPDATE_RUNTIME_BUDGET=1).
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 10, 2026
…state This branch already holds #666's final head (3cf0fa1), and main's squash of it (d62a77f) has the very same tree, so the merge records main as merged and changes nothing (strategy ours). A plain merge would have set this branch's own changes on #666's lines against the squash's copy of them, a conflict in every hunk where this pull request edits #666's code.
edgarmesquita
added a commit
that referenced
this pull request
Oct 10, 2026
Closes #664 Stacked on #666, which decides when a page listens for rebuilds: this pull request's base is #666's branch, and it moves to main when #666 merges. ## What A hot reload keeps the page's state. Save a component and the page comes back with the edit and the values it held. - The reload captures the page's own fields, without the runtime's beside them (the nearest runtime base class, built bare, says which those are), and hands them to the reloaded page before it builds, each rebuilt from the hydration spec the capture wrote beside it, by the typed `hydrate` the server's payload goes through, and kept only where the page declares or initializes that type. - Only data crosses: a primitive, an array or a plain object of data (an array with a hole or an undefined element is not), a record or a struct, which its twin says it is (eqc writes `static $record = true` on every record and struct twin and on nothing else), and the runtime's value types, a `long` among them. A controller or a service keeps the one its initializer makes, since its JSON is not it. - A field the edit removed is left behind, a field it added keeps its initializer, and a field JSON cannot carry is left alone while the others cross. For a developer: nothing to write. The state that survives is the state the page's C# declares. ## Why Measured on the dashboard sample while measuring #627: `Count: 3`, a save to `DeclarativeScreen.cs`, and the page came back `Count: 0`, under `dotnet run` and under `dotnet watch` alike. The boot captured the page's `_state` bag, which no write-once page has (its state is the class fields its twin declares), and the door the captured state went back in by, the server-data adoption, takes only what the hydration manifest lists. ## OpenSpec `openspec/changes/archive/2026-10-07-a-hot-reload-keeps-the-pages-state`, archived. It adds one requirement to the `hot-reload` capability that #666 creates. ## Proof - On the dashboard sample under `dotnet run`: `Count: 3`, a save, and the page comes back with the edit and `Count: 3`, then counts on to 4. On #666's head it came back `Count: 0`. - The spreadsheet screen, which holds a `SheetController` in a field, reloads after a save with the edit and its cells, and no error in the console. - `hot-reload-state.spec.ts` runs each case through JSON, as sessionStorage carries it: the page's fields and none of the runtime's, a removed and an added field, a record, a `long` and a controller beside them, and a field JSON cannot carry. The review before this opened found the controller and the `long`, and that case fails on the commit before the fix. - Copilot's first round found four, each confirmed through JSON as sessionStorage carries the state, and fixed by one rule (75b8d4f): the capture writes, beside each field's JSON, the hydration spec of its runtime type, and the reload rebuilds the field with `hydrate`, the server payload's own machinery. A number JSON writes as null (NaN, an infinity), a controller inside a dictionary and a sorted dictionary keep their initializer. The vocabulary's value types cross as themselves, found by name in the registry `ValueTwinHashTests` holds to the C#, and a `long[]`, a `Dictionary<int, long>` and nested values come back as their types. With the module before the fix 9 of the 15 cases fail, and all 15 pass with it. On the sample, a page holding a `long`, a `List<long>`, a `Dictionary<int, long>`, a `Point`, a `decimal` and a `DateTime?` came back after a save with each value as its type, and its NaN `double` kept its initializer. - Copilot's second round found four more, each failing first on 3d8c744 and fixed in ed5c2a7: a negative zero came back as zero (a -0 key and an undefined dictionary value too, now none of them is data), a record whose setter transforms its input came back transformed twice (a record now crosses as its own data members, stores included, rebuilt with no accessor run), a collection whose element or key type the edit changed came back holding the old type (the value is now checked at every depth against the type the reloaded page gives it: its declared `$hydration` spec, or else its initializer), and the first render's server adoption wrote back a field the replay had refused (the boot removes the fields the replay decided from the page's payload, and keeps the members only the server hands it). With the module and boot before the fix exactly the 6 new cases fail, and all 21 pass with it. Checked live on the dashboard sample: an edit that made `ServerDataScreen.ProcessId` a string came back as the string after the reload, and the child's payload was still adopted. One limit, by design: on a page with no `$hydration` (eqc writes one only where a component prefetches or holds a server value), an empty or null initializer types nothing, so a typed value no longer crosses into it. eqc declaring every field's type would lift it. - Copilot's third round found three, each confirmed and fixed. An app's class with an `Equals` override and a `With(...)` of its own was carried as a record, since the reload recognized one by its `with` and `equals`, and came back built without its constructor (measured through eqc's own output, 4496f95). eqc now writes `static $record = true` on every record and struct twin and on nothing else, and the reload recognizes a record by that alone, so the class keeps its initializer. The 48 twins the runtime ships differ from their committed form by that line alone. A list with a hole or an undefined element came back with nulls (eqc writes `Array.Resize` as a length change and a vocabulary struct's `default` as undefined), and it is now left to its initializer, as a dictionary already was (87fb1b1). The archived change's tasks and proposal now describe the typed `hydrate` that was delivered (bdaac58). With the module or the emitter before each fix, exactly its new case fails: 1 of 22, 1 of 23, and the compiler's 1 of 1. Suites at this head (bdaac58, #666's head 519d99a merged, on main 00714cc): runtime 164 files and 2,193 tests, `tsc` clean. Compiler 1,730 of 1,730. Web 960 of 960 with the twins regenerated. The record cases of the conformance suite 281 of 281 (the whole suite runs in CI). Server: `ServedRuntimeBudgetTests` passes, 206,307 bytes gzipped against the 205,023 recorded. OpenSpec validates 31 items strictly. `samples/DefaultUIDashboard` builds with 0 errors. ## Checklist - [x] `dotnet test` on the affected test projects, and `dotnet build src/eQuantic.UI.Runtime -t:TestRuntime` if TypeScript changed (the embedded Bun runs `tsc`, then `vitest run`) - [ ] This PR's checks include a run named **CI** with jobs in it (`build-packages`, `test (ubuntu-latest)`, `test (windows-latest)`, `openspec`, `session-start`, `wiki-checkout`) - [x] `./scripts/check-openspec.sh` passes: the OpenSpec change validates strictly, and it is archived before the merge - [x] `dotnet build samples/DefaultUIDashboard`: 0 errors (the native track did not change) - [x] A broken contract has a line in the migration notes: none breaks, the runtime's new module is internal to the boot - [x] The documentation changed with the behaviour: the wiki's ServerIntegration, Architecture and GettingStarted pages in English AND Portuguese, in one commit on the wiki branch named like this pull request's, merged into the wiki's master when this merges - [x] One `docs/LEDGER.md` line for this event, citing the issue - [x] The diff reviewed by its author before the PR opened (`/code-review high`), every Copilot thread answered and resolved, a new round asked for only after a defect, and three rounds at most - [x] No "widget" in prose: the project's word is *component* --------- Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
6 of 9 tasks
edgarmesquita
added a commit
that referenced
this pull request
Oct 10, 2026
Closes #495 Closes #506 Closes #511 Closes #622 Closes #623 ## What Five places where the producers that draw a tree (the server's realizer, its browser twin and Photon) drew it differently. One family of the bug sweep. - **A Text is a block on the web, wherever it sits (#495).** As an inline span inside a block parent (a Box, a Flexible, a Link, a Pressable) it sat on the parent's line box and its body-font strut: a 10/15 label in a padded pill was 26.5px where its parts add up to 21. Now it has its own line box, and it takes the width its parent gives, as Flutter's Text does under a tight width. - **A pinned header's `ScrolledStyle` applies every member on every target (#506).** The web wrote four members of seven and a border alone (an `Elevation`, the member a developer reaches for first, did nothing), and Photon read none of it. It goes through the builder a box's states use, over `Pinned.ScrolledBase` (the hairline along the bottom edge), and Photon draws it, gliding under the header's `Transition`. The surface that decides is the one the header pins to: its nearest ScrollView, or the page. On the web the state is the header's own `data-eq-scrolled`, where one class on `<html>` could not see a header in a scrolling panel. The same builder now draws a state's border along the edges its box draws, and a width alone in the base's colour. - **A draggable's offset rides `translate` (#511)**, on both producers and in the drag controller, so an open row keeps its box's `transform` and its hover's, and the two producers write the same markup. A release hands the surface back to the markup, so a swipe that changes nothing glides home instead of staying where the finger left it. A normalized rest is a percentage of the surface's own box. - **The hit slop's lift reaches through wrappers that draw no box (#622)**: an InView, an Adaptive's arms, a light and dark Image. The first descendants that draw a box carry `eq-lift`. - **A served page is built at the density the browser asked for (#623).** The runtime leaves the density its pointer asks for in a session cookie, the server builds the page at it (every write-once bridge of the render, an escape-hatch page's included) and says so in the page's configuration, and hydration lowers at that density and then switches the whole page at once when the browser's own differs. A client navigation finds its server data in the tree built at the same density. Found and fixed on the way: the browser twin wrote a dark artwork's class over its sizing classes, so a light and dark `Image` pair drew at its natural size in dark mode on a client-rendered page. Migration: - A Text is a block on the web: several Texts inside an `HtmlElement` take a line each. A sentence is one Text with `Spans`. - `WebRealizer.Lower` takes a trailing `density`, and `VisualNodeComponent.AmbientDensity` is the request's density, which every bridge of a render is built at. - The scrolled rules select on the header's `data-eq-scrolled` instead of `html.eq-scrolled`. ## Why Each one is a member, a setting or a measurement one producer honours and another ignores or draws otherwise, which is the defect class the product principle rules out. Flutter's answers are in the OpenSpec design: `scrolledUnderElevation` and its depth-0 notifications, a Text under a tight width, a `Transform` that composes. Filed from this work: #659 (Photon sizes a Text itself where Flutter's takes the parent's width). ## OpenSpec Archived as `openspec/changes/archive/2026-10-07-every-producer-draws-the-same-tree`, with deltas for `layout` (new), `interaction-states`, `gestures` (new), `hit-targets` and `hydration-contract`. #648 creates `layout` too, so the second of the two to merge combines their requirement lists. ## Proof A/B against the wrong fixes, the same tests on both sides, 13 sabotages, each failing its tests while the fix passes all of them: - the Text without its block: `TextBlockFlowTests` 3 fail, the parity fixture 34 - the offset back in `transform`: `DraggableRealizerTests` 3 fail, and the controller dragging by `transform` and keeping its inline offset fails 8 specs - the scrolled header's old four members fail 2, the state border as a four-edge shorthand 2, Photon drawing no scrolled chrome 5, Photon reading the outermost scroll view 1, and the runtime reading the window for every header 2 - no lift through wrappers: `HitSlopTests` 4 fail, the parity fixture 1 - the server ignoring the density cookie fails 1, the runtime leaving no cookie 2, and a navigation discovering its data at the default density 1 In Chromium, the dashboard sample with a temporary page (not committed), on a served page and after a client navigation, both: the pill is 21px with its text at 3px, as through a Column. The open row computes `translate: -80px` with its own `scale(0.9)`, and keeps the offset under the pointer while the hover lifts it. The card behind an InView is the element under the pointer at its centre. Each header follows its own surface, page or panel, scrolled at 9 and not at 8. And a session's first page is served Comfortable, switches whole to Compact after hydration (the checkbox at 20px), and the next request is served Compact. Suites on this head, rebased on main 3b9b269: `eQuantic.UI.Web.Tests` 976 (against this branch's wiki), `eQuantic.UI.Native.Engine.Tests` 1840 (19 skipped, the allocation ruler included), `eQuantic.UI.Server.Tests` 279 (the served runtime's budget included), and the runtime's `-t:TestRuntime` 2181. The Compiler and Conformance suites are left to CI. After the merge of main b142b62 (#730 through #672): `eQuantic.UI.Server.Tests` 318 and the runtime's `-t:TestRuntime` 2230. This change adds 989 bytes to the served runtime (210,138 gzipped against main's 209,149), inside the budget's 1%, so the record stays main's. The merge with #666 kept both fields the two changes added to the client's configuration, `HotReload` and `Density`. Copilot's first round found the navigation's discovery walk building at the default density, so a page that composes by its density sent the data of a branch the browser does not build. Fixed, with the test above failing before it. The second found a header inside a horizontal scroll view decided by the page on the web, where Photon never scrolls it (fixed, with a spec that failed before), and the browser's twin of `VisualNodeComponent` ignoring an explicit density. Nothing passed one, so that parameter is gone and a bridge takes the request's density alone. From `/code-review high`: 8 findings, all fixed (every bridge at the request's density, Photon's fading veil keeping its hue, the TS `Pinned` statics, the normalized rest, the scroll sync once per frame, the boot's density steps under test, and two cleanups). ## Checklist - [x] `dotnet test` on the affected test projects, and `dotnet build src/eQuantic.UI.Runtime -t:TestRuntime` if TypeScript changed (the embedded Bun runs `tsc`, then `vitest run`) - [ ] This PR's checks include a run named **CI** with jobs in it (`build-packages`, `test (ubuntu-latest)`, `test (windows-latest)`, `openspec`, `session-start`, `wiki-checkout`). A workflow whose expression does not parse creates zero jobs, and the ruleset's required checks then keep the PR blocked; GitHub lists the workflow by its file path instead of `CI`. While GitHub Actions has no credits, the same run is on eQuantic Space (`eqs runs ls`) - [ ] `./scripts/check-openspec.sh` passes: the OpenSpec change validates strictly, and it is archived before the merge - [x] `dotnet build samples/DefaultUIDashboard` (and `PhotonDesktop` / `WalletMobile` if the native track changed): the solution builds with all three, 0 errors - [x] A broken contract has a line in the migration notes (we are in preview: break freely, hide nothing) - [x] The documentation changed with the behaviour: this repository's Markdown here, and the wiki page in English AND Portuguese, in one commit on a branch of the [wiki repository](https://github.com/eQuantic/equantic-ui/wiki) named exactly like this pull request's branch, merged into the wiki's master when this merges (the twin lives at `locale/pt-BR/<Page>-pt-BR.md` there) - [x] One `docs/LEDGER.md` line for this event, citing the issue - [ ] The diff reviewed by its author before the PR opened (in Claude Code, `/code-review high`); every Copilot thread answered and resolved; a new round asked for (`gh pr edit <n> --add-reviewer @copilot`) only after a defect, and three rounds at most - [x] No "widget" in prose; the project's word is *component* --------- Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 10, 2026
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 10, 2026
edgarmesquita
pushed a commit
that referenced
this pull request
Oct 11, 2026
Merges 20066eb, the third of the five steps that bring this branch up to main eb33f44. It carries #666, #672, #688, #653, #699, #689, #690, #696, #733 and #748, and #748 is the one that meets this branch's source. How each source conflict was resolved: - ValueFlow.cs: both sides taught FlowsIntoText the hole of an interpolated string a handler takes (sb.Append($"...")), this branch by the hole's syntax (an AppendFormatted with no alignment and no format clause) and #748 by the bound operation (an argument of the handler's call that is the call's only argument written). #748's rule is kept, since it reads the operation the hole is, and the hole's text is still this branch's: a value that flows into text goes through StringConversion, which writes it in the page's culture. - InterpolatedStringStrategy.cs: #748 moved a hole's lowering into Hole(), which the builder's part by part append calls too, and this branch had changed the number kind the hole passes. Main's Hole() is kept with this branch's kind in it: FormatKind.Of where a specifier is written, and FormatKind.OfText with none, every number but a double. - StringBuilderStrategy.cs: #748 rewrote the dispatch (the span and GetChunks fences, named arguments bound through ParameterTemplate, AppendFormat, AppendJoin, the interpolated append part by part, appendBuilder and equalsBuilder), and this branch had written the value of Append, AppendLine and Insert in the culture through its own bound call. Main's dispatch is kept, and the value goes in front of it through the same ParameterTemplate.Call, its convert writing the value argument through StringConversion and every other argument as it is, each evaluated in the order it is written. A value that is text already passes as it is, as before (ValueInTheCulture, IsText). BoundArguments.Call, which this branch added for that value alone, goes: nothing else calls it. - docs/DIAGNOSTICS.md: EQ2108 keeps this branch's Convert.ToString and gains #748's builder clause, EQ2109 stays retired, and EQ2110 keeps this branch's reading-only meaning. - diagnostics.baseline.txt: EQ2108 gains #748's StringBuilderStrategy, EQ2109 stays out, and EQ2110 stays this branch's, ParseCulture alone. - bcl-surface.baseline.txt takes main's side and is regenerated after the last step. The runtime's twins in conflict take main's side, and every twin is regenerated after the last step. docs/LEDGER.md merged as a union, and this branch's line moves back to the end of the events.
5 of 6 tasks
edgarmesquita
added a commit
that referenced
this pull request
Oct 11, 2026
Closes #756 The bump to `0.2.0-preview.61`, cut from main at 9305100 (#637) at Edgar's request. It carries forty-five pull requests since `.60`: a component that hears the server on a typed topic (#647), a page that requires its authorization and a Server Action that refuses another site's request (#686, #737), a layout that follows its box and type that follows the window (#648, #665), a control's press and focus on every target with a 24dp floor under a pointer (#617, #628), the code editor's completion (#618, #653, #733), a hot reload under `dotnet watch` that keeps the page's state (#666, #672), the image optimizer on SkiaSharp (#730), the places where the producers drew one tree differently, with Photon's hit-testing and its text layout (#688, #690, #699, #739, #741, #749), a number and a date printed in the page's culture (#637), and a long family of browser answers made .NET's (#579, #588, #594, #607, #608, #621, #654, #656, #660, #661, #667, #668, #689, #696, #697, #705, #707, #708, #709, #715, #731, #748, #752), with a page that answers HEAD (#610) and the conformance harness's own comparison (#609). ## What this commit does - `Directory.Build.props`: `0.2.0-preview.60` → `0.2.0-preview.61`. - `./scripts/public-api.sh ship`: nine projects, 462 declarations join the shipped surface and 43 retire, and every `Unshipped` is empty afterwards. Checked after the fold, per project and compared as sets: the lines that left `Shipped` are exactly the 43 `*REMOVED*` lines declared on main, and the lines that joined are exactly its 462 declarations, so an orphan could not vanish unnoticed. The line diff reads +463 −549: the version line, 462 lines into `Shipped` and 43 out of it, and the 505 that leave the `Unshipped` files. - The analyzer accepts the fold: the solution builds with no RS0016 or RS0017. And it read the files: one bogus declaration appended to an `Unshipped` failed the build with RS0017. - Against `v0.2.0-preview.60` the `Shipped` diff has those 43 lines gone and no other, so no pull request rewrote a shipped line in place this time, as #512 did in `.60`. ## What the retirements are Read out of the `*REMOVED*` lines, each paired with the line that joined under the same member and attributed by the commit that wrote it, not from the log. Only `ServerActionHub` left with nothing in its place, and the rest changed shape: - `ServerActionHub` and its constructor (#647): the hub, its route `/_equantic/hub` and the `AddSignalR()` call `AddUI` made are gone. An app that injected `IHubContext<ServerActionHub>` publishes through `IServerEventPublisher`, and one that maps hubs of its own calls `AddSignalR()` itself. - `ICodeCompletionProvider.CompleteAsync` (#618) takes a `CodeCompletionContext` and answers a `CodeCompletionList`. `CurlyBraceLanguage.Keywords` was the protected abstract set a language overrode, and it is the public list a completion offers now. - `Drawing`'s constructor, `Drawing.Width` and `UI.Drawing` (#648) hold the width as a `SizeValue`. `new Drawing(art, 240)` converts, and a read into a `float` becomes `drawing.Width.Value`. - `ImageOptimizer()` and `BlurPlaceholderGenerator()` (#730) take the `ImageOptimizationOptions`. `UseImageOptimization` registers both, so only an app that built one with `new` meets it. - `HitRegion` (#690) gains `Drawn` after `Bounds`, so building one and deconstructing one both change. `CodeRegion` (#653), `SheetRegion` and `TextRegion` (#690) gain optional members at the end, so only a positional deconstruction changes. - `ServerActionsMiddleware`'s constructor (#737) takes an `IOptionsMonitor<ServerActionsOptions>`. `UseServerActions()` builds it, so only an app that constructs the middleware itself meets it. - Four signatures widened at the end by optional parameters, which compiles as before from source: `UI.Positioned` by the four fractions and the two shifts (#648), `UI.Pressable` by `canRequestFocus` (#653), `WebRealizer.Lower` by `density` (#688) and `PhotonRealizer.Realize` by `sizedLike` (#690). - `eQuantic.UI.Compiler`, which no app references, 21 lines: eleven of #661 (`JsTemplate`'s `Annotate`, with its constructor, its `Deconstruct` and the factory that took it, and six LINQ strategies that crossed to the IR and lost their text `Convert`), four of #608 (`ValueMember`, `ComponentDependencyResolver`'s constructor and `RecordTypeEmitter.CanEmit`), two of #637 (`Eq.AsInteger` and `Eq.AsSingle`), two of #654 (`UserDefinedOperators.Binary` and `Unary`), and `HashSetStrategy` (#594) and `RangeIndexerStrategy` (#707) crossing to the IR. One break is an addition, which no `*REMOVED*` line shows. `CurlyBraceLanguage.ReservedWords` (#618) is a new abstract member, so a language derived from it renames its `Keywords` override to `ReservedWords`. I listed every member that joined an interface or an abstract class already shipped, and it is the only one an implementer has to write: `ICodeLanguage.Keywords` and `ICodeCompletionProvider.ResolveAsync` have default implementations. The additions are 118 in `eQuantic.UI.Code` (the completion), 116 in `Primitives` (the server events vocabulary, `FluidSize`, `Positioned`'s fractions and shifts, `BoxStyle.Pressed`, `GridTrack.AutoFill`, `Touch.MinPointerTarget`), 98 in `Server` (server events, the origins a Server Action allows), 86 in the compiler, 19 in `Native.Components`, 12 in `Web`, 7 in `Components`, 4 in `Native.Framework` and 2 in `Images`. ## The rest of the surface `developer-surface.baseline.txt` gains two configuration sections since `v0.2.0-preview.60`, `EQuantic:ServerActions` (#737) and `EQuantic:ServerEvents` (#647), and loses nothing: no MSBuild property or template parameter came or went. What an app meets with no signature moving: - Three new build errors. EQ1009 (#608, #621, #708) stops two constructors that take the same number of arguments, in a class, a record or a struct that reaches the browser, where the twin kept one and a `new` that meant another ran it. EQ2014 (#708) stops an exception built or tested where a type argument it carries is a type parameter. EQ2013 (#647) stops a `ServerTopic<T>` built over a type parameter, which only new code can meet. - Refusals widened under existing codes: EQ1007 by a second indexer and a sequence over two types (#707, #708), EQ2004 by a stored `Range`, a method group over a .NET extension and a .NET collection's own `GetEnumerator()` (#656, #707, #708), EQ2007 by a comparer handed to `ToDictionary`, `ToLookup`, `GroupBy` or `Distinct` (#707), and EQ2010 by an extension method of a server-only class and a type nested in one (#654, #705, #731). - Two refusals a running app meets on the server: a Server Action called from another origin answers 403 until the app lists that origin (#737), and a class-level `[Authorize]` guards the page as well as its actions (#686). - `image/avif` in the optimizer's `Formats` stops the app at startup (#730), and a `Spacer` of weight below 1 or a negative `Flexible` weight throws where it is written (#741). - EQ2109 is retired and EQ2110 is about reading a number now, since a number written as text follows the page's culture on both sides (#637). The rest are browser answers made .NET's and drawings made alike across the producers, listed in the tag. ## The wiki's version marks `WikiVersionMarkTests` compares a mark only once a tag holds its symbol, so the `.61` tag is when this release's marks are judged for the first time. Emulated before the tag, with the test's own pattern and lookup and a `.61` at 9305100: 145 marks, no mismatch. The thirteen it judges first resolve to `.61`, as written: `StartFraction`, `GridTrack.AutoFill`, `canRequestFocus`, `DENSITY_COOKIE`, `Drawing`'s `SizeValue` width, `WithPageAuthorization`, `AllowServerActionOrigins`, `IServerEvents`, `MapGetAndHead`, `UseServerEvents`, `TwinIsData` on the tokens, `BoxStyle.Pressed` and `ScrolledThreshold`. ## The tag's gate, rehearsed `gate-templates` runs only on a `v*` tag, so a template that does not build costs a release attempt. Rehearsed on main at 9305100, before this branch: 50 packages packed at `1.0.0-dev`, the three web and the four native template shapes scaffolded and built in Release against that feed alone, with no warning and with every eQuantic package restored at the version packed, the published `runtime.js` identical byte for byte to the Server package's, and the dashboard template served, hydrated and navigated in a browser with no console error. ## Not in this release No pull request is open. Forty-eight bugs are, none of them a regression against `.60`. Three issues came out of this stretch and wait for a decision or their turn: #753 (`string.Split` with an array of separators, a count or a null separator), #754 (an app's exception class in a snippet compiled on its own) and #755 (whether generic code should carry its type arguments at run time, which EQ2014 fences). The release notes travel in the annotated tag. The wiki's Upgrading page carries the distilled entry in both languages, on the wiki branch `chore/0.2.0-preview.61`, which merges into the wiki's master when this merges and before the tag. The `docs/LEDGER.md` line for the release follows the tag, as `.60`'s did, because it names the commit the tag lands on. ## Checklist - [x] The diff reviewed by its author before the PR opened: a version line and a mechanical fold, verified as sets above - [x] `dotnet test` on this branch: Compiler 1898, Server 374, Conformance 5087 with one skipped, Web 1021, and the runtime's own 2355 in 170 files, none aborted - [ ] This PR's checks include a run named CI with jobs in it - [x] `./scripts/public-api.sh ship` run, every `Unshipped` empty, the retirements read from the diff - [x] The documentation changed with the release: the Upgrading entry in English and Portuguese, on the wiki branch `chore/0.2.0-preview.61`, with the wiki guards passing against it (9 of 9, and the vocabulary guard fails on that page when a word it refuses is written into it) - [x] Copilot's rounds answered: one round, approval recommended with no finding and no thread, so the loop ends there Co-authored-by: Edgar Mesquita <edgar@equantic.tech>
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.


Closes #627
Closes #663
What
An edit to a component under
dotnet watchreaches the browser, anddotnet watchstays up.dotnet watchwhatever the environment, read from theDOTNET_WATCHthe watcher sets on the app it runs, as well as in Development. An app that setsUIOptions.HotReloadstill decides.UIOptions.HotReloads, settled by its first answer) maps the rebuild stream, sets the modules' cache, and reaches the page ashotReloadinwindow.__EQ_CONFIG. The boot listens and replays exactly when the server streams, where it asked__EQ_DEV__before.DefaultItemExcludes, whichdotnet watchignores, beside the Content removal that an app including wwwroot by hand still needs.CompilationSource), so the workspace ofdotnet watchor of an IDE describes the app a build describes.For a developer:
dotnet watchwith no launch profile now rebuilds the edited component and reloads the page. Nothing to write or set.Why
Reported by the Falei.pt app (#627), which runs without a launch profile, so as a Production app. Measured on the dashboard sample, four defects stood between a save and the page:
dotnet watchstopped on the first one:HotReloadMSBuildWorkspace.cs line 158: Unexpected true. That crash is the open 'dotnet watch' crashes while being modified by CoPilot dotnet/sdk#55335, and a plaindotnet new webapp stops the same way when a file is added to wwwroot.dotnet watch's own refresh of the browser landed.The review before this opened found three more: the Content removal had to stay for an app that includes wwwroot by hand, the maps with the C# would have been served under
dotnet watchin any environment, and SIGTERM to the app mid-rebuild (howdotnet watchstops an app it restarts, on macOS and Linux) left eqc writing beside the next build.Not fixed here, and filed: a write-once page's state does not survive a hot reload under any runner, since the replay reads a
_statebag such a page does not have (#664).OpenSpec
openspec/changes/archive/2026-10-07-an-edit-under-dotnet-watch-reaches-the-browser, archived. It creates thehot-reloadcapability (four requirements) and adds one requirement each togenerated-filesandhydration-contract.Proof
dotnet watch --no-launch-profile(Production): the page carries"hotReload":true, a save logs[eQuantic.HotReload] rebuilt in 13,7s, the page shows the edit, no CS8785, anddotnet watchstays up. An edit to a component two pages share renamed its chunk (DiffScreen-m4zemsfv.jstoDiffScreen-71ak7y93.js) anddotnet watchstayed up. On main the first rebuild stopped it.HotReloadServiceLifetimeTestspins them with a stand-in rebuild that sleeps, and the stop in flight with them: all three fail on the commit before the fix.Dispose. The process now starts under the gateDisposetakes, andDisposereturns once the rebuild has ended.Disposing_WhileARebuildStarts_ReturnsOnlyOnceItHasStoppedfails on the commit before the fix and passes with it.ModuleBundlerMapsTestsdrives the real bundler and bun: the failing bundle's case fails on the commit before the fix, 1 of 3, and all 3 pass with it.HotReloadDecisionTestssets the environment against the decision. On the old rule the four cases where they disagree fail, and on the maps' old placement two of the three cache and map cases fail.boot-hot-reload.spec.tssets__EQ_DEV__againsthotReload, both ways.EqcOutputIsBuildOutputTestsruns the shipped prune targets in MSBuild around a stand-in eqc that records what the folder held while it ran, and evaluates a consumer of the SDK. On main's targets three of its four first cases fail, and the explicit include case fails without the Content removal.ServerValueProjectionTests.AWorkspace_ReadsTheAppTheBuildReadscompiles a page against a referenced project passed as metadata and as a compilation. On main's generators both cases fail with the CS8785 seen underdotnet watch.Suites at this head (ac15596, on main 8475371): Server 290 · Web 960, the wiki guards reading this pull request's wiki branch · runtime 163 files and 2,164 tests,
tscclean.samples/DefaultUIDashboardbuilds with no warning. Compiler and conformance did not run: nothing on the path from eqc to the emitted JavaScript changed.Checklist
dotnet teston the affected test projects, anddotnet build src/eQuantic.UI.Runtime -t:TestRuntimeif TypeScript changed (the embedded Bun runstsc, thenvitest run)build-packages,test (ubuntu-latest),test (windows-latest),openspec,session-start,wiki-checkout)./scripts/check-openspec.shpasses: the OpenSpec change validates strictly, and it is archived before the mergedotnet build samples/DefaultUIDashboard: 0 warnings, 0 errors (the native track did not change)HotReloadsandCompilationSourceare internal).HotReload's automatic value widens todotnet watch, and an app that wants it off there setsHotReload = falsedocs/LEDGER.mdline for this event, citing the issues/code-review high), every Copilot thread answered and resolved, a new round asked for only after a defect, and three rounds at most