Skip to content

🐛 fix: an edit under dotnet watch reaches the browser - #666

Merged
edgarmesquita merged 18 commits into
mainfrom
fix/an-edit-under-dotnet-watch-reaches-the-browser
Oct 10, 2026
Merged

edgarmesquita merged 18 commits into
mainfrom
fix/an-edit-under-dotnet-watch-reaches-the-browser

Conversation

@edgarmesquita

@edgarmesquita edgarmesquita commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #627
Closes #663

What

An edit to a component under dotnet watch reaches the browser, and dotnet watch stays up.

  • Hot reload is on under dotnet watch whatever the environment, read from the DOTNET_WATCH the watcher sets on the app it runs, as well as in Development. An app that sets UIOptions.HotReload still decides.
  • That one decision (UIOptions.HotReloads, settled by its first answer) maps the rebuild stream, sets the modules' cache, and reaches the page as hotReload in window.__EQ_CONFIG. The boot listens and replays exactly when the server streams, where it asked __EQ_DEV__ before.
  • eqc's output folder is declared as build output in DefaultItemExcludes, which dotnet watch ignores, beside the Content removal that an app including wwwroot by hand still needs.
  • eqc writes its output in place, and what it stopped writing is removed after the rest, by its timestamp, as the generated sources already are. The folder is no longer emptied before eqc compiles.
  • The hydration manifest reads the declarations its compilation holds (CompilationSource), so the workspace of dotnet watch or of an IDE describes the app a build describes.
  • The stage-one maps, which carry the app's C#, are served in Development alone, where the error overlay that reads them installs.
  • Stopping the app stops a rebuild still running and ends the streams.

For a developer: dotnet watch with 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:

  • Hot reload was on in Development alone, so eqc never ran again and the page kept the module from before the edit.
  • Where it did run, every module, map and chunk eqc wrote again was a file added to the project, and dotnet watch stopped 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 plain dotnet new web app stops the same way when a file is added to wwwroot.
  • 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.
  • In the watcher's workspace a referenced project is another compilation, and the hydration manifest asked a semantic model of that project's tree, which threw on every edit (CS8785, The hydration manifest fails in an IDE's or dotnet watch's workspace when a page's base or callee lives in another project #663). The hot reload went on without the manifest.

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 watch in any environment, and SIGTERM to the app mid-rebuild (how dotnet watch stops 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 _state bag 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 the hot-reload capability (four requirements) and adds one requirement each to generated-files and hydration-contract.

Proof

  • On the dashboard sample under 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, and dotnet watch stays up. An edit to a component two pages share renamed its chunk (DiffScreen-m4zemsfv.js to DiffScreen-71ak7y93.js) and dotnet watch stayed up. On main the first rebuild stopped it.
  • SIGTERM to the sample's app alone mid-rebuild: on the previous commit the rebuild kept running and the app took 14 s to exit. Now none is left and it exits in 3 s.
  • Copilot's first round found two more in that shutdown, both fixed: a rebuild past its two-minute limit was released without being stopped, and a stream that registered as the app stopped waited out the host. HotReloadServiceLifetimeTests pins them with a stand-in rebuild that sleeps, and the stop in flight with them: all three fail on the commit before the fix.
  • Copilot's second round found one more, fixed: a shutdown that came while the rebuild's process was starting returned before the process existed, and the host waits for nothing after Dispose. The process now starts under the gate Dispose takes, and Dispose returns once the rebuild has ended. Disposing_WhileARebuildStarts_ReturnsOnlyOnceItHasStopped fails on the commit before the fix and passes with it.
  • Copilot's third round, the last, found one more, fixed: eqc itself emptied part of the folder first, because the bundler deleted every map before bun ran, so a bundle that failed left none. It removes the maps bun did not write only after bun has written now (e2434f2). ModuleBundlerMapsTests drives 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.
  • HotReloadDecisionTests sets 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.ts sets __EQ_DEV__ against hotReload, both ways.
  • EqcOutputIsBuildOutputTests runs 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_ReadsTheAppTheBuildReads compiles a page against a referenced project passed as metadata and as a compilation. On main's generators both cases fail with the CS8785 seen under dotnet 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, tsc clean. samples/DefaultUIDashboard builds with no warning. Compiler and conformance did not run: nothing on the path from eqc to the emitted JavaScript changed.

Checklist

  • 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)
  • ./scripts/check-openspec.sh passes: the OpenSpec change validates strictly, and it is archived before the merge
  • dotnet build samples/DefaultUIDashboard: 0 warnings, 0 errors (the native track did not change)
  • A broken contract has a line in the migration notes: no public signature moves (HotReloads and CompilationSource are internal). HotReload's automatic value widens to dotnet watch, and an app that wants it off there sets HotReload = false
  • 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
  • One docs/LEDGER.md line for this event, citing the issues
  • 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
  • No "widget" in prose: the project's word is component

…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.
Copilot AI balanced review requested due to automatic review settings October 7, 2026 16:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Comment thread src/eQuantic.UI.Server/HotReload/HotReloadService.cs
Comment thread src/eQuantic.UI.Server/HotReload/HotReloadService.cs
…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Shutdown can finish before a concurrently starting rebuild has been registered and terminated.

1 open finding
2 resolved since last review

🧠 Review effort: Balanced

Comment thread src/eQuantic.UI.Server/HotReload/HotReloadService.cs Outdated
… 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Source maps are still deleted before bundling, leaving the new output-preservation contract unmet.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/eQuantic.UI.Sdk/Sdk/Sdk.targets
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.
…ok first

Merging main left this pull request's entry above #730's, #660's, #668's
and #661's, which landed before it. The list is in the order things land,
so it moves to the end, unchanged.
@edgarmesquita
edgarmesquita merged commit d62a77f into main Oct 10, 2026
10 checks passed
@edgarmesquita
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>
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
…ompletion list

The served runtime's record conflicted and is measured again: 209,812 bytes. The ledger line moves to the end, and the twins regenerate unchanged.
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
…ditor family

The ledger line moves to the end, and the engine's twins regenerate unchanged.
edgarmesquita pushed a commit that referenced this pull request Oct 10, 2026
Merging main left this pull request's entry above #730's, #660's, #668's,
#661's, #666's and #672's, 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 10, 2026
Merging main left this pull request's entry above #666's and #672's,
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 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.
@edgarmesquita edgarmesquita mentioned this pull request Oct 11, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants