diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6b1020..c34b533 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,25 +5,89 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - dotnet-version: ['10.0.x'] + timeout-minutes: 20 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup .NET Core SDK ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v3 + # The library and its tests target net8.0, net9.0 and net10.0, so all three runtimes are needed. + # The SDK in global.json is 10.0.x. + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ matrix.dotnet-version }} + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x - - name: Install dependencies - run: dotnet restore src/Falco.Datastar + # The solution covers the library, the tests and every example, so building it is what catches an example that no + # longer compiles. It also catches a project path in the solution that does not match the folder on disk, which is + # a build that fails on Linux and passes on Windows. + - name: Restore + run: dotnet restore Falco.Datastar.sln - name: Build - run: dotnet build src/Falco.Datastar -c Release + run: dotnet build Falco.Datastar.sln -c Release --no-restore - name: Test - run: dotnet test test/Falco.Datastar.Tests -c Release + run: dotnet test test/Falco.Datastar.Tests -c Release --no-build + + # A library with warnings is not finished. The library itself treats them as errors, and this catches them + # anywhere else, such as in the tests and the examples. + # + # The project's own policy is that a NuGet advisory about a dependency is a warning and not a failure: see + # WarningsNotAsErrors in src/Falco.Datastar/Falco.Datastar.fsproj. NuGet replays those advisories on every build, + # not only on restore, so a gate on every warning would turn red on the day an advisory is published, with no + # change to this repository. They are listed here so they are reported and not swallowed, and not failed on. + - name: Check for warnings + run: | + set -o pipefail + dotnet build Falco.Datastar.sln -c Release --no-incremental 2>&1 | tee build.log + # The advisory warnings the project deliberately allows. Everything else is a failure. + if grep -E "warning [A-Z]+[0-9]+" build.log | grep -vE "warning NU19(01|02|03|04)"; then + echo "::error::the build produced warnings" + exit 1 + fi + echo "no warnings other than the advisories the project allows" + + # The mutation program is deliberately not in the solution, so nothing above builds it. That is what keeps it + # from being a dependency of the library, and it is also why it is built here: a syntax error in it would + # otherwise land green on every push and only surface in the weekly mutation run. + - name: Check the mutation program compiles + run: dotnet build test/mutation -c Release + + # Every F# example in the README and the changelog is compiled against the built library. A maintainer reads + # the README to decide whether this binding can be trusted, and an example that no longer compiles is the fastest + # way to lose them. It is a script rather than a test because it runs the F# compiler once per block, as a child + # process, so that each block is compiled on its own rather than seeing the blocks above it. + - name: Check the documented examples + run: dotnet fsi test/readme-examples.fsx + + # A package that cannot be packed cannot be published, and a missing README or icon only shows up when someone + # tries. Packing is the check that runs on every change, so a release is never the thing that finds out. + # It publishes nothing: how this library reaches NuGet is the maintainer's business, not this workflow's. + - name: Pack + run: dotnet pack src/Falco.Datastar -c Release --no-build -o packages + + # The contents are inside the .nupkg, which is a zip, so the entries have to be read out of it rather than + # looked for on disk. A package that is missing its README or its icon still packs, and that is only visible here. + - name: Check the package contents + run: | + set -euo pipefail + package=$(ls packages/Falco.Datastar.*.nupkg | grep -v symbols | head -1) + echo "checking $package" + unzip -l "$package" > package.txt + for expected in Falco.Datastar.nuspec README.md icon.png \ + lib/net8.0/Falco.Datastar.dll lib/net8.0/Falco.Datastar.xml \ + lib/net9.0/Falco.Datastar.dll lib/net10.0/Falco.Datastar.dll; do + if ! grep -qF "$expected" package.txt; then + echo "::error::$expected is missing from the package" + exit 1 + fi + echo "found $expected" + done diff --git a/.github/workflows/dst.yml b/.github/workflows/dst.yml new file mode 100644 index 0000000..eabe237 --- /dev/null +++ b/.github/workflows/dst.yml @@ -0,0 +1,62 @@ +name: dst + +# Runs the simulation tests with many seeds. A failure names the seed, and +# DST_SEED= dotnet test test/Falco.Datastar.Tests -c Release replays it. +# +# It runs on a schedule as well as on demand, because the default push runs only 25 seeds. That is enough to keep the +# suite honest on every change and quick enough to run each time, and the rare case that 25 seeds misses is exactly the +# one a scheduled run exists to find. Without it, "we ran 2000 seeds" is a claim about one afternoon. +on: + workflow_dispatch: + inputs: + seeds: + description: "How many seeds to run for each simulation test (1 to this number)" + required: true + default: "1000" + schedule: + # Weekly, early in the morning UTC. Offset from the hour so it does not queue behind every other scheduled run. + - cron: "23 4 * * 2" + +permissions: + contents: read + +jobs: + dst: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + # The default for a scheduled run, which has no inputs to read + - name: Work out how many seeds to run + id: seeds + run: | + requested="${{ inputs.seeds }}" + if [ -z "$requested" ]; then requested=2000; fi + if ! [ "$requested" -ge 1 ] 2>/dev/null; then + echo "::error::the seed count has to be a number of at least 1, but it was '$requested'" + exit 1 + fi + echo "count=$requested" >> "$GITHUB_OUTPUT" + echo "running $requested seeds" + + - name: Test with many seeds + run: dotnet test test/Falco.Datastar.Tests -c Release --framework net10.0 + env: + DST_SEEDS: ${{ steps.seeds.outputs.count }} + + # The same seeds on the oldest target the library supports, because a simulation that passes on one runtime and + # fails on another is still a failure. It is built here rather than with --no-build, because the step above only + # produced net10.0 and there is nothing to reuse for net8.0. + - name: Test the same seeds on the oldest target + run: dotnet test test/Falco.Datastar.Tests -c Release --framework net8.0 + env: + DST_SEEDS: ${{ steps.seeds.outputs.count }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..6dfccf0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,34 @@ +name: e2e + +# The browser tests only run when someone starts them: Actions tab, e2e, Run workflow. +# They load Datastar from a CDN and start a real Chromium, so they are not part of the build that runs on every push and pull request. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + # The tests run on .NET 10. The RocketComponents example targets net9.0, and the library targets net8.0 as well. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Build + run: dotnet build test/Falco.Datastar.E2E -c Release + + - name: Install Chromium + run: pwsh test/Falco.Datastar.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Test + run: dotnet test test/Falco.Datastar.E2E -c Release --no-build diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..81fa62f --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,78 @@ +name: mutation + +# Proves that the test suite catches the mistakes this library could plausibly make. See test/mutation. +# +# It rewrites a line of src for each mutant and runs the whole suite, so it is slow and it must not run alongside +# anything else that builds the repository. +# +# It runs on a schedule as well as on demand. The reason is that "every mutant is killed" is a claim about the tests as +# well as the source, and it rots quietly: delete a test that was the only thing catching a mutant, or add one that is +# too weak to catch it, and nothing fails until someone remembers to click Run workflow. A scheduled run turns that into +# a red build instead of a sentence nobody has checked in a year. +on: + workflow_dispatch: + inputs: + filter: + description: "Only run the mutants whose name contains this text" + required: false + default: "" + schedule: + # Weekly, early in the morning UTC. Not on the hour, because every scheduled workflow on the planet fires at :00 and + # the runners queue for them. + - cron: "37 4 * * 1" + +permissions: + contents: read + +jobs: + mutation: + runs-on: ubuntu-latest + # A full run mutates the source about ninety times and builds and tests after each one, which takes a while. + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + # The program runs dotnet test against net10.0 only, because the code under test is the same for every target. + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # It rewrites files in src, so it needs the tree exactly as committed, otherwise it cannot put it back. + - name: Check the tree is clean + run: git diff --exit-code + + - name: Check the mutant list against the source + # Fails if a mutant no longer finds its text, so the run below does not spend twenty minutes on a list that is stale + run: | + dotnet build test/mutation -c Release + dotnet run --project test/mutation -c Release --no-build -- check + + - name: Build the library + run: dotnet build src/Falco.Datastar -c Release + + # The filter goes through the environment rather than into the command, so a value with a quote or a semicolon in + # it is one argument and not shell syntax. dst.yml does the same. + - name: Mutate + env: + DST_MUTATION_FILTER: ${{ inputs.filter }} + run: | + if [ -n "$DST_MUTATION_FILTER" ]; then + dotnet run --project test/mutation -c Release --no-build -- "$DST_MUTATION_FILTER" + else + dotnet run --project test/mutation -c Release --no-build + fi + + # A run that is killed cannot restore the source, so a backup left behind means the tree may still be mutated and + # the next job would start from it. Cleaning up here means a cancelled run does not poison the following one. + - name: Put the source back + if: always() + run: dotnet run --project test/mutation -c Release --no-build -- clean + + - name: Check the source was put back + run: | + if [ -n "$(ls src/Falco.Datastar/*.mutation-backup 2>/dev/null)" ]; then + echo "::error::a backup file was left behind, so a mutant was not restored" + exit 1 + fi + git diff --exit-code diff --git a/.gitignore b/.gitignore index 55072a0..3905605 100644 --- a/.gitignore +++ b/.gitignore @@ -354,3 +354,6 @@ MigrationBackup/ # Rider (JetBrain's cross-platform .NET IDE) working folder .idea/ + +# Local tooling preferences, not project source +.commandcode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cc1bd1d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,171 @@ +# Changelog + +## 1.4.0 + +### What is new + +- Typed signals, expressions and statements: `Signal.browser`, `Signal.server`, `Signal.rocket`, `Expr` and `Stmt`, and overloads of the `Ds` attributes that take them. +- Rocket helpers: props, component signals, template directives and a reader for the manifest that `publishRocketManifests` posts. +- `Ds.nonce` for Content Security Policy mode, `Ds.withCase` for the `__case` modifier, `Ds.bindProp` and `Ds.bindEvent`, `Ds.query`, `Ds.peek`, `Ds.setAllFiltered` and `Ds.toggleAllFiltered`. +- `RequestCancellation.Cleanup` and `OnEventModifier.Document`, and, from SDK 1.4.0, `PatchElementsOptions.ViewTransitionSelector`. +- Datastar 1.0.4 is the default script, and Rocket has its own script helper, `Ds.rocketCdnScript`. +- Names are checked. A signal name that Datastar would read differently from an expression, and a name that could end an attribute name early, raise an error that says what to write. See [names that are refused](#names-that-are-refused). +- Request options that never worked now do: `FilterSignals` and `AbortController`. See the output changes below. +- The test suite covers every rule in four ways: named tests that pin the output, property tests that hold for any input, deterministic simulation tests seeded so a failure replays, and mutation tests that make a deliberate mistake in the source and require the suite to notice. Nothing in the public API changes because of this. All 98 mutations are accounted for: 96 are caught by the suite, and the two that cannot be are marked equivalent with the measurement that shows why. +- Every F# example in this file and in the README is compiled against the built library on every build, so an example that no longer compiles fails CI rather than the next reader. + +### Upgrading + +This section covers upgrading from version 1.3.0 or earlier. It lists what can stop your code compiling, what can produce warnings, what can clash with your own names, what changes the output without any compiler message, what changes in your dependencies, and what changes in Datastar itself. + +#### Changes that stop your code compiling + +**`OpenWhenHidden` is now `bool voption`.** The compiler reports error FS0001. Wrap the value in `ValueSome`: + +```fsharp +// README-EXCERPT fragments, not a whole program: each line is the body of a binding in your code. +// The first one no longer compiles, which is what the section above is about. +let options = + // before + { RequestOptions.Defaults with OpenWhenHidden = true } + + // after + { RequestOptions.Defaults with OpenWhenHidden = ValueSome true } +``` + +Take care with `OpenWhenHidden = false`. The old code never sent it, so `@post`, `@put`, `@patch` and `@delete` ignored it and kept running while the page was hidden, because that is Datastar's default for them. +`ValueSome false` is sent, so those requests are now cancelled when the page is hidden and started again when it is visible. +If you want the old behaviour, delete the line. On a `@get`, `ValueSome false` is the same as leaving it out. + +**Overloads.** The typed `Ds.text`, `Ds.show`, `Ds.class'`, `Ds.signal`, `Ds.computed`, `Ds.indicator`, `Ds.onEvent`, `Ds.onClick`, `Ds.onInit`, `Ds.effect`, `Ds.onInterval`, `Ds.onIntersect` and `Ds.onSignalPatch` are overloads of the string versions. +Code that passes an argument whose type is not yet known, such as `let click handler = Elem.button [ Ds.onClick handler ] []`, or `List.map Ds.show`, now fails with error FS0041, "A unique overload for method could not be determined based on type information prior to this program point". +Add a type annotation: `let click (handler: string) = ...`. Code that passes a string or a typed value directly needs no change. `Ds.signal` is also no longer `inline`, which changes nothing for callers. +The examples above are compiled as part of the test suite, so the compiler checks this advice stays true. + +**The arithmetic operators and `Rocket.propNumber` now need a numeric type.** They used to take anything that was `IFormattable`, which `string`, `DateTime`, `DateTimeOffset`, `Guid` and `TimeSpan` all are, so `Expr.add` on a `Signal` compiled and produced `$a + $b`. They now need a type that JavaScript's number type stands for: `int`, `int64`, `int16`, `sbyte`, `byte`, `uint16`, `uint32`, `uint64`, `float`, `float32`, `double`, `decimal` or `bigint`. Anything else fails with FS0001. +Code that was adding up something that is not a number was already writing meaningless JavaScript, so no working code changes. If you need text, use `Expr.concat`. + +#### Changes that produce warnings + +Three types have a new case: `BackendAction.Query`, `RequestCancellation.Cleanup` and `OnEventModifier.Document`. +A `match` that lists every case of one of these types now gets warning FS0025, which is an error if your project treats warnings as errors. +Add a case for the new value, or a `_` case. Until you do, a value that reaches the `match` without a case raises `MatchFailureException`. + +#### Name clashes + +`open Falco.Datastar` now brings `Query`, `Cleanup` and `Document` into scope as union cases, and `Expr`, `Stmt` and `Signal` into scope as types and modules. `Expr` is also the name of `Microsoft.FSharp.Quotations.Expr`. +If your own code declares a union case with one of these names, and you open `Falco.Datastar` *after* that declaration, your name now means Falco's case. You get errors such as "expected to have type `Msg` but here has type `BackendAction`". +Either move `open Falco.Datastar` above your type, or put the type name in front of the case: + +```fsharp +// README-EXCERPT a fragment: msg and Msg are from the surrounding example, not defined here +match msg with +| Msg.Query text -> text +| Msg.Cleanup -> "cleaned up" +``` + +A module or type of your own called `Rocket` does not clash. + +#### Changes that compile but produce different output + +**`RocketManifest.parse` says a property of the wrong kind instead of reading it as absent.** `"required":"yes"` was read as `Required = false`, and a numeric `bubbles` disappeared, which are different claims from the ones the manifest makes. Those properties now give `WrongKind`, naming the property and where it is: a prop's `required`, `docs.description`, `docs.label`, `docs.control` and `docs.placeholder`, a slot's `description`, and an event's `kind`, `bubbles`, `composed` and `description`. A property that is absent, or `null`, is still read as absent. A manifest Rocket wrote is not affected. + +**`Rocket.propNumber` refuses a value that is not a finite number.** Rocket's number codec runs `Number(value)` and turns anything that is not finite into `0`, so `Double.NaN` and the infinities reached the component as `0` rather than as an error. They now raise an `ArgumentException` that says so. + +**A line separator after a backslash in a signals filter is escaped.** U+2028 and U+2029 are line terminators, and a JavaScript regular expression literal cannot contain one, so `SignalsFilter.Include "a\\b"` used to write a literal that does not parse, and the whole filter expression failed. It now writes `\u2028`, as it already did for a separator that had no backslash in front of it. + +**A prop name gets the same attribute name whatever the server's culture is.** Datastar's kebab function has no culture, but the two case-insensitive regexes it was copied into folded by .NET's current culture. Under Turkish, `"rowI1"` became `row-i1` where Rocket reads `row-i-1`, and the attribute name is cached, so the wrong name was kept for the life of the process. Both now fold the way JavaScript does. + +**`Request.getRocketManifests` refuses a body that is not valid UTF-8.** `Encoding.UTF8` 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 documentation string quietly altered, and then parsed as a manifest. It now decodes strictly and returns `NotJson` for such a body. A manifest Rocket wrote is not affected. + +**Text in expressions is escaped.** `Ds.get`, `Ds.post`, `Ds.put`, `Ds.patch`, `Ds.delete` and `Ds.query` now escape the URL, and `Ds.signal` escapes its value. +A URL such as `/items?a=1&b=2` is written with `&`, which the browser reads as `&`. Before, a `'` in a URL or in a text value broke the expression, and text from a user could run code. +Tests that compare the generated text may need new expected values. + +**`Ds.setAll` and `Ds.toggleAll`.** They used to write `@setAll('foo.', true)` and `@toggleAll('foo.')`. Datastar reads `@setAll(value, filter)` and `@toggleAll(filter)`, so the prefix was being taken as the value and the value as the filter, and neither call did anything. They now write `@setAll(true, { include: /^foo\./ })` and `@toggleAll({ include: /^foo\./ })`. Your code needs no change. + +The prefix is also escaped now, so a prefix that contains a character with a meaning in a regular expression is matched literally. `Ds.setAll("foo.", true)` is unchanged, because a trailing dot matches itself. + +**`RequestOptions.Retry`.** It used to be ignored, because the library never wrote it. `Retry = OnError`, `OnAlways` and `OnNever` are now sent, so if your code sets one of them, the retry behaviour of that request changes. +`OnNever` stops the retries of a response that is not 200. Datastar 1.0.4 still retries after a network error, up to `RetryMaxCount` times. + +**`RequestOptions.RetryMaxWait`.** It was written as `retryMaxWaitMs`. Datastar stopped reading that name in 1.0.0 (RC.8 and earlier read it), so the setting was ignored. It is now written as `retryMaxWait`, which Datastar 1.0.4 reads. + +**`ContentType = CustomJson obj`.** It used to send an option called `override`, which Datastar does not have, so Datastar ignored it and sent the signals as usual. +It now sends your object as the request body. If your server code expects the signals, change it to expect your object, or stop using `CustomJson`. + +**`OpenWhenHidden = ValueSome true`** is written as the JSON boolean `true`. It used to be written as the text `"true"`. + +**`FilterSignals`, `AbortController` and `Headers`.** `FilterSignals` with any pattern used to raise `JsonReaderException`, because the filter text is not JSON. It is now sent as `{"filterSignals":{"include":"^foo"}}`. +A filter that has an exclude but no include keeps Datastar's rule that signals whose names start with an underscore stay in the browser, which Datastar would otherwise drop when it is given an exclude of its own. +`AbortController "$controller"` used to be sent as the text `"$controller"`, which Datastar ignores. It is now sent as the signal `$controller`. A `RetryScaler` that is not a number, and an `AbortController` without a name, raise an `ArgumentException` that says what to write. + +**A header name that is given twice now raises**, where before it was written out. The check ignores case, so `[ "X-A", "1"; "x-a", "2" ]` used to produce both keys and now raises an `ArgumentException`: a request sends each header name once, and the browser cannot tell you which one won. Put the values in one header, separated by commas. + +**`RequestCancellation = AbortController` is checked.** The name goes into the page as code, because Datastar only accepts an `AbortController` object and not the name of one. It is now checked to be the name of a signal, such as `"$controller"`, `"$_controller"` or `"$form.controller"`, and anything else raises an `ArgumentException` that says so. +Before this, any text could be written as code there. If you wrote the name of a variable that is not a signal, such as `AbortController "$ctl"` where `$ctl` is a local, that code raised an `ArgumentException` from now on. Declare it as a signal with `data-signals:_ctl="new AbortController()"`. + +**`ContentType = CustomJson null`** now sends `"payload": null`. It used to raise a `NullReferenceException` from inside the serializer. + +**Signals filters.** `SignalsFilter.Serialize` now returns text that is ready for an attribute, so if you put its result in an attribute yourself, do not encode it again. `SignalsFilter.Include`, `Exclude` and `Prefix` patterns are regular expressions without the slashes around them. The library escapes a slash and a line break inside the pattern, and encodes the pattern for the attribute. +`Ds.onSignalPatchFilter` and `Ds.jsonSignalsOptions` used to write the pattern as it was, so a quote in it ended the attribute. A pattern that you wrote with slashes around it, such as `"/foo/"`, is now a pattern for the text `/foo/`. Remove the slashes. + +**Carriage returns and NUL characters in attribute values.** An HTML parser changes a carriage return in an attribute value into a line feed, and a NUL character into U+FFFD, so `Rocket.propString ("label", "a\rb")` arrived in the browser as different text. +A carriage return is now written as ` `, which a parser keeps. HTML cannot keep a NUL in an attribute, so it is written as U+FFFD, which is what a browser would read. In a JavaScript string literal, a NUL is written as `\u0000`. + +**`Ds.safariStreamingFix`** wrote `data-on:pageshow.window`. Datastar 1.0.4 reads modifiers after `__`, so that attribute listened for an event called `pageshow.window` and never ran. It now writes `data-on:pageshow__window`. + +**Expressions built with `Expr.unsafeRaw` and `Stmt.unsafeRaw`** are escaped for the attribute, and `Expr.unsafeRaw` puts text that is more than a name in parentheses. `Expr.divide` on whole numbers cuts the result to a whole number. + +**`Ds.cdnSrc` and `Ds.cdnScript`.** They now load Datastar 1.0.4. They loaded 1.0.0-RC.7 before, so your pages move across several Datastar releases. +The changes in Datastar that can affect your pages are listed under [Changes in Datastar itself](#changes-in-datastar-itself), and the full list is in the [Datastar release notes](https://github.com/starfederation/datastar/releases). +To stay on the old script for now, write the tag yourself. Note that some things need a newer script than RC.7, which is what this library was on before: `Ds.bindProp`, `Ds.bindEvent` and `OnEventModifier.Document` need 1.0.0, `Ds.nonce` needs 1.0.3, and the Rocket helpers need the 1.0.4 bundle. `Ds.query`, `RequestCancellation = Cleanup` and `RequestOptions.RetryMaxWait` need a newer script too. + +```fsharp +Elem.script [ Attr.type' "module"; Attr.src "https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js" ] [] +``` + +#### Names that are refused + +Some names used to be accepted and never worked. They now raise an `ArgumentException` that says what to write. + +- A name that goes into an attribute name, such as the class in `Ds.class'`, the event in `Ds.onEvent`, the attribute in `Ds.attr'`, the property in `Ds.style`, a Rocket prop name, and the events of `Ds.bindEvent`: it cannot be empty, contain whitespace, a quote, `=`, `/`, `<` or `>`, or contain a double underscore. Datastar reads `__` as the start of a modifier, so `Ds.class' ("card__title", ...)` toggled a class called `card`. + The name of the plugin itself and the name of each modifier are checked the same way, so building a `DsAttr` by hand cannot put a quote into an attribute name. +- A typed signal name, from `Signal.browser`, `Signal.server`, `Signal.rocket` and `Signal.tryCreate`: a part cannot start with a capital letter, end with an underscore, or have two underscores in a row. HTML makes attribute names lower case, so `Signal.server "Menu"` was declared as `menu` and read as `$Menu`. +- `Rocket.forEach` needs item and index names that are JavaScript identifiers, and `Stmt.all` needs at least one statement. + +Reading a Rocket signal in an expression is unchanged and still writes `$$name`. + +`Signal.tryCreate` returns a `SignalNameError` instead of text. `RocketManifest.parse` and `Request.getRocketManifests` return a `RocketManifestError`. `Request.getRocketManifests` also returns `ConnectionFailed` and `Cancelled` when the connection fails or the request is cancelled, instead of throwing. +`SignalScope`, `SignalNameError` and `RocketManifestError` are `RequireQualifiedAccess`, so their cases do not clash with your names. + +**`Ds.ref` now escapes the signal name.** It wrote the name into the attribute's value with no escaping, and Falco.Markup does not escape attribute values, so a name containing a quote ended the attribute and added attributes of its own. `Ds.ref "a\" onmouseover=\"x"` rendered as `
`. The name is now escaped, the way `Ds.nonce` already was. A name with no quote, angle bracket or ampersand in it, which is every name that was working before, renders exactly as it did. + +#### Changes to your dependencies + +`Falco.Datastar` now depends on `StarFederation.Datastar.FSharp` 1.4.0. It depended on 1.2.0. Your project picks up the new version by itself, and three things change. + +Reading signals for a `@delete` now works. Datastar sends them in the `datastar` query parameter, and versions 1.2.0 and 1.2.1 of the SDK only read that parameter for a `GET`, so `Request.getSignals` and `Request.getSignalsJson` came back empty for a `@delete`. Version 1.3.0 added `DELETE` to that check. If you worked around this by reading the query string yourself, you can remove the workaround. + +If your own project references `StarFederation.Datastar.FSharp` directly at a version below 1.3.0, NuGet only warns (`NU1605`, "Detected package downgrade") and uses your older version, and the `@delete` problem comes back. Raise that reference to 1.4.0, or remove it. + +`PatchElementsOptions` has a new field, `ViewTransitionSelector`, for scoped view transitions. `PatchElementsOptions.Defaults with ...` needs no change, but code that calls the constructor must pass the new argument. +Everything the SDK sends is otherwise the same as before. + +#### Changes in Datastar itself + +Moving from the RC.7 script to 1.0.4 changes how some pages behave, whatever your F# code does. These are the changes from the [release notes](https://github.com/starfederation/datastar/releases) that can affect a page built with this library. + +**A `$name` inside quotes is no longer replaced (RC.8).** In an expression, `'Hello $name'` now stays as the text `Hello $name`. Join the parts instead, as in `'Hello ' + $name`, or use a template literal, where `${...}` is still replaced: `` `Hello ${$name}` ``. Since 1.0.4 the same holds for `@action(` inside a string. Both forms were checked in a browser. + +**A request is no longer cancelled when its element is removed (RC.8).** `RequestCancellation.Auto` used to do that. Use `RequestCancellation = Cleanup` if you want it. + +**Requests with the same method and URL cancel each other (1.0.2).** Starting a `@get('/items')` cancels an earlier `@get('/items')` that is still running, even when a different element started it. Use `RequestCancellation = Disabled` to let both run. + +**A `@get` or `@delete` has no body and no `Content-Type` header (1.0.0).** Server code that looked for `application/json` on those requests will no longer find it. The signals are in the `datastar` query parameter. + +**Checkboxes and radio buttons update the signal on `input` (1.0.2).** `Ds.bind` used to use `change` for them. Use `Ds.bindEvent` to choose the events yourself. + +**A patch that changes an input's property fires `datastar-prop-change` and not `change` (1.0.0).** If you relied on the native `change` event after a server patch, listen for the new event with `Ds.onEvent ("datastar-prop-change", ...)`. + +**A retried request sends the current signals (RC.8 and 1.0.3).** Before, a retry could send the values the signals had when the first attempt started. diff --git a/Falco.Datastar.sln b/Falco.Datastar.sln index 34d7628..f677155 100644 --- a/Falco.Datastar.sln +++ b/Falco.Datastar.sln @@ -12,14 +12,16 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ClickToLoad", "examples\Cli EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ClickAndSwap", "examples\ClickAndSwap\ClickAndSwap.fsproj", "{8E2C0AA3-07E5-40D8-8F16-905F5F1D30AE}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signals", "Examples\Signals\Signals.fsproj", "{0187E584-68DF-4D8C-874C-FF8E061932DF}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Signals", "examples\Signals\Signals.fsproj", "{0187E584-68DF-4D8C-874C-FF8E061932DF}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Streaming", "Examples\Streaming\Streaming.fsproj", "{AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}" +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Streaming", "examples\Streaming\Streaming.fsproj", "{AECB950B-20DD-40C4-9ACA-3A8FF1CFBC05}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.Datastar.Tests", "test\Falco.Datastar.Tests\Falco.Datastar.Tests.fsproj", "{772A0FC5-A796-4408-9751-53842F8B8C77}" 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 2727181..e8aec0d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ let demo = It provides a complete mapping of all [attribute plugins](https://data-star.dev/reference/attributes) and [action plugins](https://data-star.dev/reference/actions). As well as helpers for retrieving the signals and responding with Datastar Server Side Events. +Upgrading from version 1.3.0 or earlier? See the [changelog](https://github.com/falcoframework/Falco.Datastar/blob/main/CHANGELOG.md). + ## Key Features - Idiomatic mapping of `data-*` attributes (e.g. `data-text`, `data-bind`, `data-signals`, etc.). - Helper functions for reading signals and responding with Datastar Server Side Events. @@ -32,7 +34,7 @@ First off, for any questions or criticisms of this library or [Datastar](http:// please join our [Discord](https://discord.com/channels/1296224603642925098/1334541716497109042), where we are definitely not a cult. This guide assumes you have a [Falco](https://github.com/falcoframework/Falco) project setup. If you don't, you can create a new Falco project using the following commands. -The full code for this guide can be found in the [Hello World example](examples/HelloWorld). +The full code for this guide can be found in the [Hello World example](https://github.com/falcoframework/Falco.Datastar/tree/main/examples/HelloWorld). ```shell > dotnet new web -lang F# -o HelloWorld @@ -45,7 +47,7 @@ Install the nuget package: > dotnet add package Falco.Datastar ``` -Remove any `*.fs` files created automatically, crate a new file name `Program.fs` and set the contents to the following: +Remove any `*.fs` files created automatically, create a new file named `Program.fs` and set the contents to the following: ```fsharp open Falco @@ -63,6 +65,9 @@ wapp.UseRouting() .Run() ``` +`Ds.cdnScript` loads Datastar from the jsDelivr CDN. It is fixed to the release named in `Ds.datastarVersion`, currently Datastar 1.0.4. +To use [Rocket](https://github.com/starfederation/datastar/tree/v1.0.4/library/src/rocket), Datastar's web components, load `Ds.rocketCdnScript` instead. It is the same release with Rocket included, so load one script 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. @@ -92,6 +97,7 @@ let handleClick : HttpHandler = And lastly, we'll make Falco aware of these routes by adding them to the `endpoints` list. ```fsharp +// README-EXCERPT a fragment: handleIndex and handleClick are the handlers defined above let endpoints = [ get "/" handleIndex get "/click" handleClick ] @@ -103,29 +109,192 @@ Save the file and run the application: dotnet run ``` -Navigate to `https://localhost:5001` in your browser and click the button. You should see the text "Hello, World, from the Server!" appear in the place of the button. +Open the address that `dotnet run` prints on the line that starts with `Now listening on`, and click the button. You should see the text "Hello, World, from the Server!" appear in the place of the button. Jump to [Signal Reading and Server Side Events](#reading-signals-and-server-side-events). +## The Tao of Datastar + +Datastar's authors describe [the way they mean it to be used](https://data-star.dev/guide/the_tao_of_datastar). This library tries to make that way the easy one. + +| The Tao says | In this library | +| --- | --- | +| Most state lives on the backend, which is the source of truth. | Your handlers own the state. Nothing here keeps state for you. | +| Use signals sparingly: for user interactions, and for sending new state to the backend. | Every [signal has a kind](#signals-expressions-and-statements-in-f). `Signal.browser` never leaves the browser, and `Signal.server` is sent with requests. | +| The backend drives the frontend by patching elements and signals. | The `Response` functions patch elements and signals. | +| There is no real benefit to a content type other than `text/event-stream`. | The `Response` functions send Server Side Events. | +| Start with the defaults. | An option that you leave at `RequestOptions.Defaults` is not sent, so Datastar's own default applies. | +| In morph we trust: send big chunks, up to the whole page. | `Response.ofHtmlElements` patches by element `id`, with Datastar's default outer morph. | +| Compress the stream. | See [Compression](#compression). | +| Use CQRS: one long-lived request reads, short requests write. | See [CQRS](#cqrs). | +| Show a loading indicator, and confirm success only from the backend. | Use `Ds.indicator` with a `Signal.browser`. The library has no helper for optimistic updates. | +| Use anchors to navigate, and let the browser keep the history. | The library has no navigation or history helper. | + +### CQRS + +One long-lived request reads. The server writes to it whenever something changes. Short requests write, and the change comes back over the stream. + +```fsharp +// The long-lived request. The server sends an update whenever the state changes. +Elem.body [ Ds.onInit (Stmt.get "/updates") ] [ + // A short request. The server answers 204 No Content, and the change arrives on the stream. + Elem.button [ Ds.onClick (Stmt.post "/items/add") ] [ Text.raw "Add" ] +] +``` + +A 204 response ends without an error and is not retried. The [RocketComponents example](https://github.com/falcoframework/Falco.Datastar/tree/main/examples/RocketComponents) is a whole working app: one agent owns the state, and every visitor sees each change as it happens. + +Two details of Datastar's behaviour matter here. The Tao advises showing a loading indicator by hand and hiding it when the backend's update arrives. +`Ds.indicator` turns off when the request ends, and a 204 ends the request before the update comes, so for a write that answers 204, set the indicator yourself and clear it when the update arrives. +Also, `@get` stops its request when the page is hidden and opens it again when the page is visible (`openWhenHidden` is false for `@get`), so the server should send the current state first on every new stream, as the example does. + +Both of those mean the read handler sends the state before it waits for changes, rather than waiting first: + +```fsharp +// README-EXCERPT a fragment: counter is the signal defined above, and this continues the stream handler +let handleUpdates : HttpHandler = fun ctx -> task { + do! Response.sseStartResponse ctx + // The current state first. A new visitor needs it, and so does one whose stream was reopened after the page was hidden. + do! Response.ssePatchSignal ctx (SignalPath.sp "count") (counter.Current) + let mutable last = counter.Current + while true do + let next = counter.WaitForChange last // your own way of waiting for the next value + last <- next + do! Response.ssePatchSignal ctx (SignalPath.sp "count") next +} +``` + +`Ds.indicator` goes on the same element as the action that makes the request, because Datastar only sets the signal for requests made by that element. On a wrapper around the button it would never move. + +### Compression + +A stream of morphs compresses very well. ASP.NET Core does not compress `text/event-stream` unless you add it: + +```fsharp +open Microsoft.AspNetCore.ResponseCompression + +let builder = WebApplication.CreateBuilder() + +builder.Services.AddResponseCompression(fun options -> + options.EnableForHttps <- true + options.Providers.Add() + options.MimeTypes <- Seq.append ResponseCompressionDefaults.MimeTypes [ "text/event-stream" ]) +|> ignore + +let wapp = builder.Build() +wapp.UseResponseCompression() |> ignore +``` + +Each event still reaches the browser as soon as it is sent. In the example the stream is sent with `content-encoding: br`, and a second window shows each change as it arrives. +`EnableForHttps` compresses HTTPS responses too. A response that holds both a secret, such as a CSRF token, and text that an attacker can choose can leak the secret (the BREACH attack), so keep such responses out of the stream, or leave `EnableForHttps` off. + ## Signals and Expressions -Datastar uses signals to manage state. Signals are reactive variables that automatically track and -propagate changes in [Datastar expressions](https://data-star.dev/guide/datastar_expressions). -They can be created and modified using data attributes on the frontend, or events sent from the backend. +Signals are reactive variables in the browser. When one changes, every binding and expression that reads it updates. +They can be created and changed with data attributes on the frontend, or by events sent from the backend. + +Use them sparingly. The Tao of Datastar says that most state lives on the backend, and that signals are for user interactions, such as toggling an element, +and for sending new state to the backend, such as binding a form input. See [The Tao of Datastar](#the-tao-of-datastar). [Datastar expressions](https://data-star.dev/guide/datastar_expressions) are strings that are evaluated by bindings, events, and triggers. Updating a signal value in an expression will cause other bindings and expressions to update elsewhere. Some important notes: Signals defined later in the DOM tree override those defined earlier. -`data-*` attributes are [evaluated in the order they appear in the DOM](https://data-star.dev/examples/plugin_order); meaning that signals need to be specified before they can be used. +`data-*` attributes are [evaluated in the order they appear in the DOM](https://data-star.dev/reference/attributes#attribute-evaluation-order); meaning that signals need to be specified before they can be used. + +### Signals, expressions and statements in F# + +Most functions above take JavaScript as a string, such as `Ds.text "$count"`. Nothing checks the string until the browser runs it. +This library also has typed versions. The compiler then catches a signal that does not exist, a condition that is not a boolean, and a value of the wrong type. +The string versions still work. + +A `Signal<'T>` has a type and a kind. The kind decides whether Datastar sends the signal to the server: + +- `Signal.browser<'T> "name"` stays in the browser. Datastar never sends it, because its name starts with an underscore, which the function adds for you. Use it for what the user does on the page. +- `Signal.server<'T> "name"` is sent with every request. Use it for new state that the backend needs, such as the value of a form input. +- `Signal.rocket<'T> "name"` belongs to one instance of a [Rocket component](#rocket-components). Rocket keeps these under a private path, so Datastar never sends them. It is refused by the helpers that put a name in an attribute, because a component's own signal only exists where the component declared it, and elsewhere the name would quietly mean a page signal that two components share. + +```fsharp +let count = Signal.browser "count" +let name = Signal.server "form.name" + +Elem.div [ Ds.signal (count, 0); Ds.signal (name, "Ada") ] [ + Elem.button [ Ds.onClick (Stmt.set count (Expr.add (Expr.read count) (Expr.int 1))) ] [ Text.raw "+" ] + Elem.span [ Ds.text (Expr.read count) ] [] + Elem.input [ Ds.bind name ] + Elem.button [ Ds.onClick (Stmt.post "/save") ] [ Text.raw "Save" ] // sends form.name only +] +``` + +In a browser, the save request carried only `{"form":{"name":"..."}}`. The browser signal `count` stayed on the page. + +A name that Datastar cannot use is refused with a message that says what to write. Datastar reads the name of a signal from an attribute name, and HTML makes attribute names lower case, +so the name has to read the same there and in an expression. These names are refused: + +- a name with a hyphen. Datastar's signal pattern treats `.` and `-` as part of a name, so `$foo-bar` is one signal called `foo-bar`, not `$foo` minus something. That is fine in an expression, but the name reaches the signal through an attribute name, where the HTML parser has already lowercased it and the case cannot be put back. So `myCount` and `my-count` would be two different signals. Write `myCount`. +- a name that starts with an underscore. Datastar keeps such a signal in the browser, so a server signal cannot have one, and `Signal.browser` adds the underscore itself. +- a part that starts with a capital letter, such as `Menu` or `form.First`. Start each part with a lower case letter, for the same reason. +- a double underscore, which Datastar reads as the start of a modifier, and an underscore at the end of a part. +- anything that is not letters, digits, underscores and dots. + +`Signal.tryCreate scope name` returns the reason as a `SignalNameError`, which has a `Message`. `Signal.browser`, `Signal.server` and `Signal.rocket` raise an `ArgumentException` with the same message. + +```fsharp +match Signal.tryCreate SignalScope.Server "Menu" with +| Ok signal -> Ds.signal (signal, 0) +| Error (SignalNameError.StartsWithCapital name) -> failwith $"rename {name}" // the case tells you what is wrong +| Error error -> failwith error.Message +// error.Message: The signal name 'Menu' has a part that starts with a capital letter. Datastar reads the name from an attribute name, +// and HTML makes attribute names lower case, so the signal would be called 'menu' there but 'Menu' in an expression. +// Start each part with a lower case letter, for example 'menu'. +``` + +The cases are `Blank`, `HasHyphen`, `StartsWithUnderscore`, `StartsWithCapital`, `HasDoubleUnderscore`, `EndsWithUnderscore` and `NotAPath`. They are `RequireQualifiedAccess`. + +An `Expr<'T>` is an expression with a value, and a `Stmt` is something that is done. Build them with these functions: + +| For | Functions | +| --- | --- | +| Values | `Expr.int`, `Expr.float`, `Expr.bool`, `Expr.string`, `Expr.read signal` | +| Numbers | `Expr.add`, `Expr.subtract`, `Expr.multiply`, `Expr.divide`, `Expr.remainder` | +| Comparing | `Expr.equal`, `Expr.notEqual`, `Expr.greater`, `Expr.less`, `Expr.atLeast`, `Expr.atMost` | +| Booleans | `Expr.andAlso`, `Expr.orElse`, `Expr.negate` | +| Choosing | `Expr.ifElse condition whenTrue whenFalse` | +| Text | `Expr.concat`, `Expr.toText` | +| Not subscribing | `Expr.peek`, which reads a value without running the expression again when it changes | +| Doing | `Stmt.set signal value`, `Stmt.toggle signal`, `Stmt.all [ ... ]` | +| Many signals | `Stmt.setAll prefix value`, `Stmt.toggleAll prefix`, and `Stmt.setAllWhere` / `Stmt.toggleAllWhere` with a `SignalsFilter` | +| Backend actions | `Stmt.get`, `Stmt.post`, `Stmt.put`, `Stmt.patch`, `Stmt.delete`, `Stmt.query`, and the same with `With` and a `RequestOptions` | + +`Ds.text`, `Ds.show`, `Ds.class'`, `Ds.attr'`, `Ds.style`, `Ds.computed`, `Ds.signal`, `Ds.bind`, `Ds.indicator`, `Ds.onEvent`, `Ds.onClick`, `Ds.onInit`, `Ds.effect`, `Ds.onInterval`, +`Ds.onIntersect` and `Ds.onSignalPatch` all have overloads that take them. The typed `Ds.show` takes a boolean expression, and the typed `Ds.onClick` takes a statement. + +```fsharp +// README-EXCERPT fragments: count and menuOpen are the signals declared above +Expr.toString (Expr.divide (Expr.read count) (Expr.int 5)) // Math.trunc($_count / 5), because count is an int +Expr.toString (Expr.unsafeRaw "$a || $b") // ($a || $b), so Expr.negate gives (!($a || $b)) +Expr.toString (Expr.string "it's") // 'it\'s' +Stmt.toString (Stmt.all [ Stmt.set count (Expr.int 0); Stmt.toggle menuOpen ]) // $_count = 0; $_menuOpen = !$_menuOpen +``` + +Some details follow. Operators are written with spaces, because Datastar reads `$a-1` as a signal called `a-1`. `Expr.add` and the other arithmetic functions take numbers, and text is joined with `Expr.concat`. +JavaScript has one kind of number, so `7 / 2` is 3.5 there. `Expr.divide` on whole numbers, such as `int`, cuts the result to a whole number, so an `int` signal never holds a fraction. +Text in `Expr.string`, and the URL in `Stmt.get` and the like, is escaped, so it stays text whatever it contains. +The text inside every `Expr` and `Stmt` is escaped for use in an attribute. `Expr.toString` and `Stmt.toString` return it, for the string helpers such as `Ds.text`. It is not meant for a script element, where the escapes would show. + +For what the typed functions do not cover, `Expr.unsafeRaw` and `Stmt.unsafeRaw` pass JavaScript through. Nothing checks that it is valid JavaScript. The library escapes it for the attribute, and `Expr.unsafeRaw` puts it in parentheses when it is more than a name, so an operator around it applies to all of it. Never build it from text that a user can change. +`Stmt.all` needs at least one statement, because an attribute with an empty expression makes Datastar throw. +The same goes for the string helpers: pass user values to Datastar through signals, and keep the text of an expression fixed, as the [Datastar documentation](https://data-star.dev/reference/security) advises. #### Sections: - [Index](#attribute-index) +- [Signals, expressions and statements in F#](#signals-expressions-and-statements-in-f) - [Creating Signals](#creating-signals) - [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_ @@ -135,14 +304,16 @@ Some important notes: Signals defined later in the DOM tree override those defin - [data-class](#dsclass--data-class) - [data-computed](#dscomputed--data-computed) - [data-effect](#dseffect--data-effect) -- [data-ignore](#dsignore--dsignoreself--dsignoremorph--data-star-ignore) +- [data-ignore](#dsignore--dsignoreself--dsignoremorph--data-ignore) - [data-indicator](#dsindicator--data-indicator) -- [data-json-signals](#dssignals--dssignal--data-signals) -- [data-init](#dsinit--data-init) +- [data-json-signals](#dsjsonsignals--dsjsonsignalsoptions--data-json-signals) +- [data-init](#dsoninit--data-init) +- [data-nonce](#dsnonce--data-nonce) - [data-on](#dsonevent--data-on) - [data-on-intersect](#dsonintersect--data-on-intersect) - [data-on-interval](#dsoninterval--data-on-interval) - [data-on-signal-patch](#dsonsignalpatch--dsonsignalpatchfilter--data-on-signal-patch) +- [data-preserve-attr](#dspreserveattr--data-preserve-attr) - [data-ref](#dsref--data-ref) - [data-show](#dsshow--data-show) - [data-signals](#dssignals--dssignal--data-signals) @@ -152,7 +323,7 @@ Some important notes: Signals defined later in the DOM tree override those defin ## _Creating Signals_ Create signals, which are reactive variables that automatically propagate their value to all references of the signal. -Important: Never use hyphens when naming signals. +Important: Never use hyphens when naming signals. A signal whose name starts with an underscore stays in the browser: Datastar does not send it to the server. ### [Ds.signals / Ds.signal : `data-signals`](https://data-star.dev/reference/attributes#data-signals) @@ -169,9 +340,11 @@ let signals = MySignals() Elem.div [ Ds.signals signals ] [] ``` +`Ds.signals` puts the object's property names into the attribute's value rather than into its key, so the names reach Datastar exactly as you wrote them and no casing rule applies. That also means a property name is one signal, not a path: `{| form.name = "Ada" |}` makes a signal called `form.name` as a single name, not a `form` object with a `name` in it, and a property name with a hyphen in it is a name Datastar reads as one piece where the attribute-key form would have been split. Use `Ds.signal` with a path when you want nesting or a camelCase name to survive. + As a convenience, you can create a single signal with the option to add it only if it is missing. -**Important note**: if you use kebab-case, it will be returned in pascal-case. +**Important note**: the HTML parser lowercases attribute names, so this library writes the name in kebab-case (`signal-path`), and Datastar reads it as `signalPath`. Use [`Ds.withCase`](#dswithcase--__case) to change that. ```fsharp Elem.div [ Ds.signal (sp"signalPath", "signalValue", ifMissing = true) ] [] @@ -206,18 +379,39 @@ As an example, the signal can be used to show a loading indicator. ```fsharp Elem.button [ Ds.onClick (Ds.get "/fetchBigData") // make a request to the backend, making fetch happen - Ds.indicator "fetching" // the signal we are creating - Ds.attr' ("disabled", "$fetching") // assigns the "disabled" attribute if the `fetching` signal value is true + Ds.indicator "_fetching" // the signal we are creating. The underscore keeps it in the browser, so it is not sent with requests + Ds.attr' ("disabled", "$_fetching") // assigns the "disabled" attribute if the `_fetching` signal value is true ] [ Text.raw "Fetch!" ] Elem.div - [ Ds.show "$fetching" ] // show or hide this
if the `fetching` signal value is true or false, respectively + [ Ds.show "$_fetching" ] // show or hide this
if the `_fetching` signal value is true or false, respectively [ Text.raw "Fetching" ] ``` -The previous example uses a couple functions we haven't covered yet. [`Ds.onClick`](#dsonevent--data-on) firing a [`Ds.get action`](), which sends a GET request to the server. -[`Ds.attr'`](#dsattr--data-attr) and [`Ds.show`](#dsshow--data-show) are evaluating the Datastar expression `$fetching` and are assigning `disabled` attribute and -show/hiding the div, respectively, based on the `fetching` signal value's "true-ness". +The previous example uses a couple functions we haven't covered yet. [`Ds.onClick`](#dsonevent--data-on) firing a [`Ds.get`](#get--post--put--patch--delete--query) action, which sends a GET request to the server. +[`Ds.attr'`](#dsattr--data-attr) and [`Ds.show`](#dsshow--data-show) are evaluating the Datastar expression `$_fetching` and are assigning `disabled` attribute and +show/hiding the div, respectively, based on the `_fetching` signal value's "true-ness". + +### [Ds.withCase : `__case`](https://data-star.dev/reference/attributes#data-signals) + +Datastar reads the name that some attributes create from the attribute's key, and the HTML parser lowercases attribute names. +Because of that, a signal gets a camelCase name, and a class name or an event name gets a kebab-case name. +`Ds.withCase` adds Datastar's `__case` modifier to change that. The styles are `CaseStyle.Camel`, `Kebab`, `Snake` and `Pascal`. +Use it when your server uses another style, such as `snake_case` JSON. + +```fsharp +Elem.div [ Ds.signal (sp"myValue", 1) |> Ds.withCase CaseStyle.Snake ] [] // creates $my_value +Elem.div [ Ds.onEvent ("my-event", "$seen = true") |> Ds.withCase CaseStyle.Camel ] [] // listens for myEvent +``` + +```html +
+
+``` + +It works on `Ds.bind`, `Ds.class'`, `Ds.computed`, `Ds.indicator`, `Ds.onEvent` and `Ds.signal`. +It does nothing on `Ds.ref` and `Ds.signals`, which put the name or the object in the attribute's value. They do not use its key. +Datastar's default casing is the recommended one, so use `Ds.withCase` only when you have a reason, such as a server that uses `snake_case` JSON. ## _Signal Binding_ @@ -227,8 +421,8 @@ Example: setting the innerText of a `
` to a value that is updated by a serv ### [Ds.bind : `data-bind`](https://data-star.dev/reference/attributes#data-bind) Creates a two-way binding from a signal to the "value" of an HTML "input" element. Can be placed on any HTML element on which data can be input or choices -selected (e.g. `input`, `textarea`, `select`, `checkbox` and `radio` elements, as well as web components. Although not necessary, you can find the `switch` statement in the -[source](https://github.com/starfederation/datastar/blob/main/library/src/plugins/attributes/bind.ts) to see how signals are translated). +selected (e.g. `input`, `textarea` and `select` elements, `input` with `type` of `checkbox` or `radio`, and web components. Although not necessary, you can find the `switch` statement in the +[source](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/plugins/attributes/bind.ts) to see how signals are translated). The signal will be created if it does not already exist. And the type of the signal is preserved during binding; if an element's value changes, the signal value is automatically converted to match the original (see the [documentation](https://data-star.dev/reference/attributes#data-bind) for an example.) @@ -236,6 +430,25 @@ the signal value is automatically converted to match the original (see the [docu Elem.input [ Attr.type' "text"; Ds.bind "firstName" ] ``` +An `input` with `type="file"` is a special case: Datastar puts the chosen files into one signal as a list of `{ name; contents; mime }`, where `contents` is a base64 data URI. It does not create the `{name}`, `{name}Names` and `{name}Mimes` signals that older versions did. + +For custom elements and web components, `Ds.bindProp` binds a signal to a named property of the element. You can also list the events that copy the property back into the signal. +`Ds.bindEvent` changes only the events, and needs at least one, because with none Datastar never syncs the signal. + +Write the property name in kebab-case, because the HTML parser lowercases attribute names. Datastar only removes the hyphens from what it reads, so a name with a capital letter in it is not turned back into camelCase: `"isOn"` would be read as the property `ison`, and the component has no such property. + +```fsharp +Elem.create "my-slider" [ Ds.bindProp (sp"volume", "value", [ "change" ]) ] [] +Elem.create "my-toggle" [ Ds.bindProp (sp"isOn", "is-on") ] [] +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. @@ -254,7 +467,7 @@ Elem.div [ Ds.attr' ("title", "$foo") ] [] ### [Ds.show : `data-show`](https://data-star.dev/reference/attributes#data-show) -Show or hides an element based on whether a [Datastar expression](https://data-star.dev/guide/datastar_expressions) evaluates to true or false. +Shows or hides an element based on whether a [Datastar expression](https://data-star.dev/guide/datastar_expressions) evaluates to true or false. For anything with custom requirements, use [`data-class`](#dsclass--data-class) instead. ```fsharp @@ -266,17 +479,29 @@ Elem.div [ Ds.show "$foo" ] [] Adds or removes a class to or from an element based on the "true-ness" of a [Datastar expression](https://data-star.dev/guide/datastar_expressions). ```fsharp -Elem.div [ Ds.class' "hidden" "$foo" ] [] // add the 'hidden' class when $foo evaluates to true +Elem.div [ Ds.class' ("hidden", "$foo") ] [] // add the 'hidden' class when $foo evaluates to true +``` + +The class name goes into an attribute name (`data-class:hidden`), so it is checked. A name with a quote, a space, `=`, `/`, `<` or `>` is refused, because HTML would end the attribute name there. +A name with a double underscore, such as `card__title`, is refused too, because Datastar reads `__` as the start of a modifier and would toggle a class called `card`. To toggle such a class, write the object form yourself: +`Attr.create "data-class" "{'card__title': $isActive}"`. The same checks apply to the names in `Ds.attr'`, `Ds.style`, `Ds.onEvent` and `Rocket.prop*`, and to the events of `Ds.bindEvent`. HTML lowercases attribute names, so write these names in lower case. + +```fsharp +Ds.class' ("is-active", "$a") // data-class:is-active="$a" +Ds.class' ("card__title", "$a") // ArgumentException: ... it contains '__'. Datastar reads '__' in an attribute name as the start of a modifier, + // so it would read 'card__title' as 'card' with the modifier 'title'. Choose a name without '__'. ... +Ds.class' ("x\" onmouseover=\"y", "$a") // ArgumentException: ... it contains '"'. HTML ends an attribute name there, + // and what follows would become attributes of their own. ... ``` ### [Ds.style : `data-style`](https://data-star.dev/reference/attributes#data-style) -Sets the value of inline CSS styles on an element based on an expression, and keeps them in sync. +Sets the value of inline CSS styles on an element based on an expression, and keeps them in sync. Write the property name in kebab-case, as CSS spells it (`background-color`), because the HTML parser lowercases attribute names and Datastar passes the name on as it reads it. ```fsharp -Elem.div [ Ds.style "backgroundColor" "$usingRed ? 'red' : 'blue'" ] [ Text.raw "Red of Blue" ] +Elem.div [ Ds.style ("background-color", "$usingRed ? 'red' : 'blue'") ] [ Text.raw "Red or blue" ] -Elem.div [ Ds.style "display" "$hiding && 'none'" ] [ Text.raw "Might be hiding" ] +Elem.div [ Ds.style ("display", "$hiding && 'none'") ] [ Text.raw "Might be hiding" ] ``` ## _Events and Triggers_ @@ -284,16 +509,17 @@ Elem.div [ Ds.style "display" "$hiding && 'none'" ] [ Text.raw "Might be hiding" Events and triggers result in [Datastar expressions](https://data-star.dev/guide/datastar_expressions) being executed. This can result in signal changes and other expressions being run. Example: clicking a button to send a request or an element scrolling into view. -### [Ds.init : `data-init`](https://data-star.dev/reference/attributes#data-init) +### [Ds.onInit : `data-init`](https://data-star.dev/reference/attributes#data-init) -Runs an expression when the element is loaded into the DOM. **Important:** when patching elements, -`ElementPatchMode.Replace` the [Datastar expression](https://data-star.dev/guide/datastar_expressions) -will be fired a second time, but will not with `ElementPatchMode.Outer`. +Runs a [Datastar expression](https://data-star.dev/guide/datastar_expressions) when the attribute is initialized. That happens on page load, when an element that has it is patched into the DOM, +and any time the attribute is modified. ```fsharp -Elem.div [ Ds.init (Ds.get "/moreAgents") ] [] +Elem.div [ Ds.onInit (Ds.get "/moreAgents") ] [] ``` +Attributes are evaluated in the order they appear, so when the request should use a signal from `Ds.indicator`, put `Ds.indicator` before `Ds.onInit`. + ### [Ds.onEvent : `data-on`](https://data-star.dev/reference/attributes#data-on) Attaches an event listener to an element, executing a [Datastar expression](https://data-star.dev/guide/datastar_expressions) whenever the event is triggered. @@ -301,12 +527,12 @@ An `evt` variable that represents the event object is available in the expressio ```fsharp Elem.div [ Ds.onEvent("mouseup", "$selection = document.getSelection().toString()") ] [ Text.raw "Highlight some of me!" ] -Elem.div [ Ds.onEvent("mouseenter", "$show = !$show"); Ds.onEvent("mouseexit", "$show = !$show") ] [] +Elem.div [ Ds.onEvent("mouseenter", "$show = !$show"); Ds.onEvent("mouseleave", "$show = !$show") ] [] ``` ```fsharp Elem.button [ Ds.onClick "$show = !$show" ] [ Text.raw "Peek-a-boo!" ] -Elem.div [ Ds.init (Ds.get "/edit") ] [] +Elem.div [ Ds.onInit (Ds.get "/edit") ] [] ``` #### `data-on` Modifiers @@ -314,7 +540,7 @@ Elem.div [ Ds.init (Ds.get "/edit") ] [] Modifiers allow you to alter the behavior when events are triggered. (Modifiers with a '*' can only be used with the [built-in events](https://developer.mozilla.org/en-US/docs/Web/Events)). ```fsharp - type OnEventModifier = +type OnEventModifier = | Once // * - can only be used with built-in events | Passive // * - can only be used with built-in events | Capture // * - can only be used with built-in events @@ -323,7 +549,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 @@ -332,15 +559,17 @@ Modifiers allow you to alter the behavior when events are triggered. (Modifiers As an example: ```fsharp Elem.div [ - Ds.onEvent ("click", "$foo = ''", [ Window; Debounce.With(1000, leading = true) ]) + Ds.onEvent ("click", "$foo = ''", [ Window; Debounce (Debounce.With(1000, leading = true)) ]) ] [] ``` Results in: ```html -
+
``` +The modifiers are written in the reverse of the order you list them. Datastar does not care about their order. + ### [Ds.effect : `data-effect`](https://data-star.dev/reference/attributes#data-effect) Executes an expression on page load and whenever any signals in the expression change. This is useful for performing @@ -369,29 +598,46 @@ Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = ### [Ds.onSignalPatch | Ds.onSignalPatchFilter : `data-on-signal-patch`](https://data-star.dev/reference/attributes#data-on-signal-patch) -Runs an expression any signal changes. This should be used sparingly, as it is cost intensive. +Runs an expression whenever a signal is patched. Datastar listens for its own patch events on the `document`, so every `data-on-signal-patch` on the page runs for every patch, whichever element made it. Use it sparingly, and give it a `Ds.onSignalPatchFilter` unless you really do want all of them. + +The filter is a separate attribute read from the same element as the `data-on-signal-patch`, so put the two together: ```fsharp -Elem.div [ Ds.onSignalPatch "$show = !$show" ] [] +Elem.div [ + Ds.onSignalPatch "$show = !$show" + Ds.onSignalPatchFilter (SignalsFilter.Include "form") +] [] +``` -Elem.div [ Ds.onSignalPatchFilter (SignalsFilter.Include "/foo/") ] [] +A `SignalsFilter` holds regular expressions for the paths of the signals to include and exclude. Write the pattern without slashes around it: the library adds them, and escapes a slash inside the pattern. +`SignalsFilter.Prefix "form."` matches the signals that start with `form.`. + +```fsharp +SignalsFilter.Serialize (SignalsFilter.Include "^form\\.") // { include: /^form\./ } +SignalsFilter.Serialize { SignalsFilter.Prefix "form." with ExcludePattern = ValueSome "\\.id$" } + // { include: /^form\./,exclude: /\.id$/ } +SignalsFilter.Serialize (SignalsFilter.Include "a/b") // { include: /a\/b/ }, the slash is escaped for you +Ds.onSignalPatchFilter (SignalsFilter.Include "a\"b") // data-on-signal-patch-filter="{ include: /a"b/ }" ``` ### [Ds.onInterval : `data-on-interval`](https://data-star.dev/reference/attributes#data-on-interval) -Runs an expression at a regular interval. The interval duration defaults to 1 second and can be modified by passing a `TimeSpan` +Runs a statement at a regular interval. Pass the interval in milliseconds, and `leading = true` to run it once straight away. +The Tao of Datastar prefers one long-lived stream from the server to polling, so use an interval for work that only the browser needs, such as a clock. Do not use it to ask the backend for changes. ```fsharp +let tick = Signal.browser "tick" + Elem.div [ - Ds.signal (sp"intervalSignalOneSecond", false) - Ds.onInterval "$intervalSignalOneSecond = !$intervalSignalOneSecond" - Ds.text "'One Second Interval = ' + $intervalSignalOneSecond" + Ds.signal (tick, false) + Ds.onInterval (Stmt.toggle tick, 1000) + Ds.text (Expr.ifElse (Expr.read tick) (Expr.string "tick") (Expr.string "tock")) ] [] +// The same with strings Elem.div [ - Ds.signal (sp"intervalSignalFiveSecond", false) - Ds.onInterval ("$intervalSignalFiveSecond = !$intervalSignalFiveSecond", TimeSpan.FromSeconds(5.0), leading = true) - Ds.text "'Five Second Interval = ' + $intervalSignalFiveSecond" + Ds.signal (sp"_fiveSecond", false) + Ds.onInterval ("$_fiveSecond = !$_fiveSecond", 5000, leading = true) ] [] ``` @@ -400,16 +646,16 @@ 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-actions) 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) +Luckily an F#-friendly [SDK exists](https://data-star.dev/reference/sdks#csharp) and `Falco.Datastar` has several [helper methods](#reading-signals-and-server-side-events) All signals, that do not have an underscore prefix, are sent in the request. -`@get` will send the signal values as query parameters. All others are sent within a JSON body. +`@get` and `@delete` send the signal values in the `datastar` query parameter. The other actions send them in a JSON body. ```fsharp -Elem.div [ Ds.init (Ds.get "/get") ] [] +Elem.div [ Ds.onInit (Ds.get "/get") ] [] Elem.button [ Ds.onClick (Ds.post "/post") ] [ Text.raw "Post" ] @@ -418,47 +664,170 @@ 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 an HTTP `QUERY` request. Like a `@get`, it does not change anything on the server, but it sends the signals in the request body instead of 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_) +[Datastar expressions](https://data-star.dev/guide/datastar_expressions) and any [event or trigger](#events-and-triggers) could activate them. -Each request action can also be provided a number of options, explained in depth [here](https://data-star.dev/reference/actions#options): +Each request action can also be provided a number of options, explained in depth [here](https://data-star.dev/reference/actions#options). +Start with the defaults. Datastar's authors recommend them for most apps, and an option that you leave at `RequestOptions.Defaults` is not sent, so Datastar's own default applies. ```fsharp 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" ] ``` +`OpenWhenHidden` controls what happens to a request while the page is hidden. When it is `false`, Datastar cancels the request as soon as the page is hidden and starts it again when the page is visible. +When it is `true`, the request keeps running, which suits something like a dashboard. +The option is not set unless you set it, so Datastar chooses: `false` for `@get`, and `true` for `@post`, `@put`, `@patch`, `@delete` and `@query`. +Set `OpenWhenHidden = ValueSome false` to make a `@post` behave like a `@get` here, or `ValueSome true` to keep a `@get` running. + +Older versions of this library declared the option as `OpenWhenHidden: bool` with a default of `false`, but they never sent that default, so a `@post` used Datastar's `true` anyway. +The type is now `bool voption`. Change `OpenWhenHidden = true` in your code to `OpenWhenHidden = ValueSome true`. + +Each option is written as a value in an object that follows the URL: `@post('/x',{"retry":"error"})`. In the attribute the quotes are escaped as `"`. Only the options that differ from Datastar's defaults are written. +The examples below show the F# and what it writes, with the attribute's escaping undone. + +**`FilterSignals`** sends only the signals that match. `SignalsFilter.Prefix "form."` matches the signals that start with `form.`. `SignalsFilter.Include` and `SignalsFilter.Exclude` take a regular expression. +A filter that has an exclude keeps Datastar's rule that signals whose names start with an underscore stay in the browser, which Datastar would otherwise drop when it is given an exclude of its own. + +```fsharp +Ds.get ("/search", { RequestOptions.Defaults with FilterSignals = SignalsFilter.Prefix "form." }) +// @get('/search',{"filterSignals":{"include":"^form\\."}}) + +Ds.post ("/save", { RequestOptions.Defaults with FilterSignals = SignalsFilter.Exclude "^secret" }) +// @post('/save',{"filterSignals":{"exclude":"(^|\\.)_|(?:^secret)"}}) +``` + +**`Headers`** sends HTTP headers. Each name can be given once, because a request sends each header name once. Put several values in one header, separated by commas. A name that appears twice raises an `ArgumentException` that says so. + +```fsharp +Ds.post ("/save", { RequestOptions.Defaults with Headers = [ "X-Csrf-Token", "abc" ] }) +// @post('/save',{"headers":{"X-Csrf-Token":"abc"}}) +``` + +**`RequestCancellation`** decides what happens to an earlier request with the same method and URL. `Auto` cancels it, `Disabled` lets both run, and `Cleanup` also cancels the request when the element it is on is removed from the page. +`AbortController` lets you cancel the request from your own code. The name is a signal that holds an `AbortController`, for example one made with `data-signals:_controller="new AbortController()"`. +Datastar only cancels the request when it is given the controller itself, so the name is written as code and not as text. An empty name raises an `ArgumentException`. + +```fsharp +Elem.div [ Attr.create "data-signals:_controller" "new AbortController()" ] [ + Elem.button [ Ds.onClick (Ds.get ("/slow", { RequestOptions.Defaults with RequestCancellation = AbortController "$_controller" })) ] [ Text.raw "Start" ] + Elem.button [ Ds.onClick "$_controller.abort()" ] [ Text.raw "Cancel" ] +] +// @get('/slow',{"requestCancellation":$_controller}) +``` + +**`ContentType`** is `Json` by default and sends the signals. `Form` sends the closest form instead, and `SelectedForm "#myForm"` sends the form that the selector finds. `CustomJson obj` sends the object as the request body, instead of the signals. + +**`Retry`** is sent when it is not `OnAuto`. `OnNever` does not retry a response that is not 200, but Datastar 1.0.4 still retries after a network error, up to `RetryMaxCount` times. +**`RetryScaler`** has to be a finite number. Datastar multiplies the wait by it after every retry, so leave it at 2, or use a number such as 1.5. + +```fsharp +Ds.get ("/x", { RequestOptions.Defaults with Retry = OnError; RetryInterval = TimeSpan.FromMilliseconds 250.0; RetryMaxCount = 4 }) +// @get('/x',{"retry":"error","retryInterval":250,"retryMaxCount":4}) +``` + ### [`@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 every signal whose path starts with the prefix to the value in the second argument. Use it to set a whole group of signals at once. +Strings are quoted in the expression, and numbers and booleans are not. +The Datastar action itself 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 a `SignalsFilter` instead of a prefix, and the value comes first: `Ds.setAllFiltered (true, SignalsFilter.Prefix "foo.")`. Pass `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 a `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. Use it in `Ds.effect` to read a signal without re-running the effect when that signal changes. ```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) +### Ds.expression + +Joins several expressions into one, separated by `; `. Datastar reads a semicolon as the end of a statement, so this is how a line of code becomes several statements. +The [typed layer](#signals-expressions-and-statements-in-f) does this for you with [`Stmt.all`](#signals-expressions-and-statements-in-f), which is preferred where it fits. + +```fsharp +Ds.expression [ "$_menuOpen = !$_menuOpen"; "$_count = 0" ] // $_menuOpen = !$_menuOpen ; $_count = 0 +``` + +### [Ds.nonce : `data-nonce`](https://data-star.dev/reference/security#csp-mode) + +Datastar runs the expressions in your `data-*` attributes with `Function`, which a Content Security Policy blocks unless the policy allows `unsafe-eval`. +If your policy uses a nonce instead, put `Ds.nonce` on the `` element. This is Datastar's [CSP mode](https://data-star.dev/reference/security#csp-mode): it runs its expressions through script tags that carry the nonce. +Without it, a page under such a policy fails with `EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive`. + +The nonce must match the one in your policy's `script-src`. It must not be empty, and it should be a new random value for every full-page response. +Datastar reads the attribute once and then removes it from the page. +The script tag that loads Datastar needs the nonce too, unless your policy already allows its source, for example with `'self'`. +Datastar also applies the nonce to scripts that arrive in element patches and JavaScript responses, so those responses do not need to carry it. + +```fsharp +// README-EXCERPT a fragment: it belongs inside a handler, and ctx is that handler's context argument +let nonce = "..." // a new random value for each response +ctx.Response.Headers["Content-Security-Policy"] <- $"script-src 'nonce-{nonce}'" + +Elem.html [ Ds.nonce nonce ] [ + Elem.head [] [ Elem.script [ Attr.type' "module"; Attr.src Ds.cdnSrc; Attr.create "nonce" nonce ] [] ] + Elem.body [] [ (* ... *) ] +] +``` + +CSP mode does not make Datastar expressions safe to use with untrusted content, because Datastar does not check or clean the expressions in your attributes. +Pass user values through signals. Do not put them in the text of an expression. Sanitize any HTML that users can provide. +Datastar also works with Trusted Types: it creates a policy named `datastar`, so a policy with `trusted-types datastar; require-trusted-types-for 'script'` allows it. +If you use an aliased Datastar script, the attribute carries the alias too, for example `data-star-nonce`. Setting `Constants.dataSlugPrefix <- "data-star"` makes `Ds.nonce` write that name, and it must match the alias the bundle was built with, because that is what Datastar reads. + +### `Ds.safariStreamingFix` + +Safari can show a stale copy of a streaming page when the user goes back to it, and the stream never starts again. Put this attribute on the `` of a page that opens a stream, to reload the page when it is restored from the back-forward cache. +It listens for `pageshow` on the window. Datastar 1.0.4 reads modifiers after `__`, so this library writes `__window`. +[The problem](https://stackoverflow.com/questions/8788802/prevent-safari-loading-from-cache-when-back-button-is-clicked) is explained on Stack Overflow. + +```fsharp +Elem.body [ Ds.safariStreamingFix; Ds.onInit (Stmt.get "/updates") ] [ (* ... *) ] +``` + +```html + +``` + +### [Ds.ignore | Ds.ignoreSelf | Ds.ignoreMorph : `data-ignore`](https://data-star.dev/reference/attributes#data-ignore) Datastar walks the entire DOM and applies plugins to each element it encounters. -It’s possible to tell Datastar to ignore an element and its descendants by placing a data-star-ignore attribute on it. +It’s possible to tell Datastar to ignore an element and its descendants by placing a data-ignore attribute on it. This can be useful for preventing naming conflicts with third-party libraries. `Ds.ignore` will force Datastar to ignore the element and all child elements. -`Ds.ignoreSelf` only affects the attribute it is attached to. +`Ds.ignoreSelf` ignores only the element it is on. Its children are still processed. +`Ds.ignoreMorph` stops a server patch from changing the element. It applies when both the element on the page and the element the server sends have the attribute, or when the element on the page is inside an element that has it. ```fsharp Elem.div [ Ds.ignore ] [ @@ -474,6 +843,14 @@ Elem.div [ Ds.ignoreMorph ] [ ] ``` +### [Ds.preserveAttr : `data-preserve-attr`](https://data-star.dev/reference/attributes#data-preserve-attr) + +Keeps the value of an attribute when a server patch morphs the element. List several attributes with a space between them. + +```fsharp +Elem.details [ Attr.createBool "open"; Ds.preserveAttr "open" ] [ Elem.summary [] [ Text.raw "Title" ]; Text.raw "Content" ] +``` + ### [Ds.jsonSignals | Ds.jsonSignalsOptions : `data-json-signals`](https://data-star.dev/reference/attributes#data-json-signals) Sets the text content of an element to a reactive JSON stringified version of signals. Useful when troubleshooting an @@ -482,16 +859,206 @@ issue. Has options for restricting the signals displayed. ```fsharp Elem.pre [ Ds.jsonSignals ] [] -Elem.pre [ Ds.jsonSignalsOptions (SignalsFilter.Include "/foo/") ] [] +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 way of writing web components. +You write a component in JavaScript with `rocket(tag, { props, setup, render })`, and the server renders the component's tag, its props and its children. +Load Datastar with `Ds.rocketCdnScript` to use it. The bundle exports `rocket`, so a component definition starts with `import { rocket } from '...'` and the address in `Ds.rocketCdnSrc`, as the example does. The helpers below write only the values you pass, so Rocket's own defaults still apply. +Datastar's [Rocket reference](https://data-star.dev/reference/rocket) says that Rocket is in beta and that its API is subject to change, so these helpers may need to change with it. + +Rocket components fit the Tao when they hold behaviour for the user interface. Keep business state on the backend, and let a component emit an event that the page turns into a request. +The [RocketComponents example](https://github.com/falcoframework/Falco.Datastar/tree/main/examples/RocketComponents) does this: props come from the server, one component keeps state that only the browser needs, and the events turn into commands. + +### `Rocket.propString | propNumber | propBool | propDate | propJson | propBin` + +Rocket reads each prop from the attribute with the prop's name, and decodes it with the [codec](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/rocket/codecs.ts) the component chose. +Each helper writes the format its codec reads: + +| Helper | Codec | What it writes | +| --- | --- | --- | +| `Rocket.propString` | `string` | the text, escaped for use in an attribute. A carriage return is written as ` `, because a parser would change it into a line feed. HTML cannot keep a NUL character, so it is written as U+FFFD, as a browser would read it | +| `Rocket.propNumber` | `number` | any number type, with the invariant culture (`1.5`, never `1,5`) | +| `Rocket.propBool` | `bool` | `true` or `false`, always written, because a missing attribute means the prop's default, which might be true | +| `Rocket.propDate` | `date` | UTC ISO 8601 with milliseconds, like `Date.toISOString()` | +| `Rocket.propJson` | `json`, `array`, `object`, `tuple`, `oneOf`, and `js` | camelCase JSON, or JSON made with the options you pass | +| `Rocket.propBin` | `bin` | base64 | + +The attribute name is the prop name converted the way Rocket [converts it](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/utils/text.ts): `maxCount` becomes `max-count`, `innerHTML` becomes `inner-html`, and `pos3d` becomes `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 that has the same `id`, with new attribute values. Rocket reads the new values and renders the component again. + +```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` + +The children of a light-DOM component (`mode: 'light'`), and the component's own render output, can use signals and actions that belong to one instance of the component. +In an open shadow-DOM component, `Rocket.local` and `Rocket.root` also work in the children that the server rendered. +Rocket rewrites `$$name` into a path that is unique to that instance, so two instances do not share it. It rewrites `@name(...)` into a call to the action registered with `action('name', fn)` in the component's `setup`, or, if there is none, into a call to the Datastar action with that name. + +The typed way declares the signal in F#, so the state needs no JavaScript. The component declares it with `$$('on', false)` in its `setup`, and the page binds to that declared name: + +```fsharp +// In the component's setup: rocket('my-toggle', { mode: 'light', setup: ($) => { $('on', false) } }) +// $$('on', false) declares a signal local to this component instance, which is what +// Ds.signal / Rocket.local then refer to by the name "on". The server renders the tag. + +Elem.create "my-toggle" [ Attr.id "toggle" ] [ + Elem.button [ Ds.onClick (Rocket.call "toggle") ] [ Text.raw "Toggle" ] + Elem.p [ Ds.show (Rocket.local "on") ] [ Text.raw "Now you see me." ] +] +``` + +`Signal.rocket` reads as `$$name` in an expression and as the plain local name in an attribute key. Rocket rewrites both to the component instance's own path, so two instances of a component never share the state. The rewrite covers `data-signals`, `data-bind`, `data-computed`, `data-indicator` and `data-ref`, and it runs over the component's children and its render output — so put these attributes inside the component. Outside a component no rewrite happens, and there the name means a page signal. + +Define the tag with `rocket('my-toggle', { mode: 'light' })`. It needs no props, setup or render function. Two instances keep separate state: toggling one leaves the other alone. +The string helpers do the same, with an action that you register in JavaScript: + +```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 ties the signals named by `Ds.bind`, `Ds.computed` and `Ds.indicator`, and the signals created by `Ds.signal`, to the instance. +`Rocket.root` makes a bind, computed or indicator use the page's signal instead. It does nothing to `Ds.signal`, because Rocket always ties those to the instance ([source](https://github.com/starfederation/datastar/blob/v1.0.4/library/src/rocket/template.ts#L467-L481)). +Datastar's Rocket reference says `__root` also applies to `data-ref`, but Datastar 1.0.4 turns every `data-ref` in a component into a component reference before it looks for `__root`, so it has no effect there. +Use `Rocket.root` sparingly. It suits a wrapper component whose children should stay connected to the page's signals. + +```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 directives that go on `