From 5b32971665830f35435a10b2f069915949ff961f Mon Sep 17 00:00:00 2001 From: Will Ehrendreich Date: Mon, 21 Sep 2026 11:36:11 -0500 Subject: [PATCH 01/52] chore(build): bump Microsoft.SourceLink.GitHub to 10.* to fix NU1902 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 --- src/Falco.Datastar/Falco.Datastar.fsproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Falco.Datastar/Falco.Datastar.fsproj b/src/Falco.Datastar/Falco.Datastar.fsproj index a564b3d..e07a55b 100644 --- a/src/Falco.Datastar/Falco.Datastar.fsproj +++ b/src/Falco.Datastar/Falco.Datastar.fsproj @@ -38,7 +38,7 @@ - + From 5718ade3442266717f08db33b4f127266aa06acb Mon Sep 17 00:00:00 2001 From: Will Ehrendreich Date: Mon, 21 Sep 2026 11:36:11 -0500 Subject: [PATCH 02/52] feat(datastar): update to Datastar 1.0.4 and fix outdated actions 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 --- README.md | 69 +++++++++++++-- src/Falco.Datastar/Ds.fs | 111 +++++++++++++++++++++-- src/Falco.Datastar/Types.fs | 68 ++++++++++++++- src/Falco.Datastar/Utility.fs | 17 ++++ test/Falco.Datastar.Tests/DsTests.fs | 126 +++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2727181..c4b293c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ wapp.UseRouting() .Run() ``` +`Ds.cdnScript` loads the standard Datastar bundle from the jsDelivr CDN, pinned to the release in `Ds.datastarVersion` (currently Datastar 1.0.4). +If you use [Rocket](https://github.com/starfederation/datastar/tree/v1.0.4/library/src/rocket), Datastar's web-component layer, use `Ds.rocketCdnScript` instead; it loads the same release with Rocket included. +Load one or the other, not both. + Now, let's incorporate Datastar into our Falco application. First, we'll define a simple route that returns a button that, when clicked, will merge an HTML fragment from a GET request. @@ -236,6 +240,21 @@ the signal value is automatically converted to match the original (see the [docu Elem.input [ Attr.type' "text"; Ds.bind "firstName" ] ``` +For custom elements and web components, `Ds.bindProp` binds the signal to a named element property, and can name the events that sync the property back into the signal. +`Ds.bindEvent` only overrides the events. Property names are kebab-cased in the output, because the HTML parser lowercases attribute names and Datastar camel-cases them again. + +```fsharp +Elem.create "my-slider" [ Ds.bindProp (sp"volume", "value", [ "change" ]) ] [] +Elem.create "my-toggle" [ Ds.bindProp (sp"isOn", "isOn") ] [] +Elem.create "my-input" [ Ds.bindEvent (sp"query", [ "input"; "change" ]) ] [] +``` + +```html + + + +``` + ### [Ds.text : `data-text`](https://data-star.dev/reference/attributes#data-text) Binds the `text` value of an element to a [Datastar expression](https://data-star.dev/guide/datastar_expressions). The value in `$foo` will be automatically set to the `divs` innerText. @@ -323,7 +342,8 @@ Modifiers allow you to alter the behavior when events are triggered. (Modifiers | Debounce of Debounce // timespan, leading, and notrailing | Throttle of Throttle // timepan, noleading, and trailing | ViewTransition - | Window + | Window // listen on the window + | Document // listen on the document | Outside | Prevent | Stop @@ -400,7 +420,7 @@ Elem.div [ Datastar provides a number of actions and functions that can be used in [Datastar expressions](https://data-star.dev/guide/datastar_expressions) for making server requests and manipulating signals. -### [@get | @post | @put | @patch | @delete](https://data-star.dev/reference/actions#backend-plugins) +### [@get | @post | @put | @patch | @delete | @query](https://data-star.dev/reference/actions#backend-plugins) These actions make requests to any backend service that supports Server Side Events (SSE). Luckily an F#-friendly [SDK exists](https://data-star.dev/reference/sdks#dotnet) and `Falco.Datastar` has several [helper methods](#reading-signals-and-server-side-events) @@ -418,8 +438,12 @@ Elem.button [ Ds.onClick (Ds.put "/put") ] [ Text.raw "Put" ] Elem.button [ Ds.onClick (Ds.patch "/patch") ] [ Text.raw "Patch" ] Elem.button [ Ds.onClick (Ds.delete "/delete") ] [ Text.raw "Delete" ] + +Elem.button [ Ds.onClick (Ds.query "/query") ] [ Text.raw "Query" ] ``` +`@query` sends the HTTP `QUERY` method: a safe, idempotent request that carries the signals in its body, like a `@get` that isn't limited to the query string. + The majority of the above examples are fired from a button click, but remember that these are [Datastar expressions](https://data-star.dev/guide/datastar_expressions) and any [event or trigger](#_events-and-triggers_) could activate them. @@ -434,21 +458,54 @@ Elem.button [ Ds.onClick (Ds.get ("/endpoint", )) ] [ Text.raw "Push the Button" ] ``` +Options that are new since Datastar 1.0: + +- `RequestCancellation = Cleanup` also cancels the request when the element it is on is removed from the DOM. +- `ContentType = CustomJson obj` sends the object as the request payload, in place of the signals. +- `ResponseOverrides` replaces what the server sent in the events of the response: `OverrideElements` overrides the + selector, mode, and view transition of patch-elements events, and `OverrideSignals` overrides `onlyIfMissing` of patch-signals events. + +```fsharp +Elem.button [ Ds.onClick (Ds.get ("/endpoint", + { RequestOptions.Defaults with + ResponseOverrides = + ValueSome (OverrideElements { ElementsOverrides.None with + Selector = ValueSome "#results" + Mode = ValueSome Append }) } + )) ] [ Text.raw "Load more" ] +``` + ### [`@setAll`](https://data-star.dev/reference/actions#setall) -Sets all the signals that start with the prefix to the expression provided in the second argument. -This is useful for setting all the values of a signal namespace at once. +Sets all the signals that start with the prefix to the value provided in the second argument. +This is useful for setting all the values of a signal namespace at once. Strings are quoted; numbers and booleans are not. +Since Datastar 1.0 the action takes the value first and a filter second, and `Ds.setAll` builds that filter from the prefix. ```fsharp -Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.setAll "foo." true)) ] [] +Elem.button [ Ds.onClick (Ds.setAll ("foo.", true)) ] [ Text.raw "Check all" ] +``` + +```html + ``` +`Ds.setAllFiltered` takes any `SignalsFilter` instead of a prefix, or `SignalsFilter.None` to set every signal. + ### [`@toggleAll`](https://data-star.dev/reference/actions#toggleall) Toggles all the signals that start with the prefix. This is useful for toggling all the values of a signal namespace at once. +`Ds.toggleAllFiltered` takes any `SignalsFilter` instead of a prefix. + +```fsharp +Elem.button [ Ds.onClick (Ds.toggleAll "foo.") ] [ Text.raw "Toggle all" ] +``` + +### [`@peek`](https://data-star.dev/reference/actions#peek) + +Evaluates an expression without subscribing to the signals it reads. Useful in `Ds.effect` when a signal should be read but must not re-run the effect. ```fsharp -Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.toggleAll "foo.")) ] [] +Elem.div [ Ds.effect $"""$last = {Ds.peek "$count"}""" ] [] ``` ### [Ds.ignore | Ds.ignoreSelf | Ds.ignoreMorph : `data-star-ignore`](https://data-star.dev/reference/attributes#data-ignore) diff --git a/src/Falco.Datastar/Ds.fs b/src/Falco.Datastar/Ds.fs index 4115f07..f695056 100644 --- a/src/Falco.Datastar/Ds.fs +++ b/src/Falco.Datastar/Ds.fs @@ -9,8 +9,16 @@ open StarFederation.Datastar.FSharp [] type Ds = + /// + /// The Datastar release that and point at + /// + static member datastarVersion = "v1.0.4" + + /// + /// The standard Datastar bundle on the jsDelivr CDN, at + /// static member cdnSrc = - @"https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js" + $"https://cdn.jsdelivr.net/gh/starfederation/datastar@{Ds.datastarVersion}/bundles/datastar.js" /// /// Shorthand for `Elem.script [ Attr.type' "module"; Attr.src cdnSrc ] []` @@ -19,6 +27,19 @@ type Ds = static member cdnScript = Elem.script [ Attr.type' "module"; Attr.src Ds.cdnSrc ] [] + /// + /// The Datastar bundle that includes Rocket, Datastar's web-component layer, on the jsDelivr CDN, at . + /// It is a superset of ; load one or the other, not both. + /// + static member rocketCdnSrc = + $"https://cdn.jsdelivr.net/gh/starfederation/datastar@{Ds.datastarVersion}/bundles/datastar-rocket.js" + + /// + /// Shorthand for `Elem.script [ Attr.type' "module"; Attr.src rocketCdnSrc ] []` + /// + static member rocketCdnScript = + Elem.script [ Attr.type' "module"; Attr.src Ds.rocketCdnSrc ] [] + /// /// Patches a signal into the existing signals with the given value. /// Has an optional ifMissing flag. https://data-star.dev/reference/attributes#data-signals @@ -73,6 +94,36 @@ type Ds = static member inline bind signalPath = DsAttr.createSp ("bind", signalPath) + /// + /// Binds a signal to a property of a custom element or web component, instead of its default value or attribute. + /// The property name is kebab-cased because the HTML parser lowercases attribute names; Datastar camel-cases it again. + /// https://data-star.dev/reference/attributes#data-bind + /// + /// The signal to bind to + /// The element property to bind, e.g. "checked" or "someProp" + /// The events that sync the property into the signal; when omitted, Datastar uses the element's default events + /// Attribute + static member bindProp (signalPath:SignalPath, propName:string, ?events:string list) = + DsAttr.start "bind" + |> DsAttr.addTarget (signalPath |> SignalPath.kebabValue) + |> DsAttr.addModifierOption (events |> Option.filter (List.isEmpty >> not) |> Option.map (fun names -> { Name = "event"; Tags = names }) |> Option.toValueOption) + |> DsAttr.addModifier { Name = "prop"; Tags = [ String.toKebab propName ] } + |> DsAttr.create + + /// + /// Binds a signal to an element, syncing it into the signal on the given events instead of the element's default events. + /// Event names are lowercased by the HTML parser, so custom events must be lowercase to be listed here. + /// https://data-star.dev/reference/attributes#data-bind + /// + /// The signal to bind to + /// The events that sync the element into the signal, e.g. [ "input"; "change" ] + /// Attribute + static member bindEvent (signalPath:SignalPath, events:string list) = + DsAttr.start "bind" + |> DsAttr.addTarget (signalPath |> SignalPath.kebabValue) + |> DsAttr.addModifier { Name = "event"; Tags = events } + |> DsAttr.create + /// /// Adds or removes a class from the element based on an expression. /// https://data-star.dev/reference/attributes#data-class @@ -354,6 +405,8 @@ type Ds = | Patch url, ValueSome options -> $"@patch('{url}',{options |> RequestOptions.Serialize})" | Delete url, ValueNone -> $@"@delete('{url}')" | Delete url, ValueSome options -> $"@delete('{url}',{options |> RequestOptions.Serialize})" + | Query url, ValueNone -> $@"@query('{url}')" + | Query url, ValueSome options -> $"@query('{url}',{options |> RequestOptions.Serialize})" /// /// Creates a @get action for an expression with options. The action sends a GET request with the given url. @@ -406,17 +459,36 @@ type Ds = Ds.backendAction (options |> Option.toValueOption) (Delete url) /// - /// @setall(), set all the signals that start with the prefix to the expression provided. + /// Creates a @query action for an expression with options. The action sends a QUERY request with the given url. + /// Signals are sent with the body of the request. + /// https://data-star.dev/reference/actions#query + /// https://data-star.dev/reference/actions#options + /// + /// Expression + static member query (url, ?options) = + Ds.backendAction (options |> Option.toValueOption) (Query url) + + /// + /// @setAll(), set all the signals matching the filter to the value provided; every signal if there is no filter. + /// https://data-star.dev/reference/actions#setall + /// + /// Value to set; strings are quoted, numbers and booleans are not + /// Regex of signal paths to be included and excluded + /// Expression + static member setAllFiltered<'T> (value:'T, signalsFilter:SignalsFilter) = + match signalsFilter = SignalsFilter.None with + | true -> $"@setAll({Js.literal value})" + | false -> $"@setAll({Js.literal value}, {signalsFilter |> SignalsFilter.Serialize |> Js.attrEncode})" + + /// + /// @setAll(), set all the signals that start with the prefix to the value provided. /// https://data-star.dev/reference/actions#setall /// /// All signals to set that have this prefix, e.g. 'foo.' - /// Value to set + /// Value to set; strings are quoted, numbers and booleans are not /// Expression - static member inline setAll<'T> (signalsPathPrefix:string, value:'T) = - match box value with - | :? Boolean as value' -> $"@setAll('{signalsPathPrefix}', {value'.ToString().ToLower()})" - | :? string as value' -> $"@setAll('{signalsPathPrefix}', '{value'}')" - | value' -> $"@setAll('{signalsPathPrefix}', '{value'}')" + static member setAll<'T> (signalsPathPrefix:string, value:'T) = + Ds.setAllFiltered (value, SignalsFilter.Prefix signalsPathPrefix) /// /// @toggleAll(), toggle all the signals that start with the prefix. @@ -425,7 +497,28 @@ type Ds = /// All signals to toggle that have this prefix, e.g. 'foo.' /// Expression static member toggleAll (signalsPathPrefix:string) = - $"@toggleAll('{signalsPathPrefix}')" + Ds.toggleAllFiltered (SignalsFilter.Prefix signalsPathPrefix) + + /// + /// @toggleAll(), toggle all the signals matching the filter; every signal if there is no filter. + /// https://data-star.dev/reference/actions#toggleall + /// + /// Regex of signal paths to be included and excluded + /// Expression + static member toggleAllFiltered (signalsFilter:SignalsFilter) = + match signalsFilter = SignalsFilter.None with + | true -> "@toggleAll()" + | false -> $"@toggleAll({signalsFilter |> SignalsFilter.Serialize |> Js.attrEncode})" + + /// + /// @peek(), evaluate the expression without subscribing to the signals it reads. + /// Useful in Ds.effect and Ds.computed when a signal should be read but not tracked. + /// https://data-star.dev/reference/actions#peek + /// + /// Expression to evaluate, https://data-star.dev/guide/datastar_expressions + /// Expression + static member peek (expression:string) = + $"@peek(() => {expression})" /// /// Method for joining strings with " ; " to simplify multi-line expressions diff --git a/src/Falco.Datastar/Types.fs b/src/Falco.Datastar/Types.fs index cc717b8..272f7e9 100644 --- a/src/Falco.Datastar/Types.fs +++ b/src/Falco.Datastar/Types.fs @@ -5,6 +5,7 @@ open System.Collections.Generic open System.Text open System.Text.Json open System.Text.Json.Nodes +open System.Text.RegularExpressions open System.Web open Falco.Markup open StarFederation.Datastar.FSharp @@ -18,6 +19,9 @@ type SignalsFilter = static member None = { IncludePattern = ValueNone; ExcludePattern = ValueNone } static member Include pattern = { IncludePattern = ValueSome pattern; ExcludePattern = ValueNone } static member Exclude pattern = { IncludePattern = ValueNone; ExcludePattern = ValueSome pattern } + /// Includes every signal whose path starts with the prefix, e.g. "form." matches "form.name" but not "formal" + static member Prefix (prefix:string) = + SignalsFilter.Include ("^" + Regex.Escape(prefix).Replace("/", "\\/")) static member Serialize (signalFilter:SignalsFilter) = if signalFilter = SignalsFilter.None then "" @@ -83,11 +87,13 @@ type BackendAction = | Put of url:string | Patch of url:string | Delete of url:string + /// The HTTP QUERY method: a safe, idempotent request that carries a body + | Query of url:string type ContentType = /// default, filtered signals; default | Json - /// sends a custom object instead of the default, filtered signals + /// sends a custom object as the request payload instead of the default, filtered signals | CustomJson of obj /// validates inputs of closest form and sends them to the backend | Form @@ -109,6 +115,8 @@ type RequestCancellation = | Auto /// allows concurrent requests | Disabled + /// like Auto, but also cancels the request when the element it is on is removed from the DOM + | Cleanup /// an object name that can be aborted; https://data-star.dev/reference/actions#request-cancellation; /// creator should include '$', e.g. (AbortController "$controller") | AbortController of string @@ -117,6 +125,7 @@ type RequestCancellation = match requestCancellation with | Auto -> "auto" | Disabled -> "disabled" + | Cleanup -> "cleanup" | AbortController controller -> controller type ResponseOverrideMode = @@ -128,6 +137,46 @@ type ResponseOverrideMode = | Append | Before | After + with + static member Serialize (mode:ResponseOverrideMode) = + match mode with + | Outer -> "outer" + | Inner -> "inner" + | Remove -> "remove" + | Replace -> "replace" + | Prepend -> "prepend" + | Append -> "append" + | Before -> "before" + | After -> "after" + +/// Values that replace what the server sent in a patch-elements event +type ElementsOverrides = + { /// CSS selector of the element to patch, instead of the one the server named + Selector: string voption + /// How to patch, instead of the mode the server named + Mode: ResponseOverrideMode voption + /// Whether to wrap the patch in a view transition, instead of what the server named + UseViewTransition: bool voption } + with + static member None = { Selector = ValueNone; Mode = ValueNone; UseViewTransition = ValueNone } + +/// Overrides for the events a backend action receives; the client applies them regardless of what the server sent +type ResponseOverrides = + /// Override how patch-elements events are applied + | OverrideElements of ElementsOverrides + /// Override whether patch-signals events only merge signals that are missing + | OverrideSignals of onlyIfMissing:bool + with + static member internal Serialize (responseOverrides:ResponseOverrides) = + let jsonObject = JsonObject() + match responseOverrides with + | OverrideElements overrides -> + overrides.Selector |> ValueOption.iter (fun selector -> jsonObject.Add("selector", selector)) + overrides.Mode |> ValueOption.iter (fun mode -> jsonObject.Add("mode", ResponseOverrideMode.Serialize mode)) + overrides.UseViewTransition |> ValueOption.iter (fun useViewTransition -> jsonObject.Add("useViewTransition", JsonValue.Create useViewTransition)) + | OverrideSignals onlyIfMissing -> + jsonObject.Add("onlyIfMissing", JsonValue.Create onlyIfMissing) + jsonObject :> JsonNode /// Request Options for backend action plugins /// https://data-star.dev/reference/action_plugins @@ -166,6 +215,10 @@ type RequestOptions = { /// An AbortSignal object that can be used to cancel the request. /// https://data-star.dev/reference/actions#request-cancellation RequestCancellation: RequestCancellation + + /// Values that replace what the server sent in the events of the response. + /// https://data-star.dev/reference/actions#options + ResponseOverrides: ResponseOverrides voption } with static member Defaults = @@ -178,7 +231,8 @@ type RequestOptions = { RetryScaler = 2.0 RetryMaxWait = TimeSpan.FromSeconds(30.0) RetryMaxCount = 10 - RequestCancellation = Auto } + RequestCancellation = Auto + ResponseOverrides = ValueNone } static member inline With contentType = { RequestOptions.Defaults with ContentType = contentType } @@ -192,9 +246,8 @@ type RequestOptions = { jsonObject.Add("contentType", "form") jsonObject.Add("selector", formSelector) | CustomJson customJson -> - let serializedOverride = JsonSerializer.Serialize(customJson, JsonSerializerOptions.SignalsDefault) jsonObject.Add("contentType", "json") - jsonObject.Add("override", serializedOverride) + jsonObject.Add("payload", JsonSerializer.SerializeToNode(customJson, JsonSerializerOptions.SignalsDefault)) | Json -> jsonObject.Add("contentType", "json") if backendActionOptions.FilterSignals <> RequestOptions.Defaults.FilterSignals then @@ -224,6 +277,10 @@ type RequestOptions = { let requestCancellation = backendActionOptions.RequestCancellation |> RequestCancellation.Serialize jsonObject.Add("requestCancellation", requestCancellation) + backendActionOptions.ResponseOverrides + |> ValueOption.iter (fun responseOverrides -> + jsonObject.Add("responseOverrides", ResponseOverrides.Serialize responseOverrides)) + let options = JsonSerializerOptions() options.WriteIndented <- false HttpUtility.HtmlEncode(jsonObject.ToJsonString(options)) @@ -263,6 +320,8 @@ type OnEventModifier = | Throttle of Throttle /// Attaches the event listener to the window element. | Window + /// Attaches the event listener to the document. + | Document /// Triggers the event when it occurs outside the element. | Outside /// Call `preventDefault` on the event listener @@ -323,6 +382,7 @@ type DsAttrModifier = | Throttle throttle -> (DsAttrModifier.Throttle throttle) | ViewTransition -> { Name = "viewtransition"; Tags = [] } | Window -> { Name = "window"; Tags = [] } + | Document -> { Name = "document"; Tags = [] } | Outside -> { Name = "outside"; Tags = [] } | Prevent -> { Name = "prevent"; Tags = [] } | Stop -> { Name = "stop"; Tags = [] } diff --git a/src/Falco.Datastar/Utility.fs b/src/Falco.Datastar/Utility.fs index bd9b755..722412d 100644 --- a/src/Falco.Datastar/Utility.fs +++ b/src/Falco.Datastar/Utility.fs @@ -16,6 +16,23 @@ module internal String = ) |> _.Replace("-", "", 0, 1).ToString() +/// Builds JavaScript literals that are safe to place inside a double-quoted HTML attribute. +/// Falco.Markup does not escape attribute values, so anything embedded in an expression has to be escaped here. +module internal Js = + let attrEncode (value:string) = + value.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """) + + let stringLiteral (value:string) = + value.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r") + |> attrEncode + |> fun escaped -> "'" + escaped + "'" + + /// Strings become single-quoted literals; everything else is its JSON form (true, 5, 1.5, null) + let literal<'T> (value:'T) = + match box value with + | :? string as text -> stringLiteral text + | _ -> System.Text.Json.JsonSerializer.Serialize<'T>(value) |> attrEncode + module internal Bool = let inline eitherOr trueThing falseThing bool = match bool with diff --git a/test/Falco.Datastar.Tests/DsTests.fs b/test/Falco.Datastar.Tests/DsTests.fs index eb0c211..fefa3be 100644 --- a/test/Falco.Datastar.Tests/DsTests.fs +++ b/test/Falco.Datastar.Tests/DsTests.fs @@ -76,3 +76,129 @@ module DsTests = renderAttr (Ds.style ("display", "$hiding && 'none'")) |> should equal """
""" + // Datastar baseline + + [] + let ``Ds.cdnSrc is pinned to the Datastar 1.0.4 bundle`` () = + Ds.cdnSrc + |> should equal "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js" + + [] + let ``Ds.rocketCdnSrc is the Rocket bundle of the same version`` () = + Ds.rocketCdnSrc + |> should equal "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" + + [] + let ``Ds.cdnScript and Ds.rocketCdnScript render module script tags`` () = + renderNode Ds.cdnScript + |> should equal """""" + renderNode Ds.rocketCdnScript + |> should equal """""" + + // @setAll / @toggleAll take (value, filter) and (filter) since 1.0.0 + + [] + let ``Ds.setAll passes the value first and the prefix as an include filter`` () = + Ds.setAll ("foo.", true) + |> should equal """@setAll(true, { include: /^foo\./ })""" + + [] + let ``Ds.setAll emits numbers unquoted and strings quoted`` () = + Ds.setAll ("foo.", 5) |> should equal """@setAll(5, { include: /^foo\./ })""" + Ds.setAll ("foo.", "x") |> should equal """@setAll('x', { include: /^foo\./ })""" + + [] + let ``Ds.setAll escapes string values so they cannot break out of the attribute`` () = + Ds.setAll ("foo.", "it's \"q\"") + |> should equal """@setAll('it\'s "q"', { include: /^foo\./ })""" + + [] + let ``Ds.setAllFiltered without a filter sets every signal`` () = + Ds.setAllFiltered (false, SignalsFilter.None) + |> should equal "@setAll(false)" + + [] + let ``Ds.setAllFiltered uses the given include and exclude filter`` () = + Ds.setAllFiltered (true, { IncludePattern = ValueSome "^form\\."; ExcludePattern = ValueSome "\\.id$" }) + |> should equal """@setAll(true, { include: /^form\./,exclude: /\.id$/ })""" + + [] + let ``Ds.toggleAll takes the prefix as an include filter`` () = + Ds.toggleAll "foo." + |> should equal """@toggleAll({ include: /^foo\./ })""" + + [] + let ``Ds.toggleAllFiltered without a filter toggles every signal`` () = + Ds.toggleAllFiltered SignalsFilter.None + |> should equal "@toggleAll()" + + [] + let ``Ds.peek wraps the expression so it reads signals without subscribing`` () = + Ds.peek "$count" + |> should equal "@peek(() => $count)" + + // data-bind on custom elements and web components + + [] + let ``Ds.bindProp binds a signal to an element property`` () = + renderAttr (Ds.bindProp (SignalPath.sp "checkedState", "checked")) + |> should equal """
""" + + [] + let ``Ds.bindProp kebab-cases the property because the HTML parser lowercases attribute names`` () = + renderAttr (Ds.bindProp (SignalPath.sp "val", "someProp")) + |> should equal """
""" + + [] + let ``Ds.bindProp can also name the events that sync the signal`` () = + renderAttr (Ds.bindProp (SignalPath.sp "val", "value", [ "input"; "change" ])) + |> should equal """
""" + + [] + let ``Ds.bindEvent overrides the events that sync the signal`` () = + renderAttr (Ds.bindEvent (SignalPath.sp "val", [ "input"; "change" ])) + |> should equal """
""" + + // data-on target and fetch options + + [] + let ``Ds.onEvent Document listens on the document`` () = + renderAttr (Ds.onEvent ("keydown", "$k = evt.key", [ Document ])) + |> should equal """
""" + + [] + let ``RequestOptions Cleanup cancels the request when the element is removed`` () = + Ds.get ("/x", { RequestOptions.Defaults with RequestCancellation = Cleanup }) + |> should equal """@get('/x',{"requestCancellation":"cleanup"})""" + + [] + let ``RequestOptions CustomJson is sent as the payload object, not the removed override string`` () = + Ds.post ("/x", { RequestOptions.Defaults with ContentType = CustomJson {| a = 1 |} }) + |> should equal """@post('/x',{"contentType":"json","payload":{"a":1}})""" + + [] + let ``Ds.query creates a query action`` () = + Ds.query "/search" |> should equal """@query('/search')""" + + [] + let ``Ds.query takes request options`` () = + Ds.query ("/search", { RequestOptions.Defaults with RequestCancellation = Disabled }) + |> should equal """@query('/search',{"requestCancellation":"disabled"})""" + + [] + let ``RequestOptions ResponseOverrides for elements only lists the values that were set`` () = + let overrides = OverrideElements { ElementsOverrides.None with Selector = ValueSome "#target"; Mode = ValueSome Append } + Ds.get ("/x", { RequestOptions.Defaults with ResponseOverrides = ValueSome overrides }) + |> should equal """@get('/x',{"responseOverrides":{"selector":"#target","mode":"append"}})""" + + [] + let ``RequestOptions ResponseOverrides can turn on view transitions`` () = + let overrides = OverrideElements { ElementsOverrides.None with UseViewTransition = ValueSome true } + Ds.get ("/x", { RequestOptions.Defaults with ResponseOverrides = ValueSome overrides }) + |> should equal """@get('/x',{"responseOverrides":{"useViewTransition":true}})""" + + [] + let ``RequestOptions ResponseOverrides for signals sets onlyIfMissing`` () = + Ds.get ("/x", { RequestOptions.Defaults with ResponseOverrides = ValueSome (OverrideSignals true) }) + |> should equal """@get('/x',{"responseOverrides":{"onlyIfMissing":true}})""" + From b1d614dd3e4a4902cd09b5b80cf67a3fc3124660 Mon Sep 17 00:00:00 2001 From: Will Ehrendreich Date: Mon, 21 Sep 2026 11:36:11 -0500 Subject: [PATCH 03/52] fix(request)!: leave OpenWhenHidden unset so Datastar's default applies 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 --- README.md | 2 +- src/Falco.Datastar/Types.fs | 11 ++++++----- test/Falco.Datastar.Tests/DsTests.fs | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c4b293c..0b25b8d 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ Each request action can also be provided a number of options, explained in depth Elem.button [ Ds.onClick (Ds.get ("/endpoint", { RequestOptions.Defaults with Headers = [ ("X-Csrf-Token", "JImikTbsoCYQ9...") ] - OpenWhenHidden = true } + OpenWhenHidden = ValueSome true } )) ] [ Text.raw "Push the Button" ] ``` diff --git a/src/Falco.Datastar/Types.fs b/src/Falco.Datastar/Types.fs index 272f7e9..f137299 100644 --- a/src/Falco.Datastar/Types.fs +++ b/src/Falco.Datastar/Types.fs @@ -194,8 +194,9 @@ type RequestOptions = { Headers: (string * string) list /// Whether to keep the connection open when the page is hidden. Useful for dashboards - /// but can cause a drain on battery life and other resources when enabled. Defaults to false. - OpenWhenHidden: bool + /// but can cause a drain on battery life and other resources when enabled. + /// Unset by default, so Datastar decides: false for @get, true for the other actions. + OpenWhenHidden: bool voption /// Determines on what to retry; auto, error, always, never Retry: Retry @@ -225,7 +226,7 @@ type RequestOptions = { { ContentType = Json FilterSignals = SignalsFilter.None Headers = [] - OpenWhenHidden = false + OpenWhenHidden = ValueNone Retry = Retry.OnAuto RetryInterval = TimeSpan.FromSeconds(1.0) RetryScaler = 2.0 @@ -258,8 +259,8 @@ type RequestOptions = { backendActionOptions.Headers |> List.iter headerObject.Add jsonObject.Add("headers", headerObject) - if backendActionOptions.OpenWhenHidden <> RequestOptions.Defaults.OpenWhenHidden then - jsonObject.Add("openWhenHidden", backendActionOptions.OpenWhenHidden.ToString().ToLower()) + backendActionOptions.OpenWhenHidden + |> ValueOption.iter (fun openWhenHidden -> jsonObject.Add("openWhenHidden", JsonValue.Create openWhenHidden)) if backendActionOptions.RetryInterval <> RequestOptions.Defaults.RetryInterval then jsonObject.Add("retryInterval", backendActionOptions.RetryInterval.TotalMilliseconds) diff --git a/test/Falco.Datastar.Tests/DsTests.fs b/test/Falco.Datastar.Tests/DsTests.fs index fefa3be..a2b884f 100644 --- a/test/Falco.Datastar.Tests/DsTests.fs +++ b/test/Falco.Datastar.Tests/DsTests.fs @@ -176,6 +176,26 @@ module DsTests = Ds.post ("/x", { RequestOptions.Defaults with ContentType = CustomJson {| a = 1 |} }) |> should equal """@post('/x',{"contentType":"json","payload":{"a":1}})""" + [] + let ``RequestOptions Defaults send nothing so that Datastar's own defaults apply`` () = + Ds.get ("/x", RequestOptions.Defaults) + |> should equal "@get('/x',{})" + + [] + let ``RequestOptions OpenWhenHidden is unset by default because Datastar defaults it per method`` () = + RequestOptions.Defaults.OpenWhenHidden + |> should equal (ValueNone : bool voption) + + [] + let ``RequestOptions OpenWhenHidden false can be sent, which a POST needs because Datastar defaults it to true`` () = + Ds.post ("/x", { RequestOptions.Defaults with OpenWhenHidden = ValueSome false }) + |> should equal """@post('/x',{"openWhenHidden":false})""" + + [] + let ``RequestOptions OpenWhenHidden true is sent as a JSON boolean`` () = + Ds.get ("/x", { RequestOptions.Defaults with OpenWhenHidden = ValueSome true }) + |> should equal """@get('/x',{"openWhenHidden":true})""" + [] let ``Ds.query creates a query action`` () = Ds.query "/search" |> should equal """@query('/search')""" From f5997bd1dd6140ece030823f427649333e66567b Mon Sep 17 00:00:00 2001 From: Will Ehrendreich Date: Mon, 21 Sep 2026 11:36:11 -0500 Subject: [PATCH 04/52] feat(rocket): add server-side helpers for Rocket components 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 --- Falco.Datastar.sln | 7 + README.md | 81 ++++++++++ examples/RocketComponents/RocketComponents.fs | 119 ++++++++++++++ .../RocketComponents/RocketComponents.fsproj | 15 ++ examples/RocketComponents/appsettings.json | 8 + src/Falco.Datastar/Falco.Datastar.fsproj | 1 + src/Falco.Datastar/Rocket.fs | 152 ++++++++++++++++++ src/Falco.Datastar/Utility.fs | 17 ++ .../Falco.Datastar.Tests.fsproj | 1 + test/Falco.Datastar.Tests/RocketTests.fs | 142 ++++++++++++++++ 10 files changed, 543 insertions(+) create mode 100644 examples/RocketComponents/RocketComponents.fs create mode 100644 examples/RocketComponents/RocketComponents.fsproj create mode 100644 examples/RocketComponents/appsettings.json create mode 100644 src/Falco.Datastar/Rocket.fs create mode 100644 test/Falco.Datastar.Tests/RocketTests.fs diff --git a/Falco.Datastar.sln b/Falco.Datastar.sln index 34d7628..695a321 100644 --- a/Falco.Datastar.sln +++ b/Falco.Datastar.sln @@ -20,6 +20,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.Datastar.Tests", "tes EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InputForm", "examples\InputForm\InputForm.fsproj", "{19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "RocketComponents", "examples\RocketComponents\RocketComponents.fsproj", "{825381C2-4DD5-49D1-A487-055E61349DE0}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "environment", "environment", "{13C18205-082E-4DED-9AD4-D3001ABDFECA}" ProjectSection(SolutionItems) = preProject global.json = global.json @@ -39,6 +41,7 @@ Global {0187E584-68DF-4D8C-874C-FF8E061932DF} = {305B55B6-83D0-453C-A77E-080214FA0657} {AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05} = {305B55B6-83D0-453C-A77E-080214FA0657} {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF} = {305B55B6-83D0-453C-A77E-080214FA0657} + {825381C2-4DD5-49D1-A487-055E61349DE0} = {305B55B6-83D0-453C-A77E-080214FA0657} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6BCE09E2-DA8E-44F9-B5EC-C4F76E329471}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -77,5 +80,9 @@ Global {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Release|Any CPU.ActiveCfg = Release|Any CPU {19E053A0-6EA6-4818-AF0D-5BE0B36D55EF}.Release|Any CPU.Build.0 = Release|Any CPU + {825381C2-4DD5-49D1-A487-055E61349DE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {825381C2-4DD5-49D1-A487-055E61349DE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {825381C2-4DD5-49D1-A487-055E61349DE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {825381C2-4DD5-49D1-A487-055E61349DE0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0b25b8d..472ede6 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Some important notes: Signals defined later in the DOM tree override those defin - [Binding to Signals](#signal-binding) - [Events and Triggers](#events-and-triggers) - [Actions and Functions](#actions-and-functions) +- [Rocket Components](#rocket-components) - [When to $](#when-to-) ## _Attribute Index_ @@ -542,6 +543,86 @@ Elem.pre [ Ds.jsonSignals ] [] Elem.pre [ Ds.jsonSignalsOptions (SignalsFilter.Include "/foo/") ] [] ``` +## _Rocket Components_ + +[Rocket](https://github.com/starfederation/datastar/tree/v1.0.4/library/src/rocket) is Datastar's web-component layer. +A component is defined in JavaScript with `rocket(tag, { props, setup, render })`; the server renders the tag and the parts of the contract the server owns: +the props the component reads from its attributes, and the signals, actions and template directives its children can use. +Load Datastar with `Ds.rocketCdnScript`. Nothing in `Rocket` sets a value you did not pass, so Rocket's own defaults apply. + +The [RocketComponents example](examples/RocketComponents) is a small app that uses all of the following. + +### `Rocket.propString | propNumber | propBool | propDate | propJson | propBin` + +Rocket reads each prop from the attribute named after it and decodes it with the [codec](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/rocket/codecs.ts) the component declared. +Each helper writes what the matching codec decodes: + +| Helper | Codec | Written as | +| --- | --- | --- | +| `Rocket.propString` | `string` | the text, escaped for the attribute | +| `Rocket.propNumber` | `number` | any numeric type, with the invariant culture (`1.5`, never `1,5`) | +| `Rocket.propBool` | `bool` | `true` or `false`, always written, because leaving it out means the prop's default, which may be true | +| `Rocket.propDate` | `date` | UTC ISO 8601 with milliseconds, like `Date.toISOString()` | +| `Rocket.propJson` | `json`, `array`, `object`, `tuple`, `oneOf` | camelCase JSON, or the options you pass | +| `Rocket.propBin` | `bin` | base64 | + +The attribute name is the prop name the way Rocket [derives it](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/utils/text.ts): `maxCount` is `max-count`, `innerHTML` is `inner-html`, and `pos3d` is `pos-3-d`. + +```fsharp +Elem.create "my-counter" + [ Attr.id "counter" + Rocket.propString ("label", "Clicks") + Rocket.propNumber ("step", 1) + Rocket.propNumber ("count", 0) ] + [] +``` + +```html + +``` + +To change a prop from the server, patch the element (the same `id`) with new attribute values. Rocket decodes them and re-renders the component. + +```fsharp +let handleChange : HttpHandler = fun ctx -> + Response.ofHtmlElements (Elem.create "my-counter" [ Attr.id "counter"; Rocket.propNumber ("count", 7) ] []) ctx +``` + +### `Rocket.local | Rocket.call | Rocket.root` + +Children of a light-DOM component (`mode: 'light'`), and the component's own render, can use signals and actions that belong to one instance of the component. +Rocket rewrites `$$name` to a path that is unique to the instance, so two instances do not share it, and `@name(...)` calls an action registered with `action('name', fn)` in the component's `setup`, falling back to Datastar's global actions. + +```fsharp +Elem.create "my-toggle" [ Attr.id "toggle" ] [ + Elem.button [ Ds.onClick (Rocket.call "flip") ] [ Text.raw "Toggle" ] + Elem.p [ Ds.show (Rocket.local "on") ] [ Text.raw "Now you see me." ] +] +``` + +Inside a component, Rocket also scopes the signal a `Ds.bind`, `Ds.computed`, `Ds.indicator` or `Ds.ref` names, and the signals a `Ds.signal` creates, to the instance. +`Rocket.root` opts a bind, computed, indicator or ref out, so it refers to the page's signal instead. It has no effect on `Ds.signal`, which Rocket always scopes ([source](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/rocket/template.ts#L467-L481)). + +```fsharp +Elem.input [ Rocket.root (Ds.bind "query") ] // : binds the page's $query +Elem.input [ Ds.bind "note" ] // binds this instance's $$note +``` + +### `Rocket.templateFor | templateIf | templateElseIf | templateElse` + +Rocket adds template directives that work on `