From 2c8f2bda15970c6593729eb63589f92122051bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 13 Sep 2026 22:58:16 +0200 Subject: [PATCH 1/2] feat(blazor): add typed cache/list services, live Razor collection view and NuGet distribution --- .github/workflows/blazor.yml | 24 +++++++++++ .gitmodules | 3 ++ blazor/.gitignore | 12 ++++++ blazor/README.md | 56 +++++++++++++++++++++++++ blazor/RELEASE.md | 5 +++ blazor/Version.props | 1 + blazor/build.mjs | 1 + blazor/config.json | 1 + blazor/runtime-source | 1 + blazor/sample/Demo.razor | 56 +++++++++++++++++++++++++ blazor/src/Components.cs | 72 ++++++++++++++++++++++++++++++++ blazor/src/DynamicDataView.razor | 33 +++++++++++++++ blazor/src/entry.js | 4 ++ 13 files changed, 269 insertions(+) create mode 100644 .github/workflows/blazor.yml create mode 100644 .gitmodules create mode 100644 blazor/.gitignore create mode 100644 blazor/README.md create mode 100644 blazor/RELEASE.md create mode 100644 blazor/Version.props create mode 100644 blazor/build.mjs create mode 100644 blazor/config.json create mode 160000 blazor/runtime-source create mode 100644 blazor/sample/Demo.razor create mode 100644 blazor/src/Components.cs create mode 100644 blazor/src/DynamicDataView.razor create mode 100644 blazor/src/entry.js diff --git a/.github/workflows/blazor.yml b/.github/workflows/blazor.yml new file mode 100644 index 0000000..6e7eecd --- /dev/null +++ b/.github/workflows/blazor.yml @@ -0,0 +1,24 @@ +name: Blazor +on: + pull_request: + push: + branches: [main] + paths: ['blazor/**', 'src/**', 'dist/**', 'package*.json', '.gitmodules', '.github/workflows/blazor.yml'] + workflow_dispatch: + inputs: + publish: + description: Publish the checked-in Blazor version from main + type: boolean + default: false +permissions: + contents: read +jobs: + blazor: + permissions: + contents: write + uses: wieslawsoltes/Dockyard/.github/workflows/blazor.yml@154351f159587a0e523f3a62884b1458f66a20e8 + with: + project: blazor/src/DynamicDataWeb.Blazor.csproj + package_name: DynamicDataWeb + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + secrets: inherit diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..800c071 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "blazor/runtime-source"] + path = blazor/runtime-source + url = https://github.com/wieslawsoltes/Dockyard.git diff --git a/blazor/.gitignore b/blazor/.gitignore new file mode 100644 index 0000000..9e58305 --- /dev/null +++ b/blazor/.gitignore @@ -0,0 +1,12 @@ +**/bin/ +**/obj/ +src/WebInterop.g.cs +src/*.csproj +src/wwwroot/ +sample/Program.cs +sample/Sample.csproj +sample/_Imports.razor +sample/wwwroot/index.html +server/ +tests/ +artifacts/ diff --git a/blazor/README.md b/blazor/README.md new file mode 100644 index 0000000..08025e7 --- /dev/null +++ b/blazor/README.md @@ -0,0 +1,56 @@ +# DynamicDataWeb.Blazor + +Self-contained .NET 8 / .NET 10 components and typed collection services backed by the actual DynamicDataWeb/RxJS browser implementation. + +```sh +dotnet add package DynamicDataWeb.Blazor --version 0.2.0 +``` + +`DynamicDataProvider` manages the browser session and supplies a `BrowserModule` through `Ready` and a `RenderFragment` child context. `DynamicDataView` observes a native collection and renders a `RenderFragment>` whenever the collection changes. These are lifecycle-aware Blazor components, not placeholder HTML controls. + +```csharp +// Execute from an interactive component's Ready/OnAfterRenderAsync, not during prerender. +await using var module = new DynamicDataModule(JS); +await using var cache = await module.CreateCacheAsync("id"); +await cache.AddOrUpdateAsync(new Person(1, "Ada")); +await using var subscription = await cache.ObserveItemsAsync(items => + InvokeAsync(() => { people = items; StateHasChanged(); })); +``` + +Keep the module, collection and subscription alive for the component's lifetime in a real application; the using declarations illustrate ownership, not a one-shot live UI scope. Key selectors refer to JSON property names (normally camelCase). + +## Typed collection APIs + +`SourceCache` supports creation from a key property or native callback, single/batch add-or-update, key removal, keys, lookup, count, items, clear, Connect and collection observation. `SourceList` supports add/range-add, insert, remove/range-remove, replacement, moving, loading, refreshing, indexing, clear and observation. `Handle` retains native identity; `Module` accesses the underlying session. + +`ObserveItemsAsync` reports typed arrays, suppresses completion as a data item and forwards stream errors explicitly. `DynamicDataView` handles subscriptions and renderer dispatch automatically without owning the supplied collection. Updates in JavaScript are snapshots across the .NET boundary, not modifications to arbitrary CLR object identity. Dispose collections after their views/subscriptions detach. + +## Every bundled native export + +`BrowserModule` exposes `GetExportsAsync`, `CreateAsync`, `InvokeAsync`, `CallAsync`, `GetAsync`, `SetAsync`, `SubscribeAsync`, and `ReleaseAsync`. Native DynamicData exports are available at the root, with RxJS under `Rx` and RxJS operators under `RxOperators`. `ConnectAsync` returns the real fluent DataObservable. Invoke its Filter, Transform, Sort, Group, Page, Virtualise and other operators through native references, then subscribe or bind to their results. Generic interop is not an exhaustive strongly typed C# reimplementation of every operator. + +`BrowserFunction.Property`, `Setter` and `Constant` create synchronous native functions. `BrowserFunction.Module("./selectors.js", "predicate")` loads exported JavaScript callbacks without eval, supporting synchronous selectors/comparers and high-throughput operators. `BrowserFunction.DotNet` is asynchronous and only valid for promise-aware operators such as asynchronous transforms; it cannot replace a synchronous key selector on Blazor Server. Cancellation of a .NET interop wait does not necessarily stop an already running JavaScript algorithm. + +Callbacks preserve ordering. Session disposal removes listeners, native resources and .NET references; it tolerates disconnected Server circuits. Module instances belong to a component or circuit, never a process-wide singleton. `CreateAsync` results are session-owned; native objects returned by other calls remain parent-owned unless explicitly released. `IJSObjectReference.DisposeAsync` releases the interop handle, while `ReleaseAsync` also invokes native cleanup. Do not call native methods after disposal. + +## Hosting and build + +The package contains its JavaScript and RxJS dependency. Consumers need no Node, npm, CDN, global script or Dockyard NuGet package. Use interactive WebAssembly/Server render modes. Static prerender avoids JavaScript; assets resolve relative to the app base URI under `_content/DynamicDataWeb.Blazor`. + +```sh +git submodule update --init --recursive +npm ci +npm run build +node blazor/build.mjs +dotnet run --project blazor/sample/Sample.csproj +dotnet run --project blazor/server/Server.csproj --urls http://localhost:5080 +# Server sample: http://localhost:5080/probe/ +``` + +The common lifecycle code, sample hosts and test harness come from the commit-pinned `blazor/runtime-source` submodule. `blazor/config.json` deterministically generates namespace-specific projects and runtime code before packing; project-specific services, Razor view and sample are reviewed source in this repository. Generated files are ignored. Each published package is independent of that source-build dependency. + +CI compiles/packs both frameworks, runs bridge and managed lifecycle/prerender tests, inspects the nupkg, restores both sample hosts from the package and drives them in Chromium. The sample checks native cache lookup/count and an actual RxJS notification, then unmounts/remounts. Packages, samples and screenshots are retained as artifacts. Source and workflow pins must be updated together through PRs. + +## NuGet releases + +`blazor/Version.props` controls an independent NuGet version. Validated version-changing PRs merged to main publish with `NUGET_API_KEY`, falling back to `NUGET_TOKEN` or `NUGET_KEY`. Manual dispatch is validation-only unless publication is selected. GitHub releases use `blazor-v` and attach packages and the runnable WebAssembly sample without changing npm releases. Existing DynamicDataWeb compatibility limits still apply. diff --git a/blazor/RELEASE.md b/blazor/RELEASE.md new file mode 100644 index 0000000..de047f5 --- /dev/null +++ b/blazor/RELEASE.md @@ -0,0 +1,5 @@ +# DynamicDataWeb.Blazor 0.2.0 + +Add .NET 8 / .NET 10 browser-engine services, typed SourceCache/SourceList wrappers, DynamicDataProvider and a live Razor-template DynamicDataView. Bundle DynamicDataWeb and RxJS as local static assets. Include native callback descriptors, ordered observable notifications, lifetime management, complete generic native-export access, WebAssembly/Server samples, package-consumer tests and validated NuGet publishing. + +The implementation wraps the browser engine rather than replacing the native .NET DynamicData library. Synchronous selectors run in JavaScript; generic native interop covers operators beyond the typed convenience API. diff --git a/blazor/Version.props b/blazor/Version.props new file mode 100644 index 0000000..d8f637c --- /dev/null +++ b/blazor/Version.props @@ -0,0 +1 @@ +0.2.08.0.2810.0.10 diff --git a/blazor/build.mjs b/blazor/build.mjs new file mode 100644 index 0000000..4247875 --- /dev/null +++ b/blazor/build.mjs @@ -0,0 +1 @@ +await import('./runtime-source/blazor/build-consumer.mjs'); diff --git a/blazor/config.json b/blazor/config.json new file mode 100644 index 0000000..2f7a958 --- /dev/null +++ b/blazor/config.json @@ -0,0 +1 @@ +{ "name": "DynamicDataWeb" } diff --git a/blazor/runtime-source b/blazor/runtime-source new file mode 160000 index 0000000..154351f --- /dev/null +++ b/blazor/runtime-source @@ -0,0 +1 @@ +Subproject commit 154351f159587a0e523f3a62884b1458f66a20e8 diff --git a/blazor/sample/Demo.razor b/blazor/sample/Demo.razor new file mode 100644 index 0000000..b0a6c09 --- /dev/null +++ b/blazor/sample/Demo.razor @@ -0,0 +1,56 @@ +@page "/" +@namespace DynamicDataWeb.Blazor.Sample +@using DynamicDataWeb.Blazor +@using Microsoft.AspNetCore.Components +@implements IAsyncDisposable +
+

DynamicDataWeb + Blazor

+

A real browser SourceCache and RxJS collection stream, rendered by Razor templates.

+ + + @if (_show) { + + + @if (_cache is not null) { + +

Live items: @items.Count

    @foreach (var item in items) {
  • @item.Name
  • }
+
+ } +
+
+ } + @_status@_result +
+@code { + public sealed record Item(int Id, string Name); + private SourceCache? _cache; + private BrowserSubscription? _observation; + private TaskCompletionSource? _notification; + private bool _show = true, _ready; + private int _count; + private string _status = "Loading", _result = ""; + private async Task Initialize(BrowserModule module) + { + _count = 2; _cache = await SourceCache.CreateAsync(module, "id"); + await _cache.AddOrUpdateManyAsync([new(1, "First item"), new(2, "Second item")]); + _observation = await _cache.ObserveItemsAsync(items => { _notification?.TrySetResult(items.Length); return Task.CompletedTask; }); + _ready = true; _status = "Ready"; + } + private async Task Add() + { + _notification = new(TaskCreationOptions.RunContinuationsAsynchronously); + var expected = ++_count; + await _cache!.AddOrUpdateAsync(new(expected, $"Item {expected}")); + var observed = await _notification.Task.WaitAsync(TimeSpan.FromSeconds(15)); + var lookup = await _cache.LookupAsync(expected); + if (observed != expected || await _cache.GetCountAsync() != expected || !lookup.Found) throw new InvalidOperationException("The native cache or observable notification failed."); + _result = "Passed"; + } + private async Task Cleanup() + { + if (_observation is not null) { await _observation.DisposeAsync(); _observation = null; } + if (_cache is not null) { await _cache.DisposeAsync(); _cache = null; } + } + private async Task Toggle() { _ready = false; if (_show) await Cleanup(); _show = !_show; _status = _show ? "Loading" : "Unmounted"; _result = ""; } + public async ValueTask DisposeAsync() { await Cleanup(); } +} diff --git a/blazor/src/Components.cs b/blazor/src/Components.cs new file mode 100644 index 0000000..3dc7e46 --- /dev/null +++ b/blazor/src/Components.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Microsoft.JSInterop; +namespace DynamicDataWeb.Blazor; + +public sealed class DynamicDataProvider : BrowserProvider { } +public sealed class DynamicDataModule(IJSRuntime js) : BrowserModule(js) +{ + public ValueTask> CreateCacheAsync(string keyProperty) => SourceCache.CreateAsync(this, keyProperty); + public ValueTask> CreateListAsync(IEnumerable? initial = null) => SourceList.CreateAsync(this, initial); +} +/// Owns a native collection while preserving its JavaScript identity. +public abstract class BrowserCollection(BrowserModule module, IJSObjectReference handle) : IAsyncDisposable +{ + public BrowserModule Module { get; } = module; + public IJSObjectReference Handle { get; } = handle; + private int _disposed; + private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; + public ValueTask GetCountAsync() => Module.GetAsync(Handle, "Count"); + public ValueTask GetItemsAsync() => Module.GetAsync(Handle, "Items"); + public ValueTask ClearAsync() => Module.CallVoidAsync(Handle, "Clear"); + public ValueTask ConnectAsync(object? predicate = null) => predicate is null ? Module.CallAsync(Handle, "Connect") : Module.CallAsync(Handle, "Connect", [predicate]); + public async ValueTask ObserveItemsAsync(Func next, Func? error = null) + { + await using var changes = await ConnectAsync(); + await using var items = await Module.CallAsync(changes, "ToCollection"); + return await Module.SubscribeAsync(items, "", value => + { + if (value.ValueKind == JsonValueKind.Array) return next(value.Deserialize(Json) ?? []); + if (value.ValueKind == JsonValueKind.Object && value.TryGetProperty("error", out var failure)) return error is not null ? error(failure) : Task.FromException(new JSException(failure.ToString())); + return Task.CompletedTask; + }); + } + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + try { await Module.ReleaseAsync(Handle); } + catch (ObjectDisposedException) { await Handle.DisposeAsync(); } + catch (JSDisconnectedException) { } + } +} +public sealed class SourceCache : BrowserCollection +{ + private SourceCache(BrowserModule module, IJSObjectReference handle) : base(module, handle) { } + public static async ValueTask> CreateAsync(BrowserModule module, string keyProperty) => new(module, await module.CreateAsync("SourceCache", [BrowserFunction.Property(keyProperty)])); + public static async ValueTask> CreateWithSelectorAsync(BrowserModule module, object selector) => new(module, await module.CreateAsync("SourceCache", [selector])); + public ValueTask AddOrUpdateAsync(T item) => Module.CallVoidAsync(Handle, "AddOrUpdate", [item]); + public ValueTask AddOrUpdateManyAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddOrUpdate", [items]); + public ValueTask RemoveKeyAsync(TKey key) => Module.CallVoidAsync(Handle, "RemoveKey", [key]); + public ValueTask RemoveKeysAsync(IEnumerable keys) => Module.CallVoidAsync(Handle, "RemoveKeys", [keys]); + public ValueTask GetKeysAsync() => Module.GetAsync(Handle, "Keys"); + public async ValueTask<(bool Found, T? Value)> LookupAsync(TKey key) + { + await using var optional = await Module.CallAsync(Handle, "Lookup", [key]); + var found = await Module.GetAsync(optional, "HasValue"); + return (found, found ? await Module.GetAsync(optional, "Value") : default); + } +} +public sealed class SourceList : BrowserCollection +{ + private SourceList(BrowserModule module, IJSObjectReference handle) : base(module, handle) { } + public static async ValueTask> CreateAsync(BrowserModule module, IEnumerable? initial = null) => new(module, await module.CreateAsync("SourceList", [initial ?? []])); + public ValueTask AddAsync(T item) => Module.CallVoidAsync(Handle, "Add", [item]); + public ValueTask AddRangeAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddRange", [items]); + public ValueTask InsertAsync(int index, T item) => Module.CallVoidAsync(Handle, "Insert", [index, item]); + public ValueTask RemoveAtAsync(int index) => Module.CallVoidAsync(Handle, "RemoveAt", [index]); + public ValueTask RemoveRangeAsync(int index, int count) => Module.CallVoidAsync(Handle, "RemoveRange", [index, count]); + public ValueTask ReplaceAtAsync(int index, T item) => Module.CallVoidAsync(Handle, "ReplaceAt", [index, item]); + public ValueTask MoveAsync(int oldIndex, int newIndex) => Module.CallVoidAsync(Handle, "Move", [oldIndex, newIndex]); + public ValueTask LoadAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "Load", [items]); + public ValueTask RefreshAtAsync(int index) => Module.CallVoidAsync(Handle, "RefreshAt", [index]); + public ValueTask GetAsync(int index) => Module.CallAsync(Handle, "Get", [index]); +} diff --git a/blazor/src/DynamicDataView.razor b/blazor/src/DynamicDataView.razor new file mode 100644 index 0000000..5328923 --- /dev/null +++ b/blazor/src/DynamicDataView.razor @@ -0,0 +1,33 @@ +@namespace DynamicDataWeb.Blazor +@using Microsoft.AspNetCore.Components +@typeparam TItem +@implements IAsyncDisposable +@if (_loaded) { @ChildContent?.Invoke(_items) } else { @Loading } +@code { + [Parameter, EditorRequired] public BrowserCollection Source { get; set; } = default!; + [Parameter] public RenderFragment>? ChildContent { get; set; } + [Parameter] public RenderFragment? Loading { get; set; } + private readonly SemaphoreSlim _gate = new(1, 1); + private BrowserCollection? _source; + private BrowserSubscription? _subscription; + private IReadOnlyList _items = Array.Empty(); + private bool _loaded, _disposed; + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await _gate.WaitAsync(); + try + { + if (_disposed || ReferenceEquals(_source, Source)) return; + if (_subscription is not null) await _subscription.DisposeAsync(); + _source = Source; _loaded = false; + _subscription = await Source.ObserveItemsAsync(items => _disposed ? Task.CompletedTask : InvokeAsync(() => { _items = items; _loaded = true; StateHasChanged(); })); + } + finally { _gate.Release(); } + } + public async ValueTask DisposeAsync() + { + _disposed = true; await _gate.WaitAsync(); + try { if (_subscription is not null) await _subscription.DisposeAsync(); } + finally { _gate.Release(); } + } +} diff --git a/blazor/src/entry.js b/blazor/src/entry.js new file mode 100644 index 0000000..a406a1b --- /dev/null +++ b/blazor/src/entry.js @@ -0,0 +1,4 @@ +import * as engine from '../../src/index.js'; +import * as Rx from 'rxjs'; +import * as RxOperators from 'rxjs/operators'; +export const api = { ...engine, Rx, RxOperators }; From e028813d71133d6382207cf304a585b4b767d242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Mon, 14 Sep 2026 08:20:10 +0200 Subject: [PATCH 2/2] Complete Blazor streamed collections, literal data safety, shared runtime and package documentation --- .github/workflows/blazor.yml | 2 +- README.md | 137 +++++-------------------------- README.web.md | 133 ++++++++++++++++++++++++++++++ blazor/INTEGRATION.md | 17 ++++ blazor/README.md | 59 +++----------- blazor/RELEASE.md | 8 +- blazor/runtime-source | 2 +- blazor/sample/Demo.razor | 3 +- blazor/src/Components.cs | 32 ++++---- package.json | 152 +---------------------------------- 10 files changed, 212 insertions(+), 333 deletions(-) create mode 100644 README.web.md create mode 100644 blazor/INTEGRATION.md diff --git a/.github/workflows/blazor.yml b/.github/workflows/blazor.yml index 6e7eecd..6dff663 100644 --- a/.github/workflows/blazor.yml +++ b/.github/workflows/blazor.yml @@ -16,7 +16,7 @@ jobs: blazor: permissions: contents: write - uses: wieslawsoltes/Dockyard/.github/workflows/blazor.yml@154351f159587a0e523f3a62884b1458f66a20e8 + uses: wieslawsoltes/Dockyard/.github/workflows/blazor.yml@4da3066e32f61c05b42563f952281fa78e1bb96a with: project: blazor/src/DynamicDataWeb.Blazor.csproj package_name: DynamicDataWeb diff --git a/README.md b/README.md index 6190a7f..79afffc 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,40 @@ # DynamicDataWeb -[![CI and distribution](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/ci.yml) -[![npm version](https://img.shields.io/npm/v/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) -[![npm downloads](https://img.shields.io/npm/dm/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) -[![Latest release](https://img.shields.io/github/v/release/wieslawsoltes/DynamicDataWeb)](https://github.com/wieslawsoltes/DynamicDataWeb/releases/latest) -[![License](https://img.shields.io/github/license/wieslawsoltes/DynamicDataWeb)](LICENSE) -[![Live demo](https://img.shields.io/badge/demo-GitHub%20Pages-blue)](https://wieslawsoltes.github.io/DynamicDataWeb/) - -[Web demo](https://wieslawsoltes.github.io/DynamicDataWeb/) · -[Releases](https://github.com/wieslawsoltes/DynamicDataWeb/releases) · -[Compatibility](docs/COMPATIBILITY.md) · [Publishing](docs/publishing.md) - -Reactive keyed caches and ordered lists for JavaScript and TypeScript, built on **RxJS 7.8.2**. The package supplies actual RxJS observables, pipeable collection operators, and an opt-in-per-instance fluent API. It does not modify the global RxJS Observable prototype. +Reactive keyed caches and ordered lists for JavaScript, TypeScript and Blazor, powered by RxJS. -This is a substantial web adaptation of DynamicData, pinned to upstream commit `ef790af138811c3268c9bb687886c2acbd0b66c0` (10.0 preview). It includes counterparts for all 118 core cache/list method names. **Name coverage does not establish identical behavior for all 437 C# overloads.** Read [the compatibility report](docs/COMPATIBILITY.md) for exact adaptations, tested contracts and remaining boundaries. - -## Run the project +[![npm](https://img.shields.io/npm/v/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) +[![npm downloads](https://img.shields.io/npm/dm/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) +[![NuGet](https://img.shields.io/nuget/v/DynamicDataWeb.Blazor)](https://www.nuget.org/packages/DynamicDataWeb.Blazor) +[![NuGet downloads](https://img.shields.io/nuget/dt/DynamicDataWeb.Blazor)](https://www.nuget.org/packages/DynamicDataWeb.Blazor) +[![Blazor CI](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/blazor.yml/badge.svg)](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/blazor.yml) -Use **Node 22 or newer** for development and Node consumers. +## JavaScript ```sh -npm ci -npm test -npm run check:types -npm run build -npm run demo +npm install @wieslawsoltes/dynamicdataweb rxjs ``` -The demo opens at `http://localhost:4173`. The CI and distribution workflow validates Node 22 and 24 consumers, publishes immutable release archives, and deploys the built demo using GitHub's official Pages artifact and deployment actions. It can also be run manually. Deployment runs from `main` through the `github-pages` environment. No backend, account, API key, CDN or remote runtime dependency is required. The sample market data is generated locally and is not financial data. +The [complete JavaScript guide](README.web.md) retains all existing API examples, architecture, tests, compatibility information and notices. [Open the web demo](https://wieslawsoltes.github.io/DynamicDataWeb/). -## Use in a JavaScript application - -Install the public npm package together with its RxJS peer: +## Blazor ```sh -npm install @wieslawsoltes/dynamicdataweb rxjs@^7.8.2 -``` - -```js -import { BehaviorSubject } from 'rxjs'; -import { SourceCache, filter, transform, sort, bind } from '@wieslawsoltes/dynamicdataweb'; - -const assets = new SourceCache(asset => asset.id); -const predicate$ = new BehaviorSubject(asset => asset.enabled); -const visibleAssets = []; - -const subscription = assets.connect().pipe( - filter(predicate$), - transform(asset => ({ ...asset, displayPrice: asset.price.toFixed(2) })), - sort((a, b) => a.name.localeCompare(b.name)), - bind(visibleAssets) -).subscribe(); - -assets.edit(cache => { - cache.addOrUpdate({ id: 1, name: 'Lumen', price: 142.80, enabled: true }); - cache.addOrUpdate({ id: 2, name: 'Atlas', price: 98.20, enabled: false }); -}); -predicate$.next(asset => asset.price > 100); - -// Dispose the pipeline when its owner goes away. -subscription.unsubscribe(); -assets.dispose(); -``` - -`bind` also accepts a callback, RxJS subject, or supported observable collection. A callback integrates directly with a component's state setter. You own the subscription and must release it when the component is unmounted. - -```js -// Familiar C#-style naming, with explicit web binding instead of an out parameter: -const subscription = assets.Connect() - .Filter(asset => asset.enabled) - .Transform(asset => ({ ...asset, label: asset.name.toUpperCase() })) - .Sort((a, b) => a.label.localeCompare(b.label)) - .Bind(render) - .Subscribe(); -subscription.Dispose(); +dotnet add package DynamicDataWeb.Blazor --version 0.2.0 ``` -## Plain HTML and browser modules +The .NET 8/.NET 10 package bundles the real engine and RxJS for interactive WebAssembly and Server, without a consumer npm/CDN dependency. It provides typed `SourceCache` and `SourceList`, full collection notifications, `DynamicDataProvider`, and `DynamicDataView` with Razor rendering. Native object/function handles also expose advanced operators. -The self-contained browser ESM and global bundles include RxJS and work without a bundler: +Read the [Blazor guide](blazor/README.md), [integration contract](blazor/INTEGRATION.md), [sample](blazor/sample/Demo.razor) and [release notes](blazor/RELEASE.md). -```html - +```sh +git submodule update --init --recursive +npm ci +npm run build +node blazor/build.mjs +dotnet run --project blazor/sample/Sample.csproj +# Or: dotnet run --project blazor/server/Server.csproj ``` -Or load `dist/dynamicdata.global.js` with a normal script tag and use `DynamicData.SourceCache`, `DynamicData.Filter`, and `DynamicData.rxjs`. Node CommonJS consumers can `require('@wieslawsoltes/dynamicdataweb')`. The normal package entry point keeps RxJS external for dependency sharing. Imports from `@wieslawsoltes/dynamicdataweb/core` and `@wieslawsoltes/dynamicdataweb/operators` avoid fluent registration and allow narrower bundles. - -## Included capabilities - -- Keyed caches, ordered lists with duplicates, change-aware collections, intermediate caches, read-only views, Optional values, batching, preview, key watching, rollback on failed edits, notification suspension and ordered reentrant delivery. -- Static and observable filters, cached transforms, safe and inline transforms, nested child collections, asynchronous and observable transforms, key conversion, runtime casts and distinct values. -- Stable sorting, dynamic comparers, paging, index-window virtualisation, reverse/top, binding and collection snapshots. -- Live/immutable/property/observable grouping; specified groups; all four join kinds and their Many variants; static and dynamic union/intersection/exclusion/exclusive membership. -- Sum/count/average/minimum/maximum/standard deviation, aggregation change enumeration and update diagnostics. -- Property notification proxies, per-item subscriptions, disposal and async disposal, auto-refresh, expiry, retention limits, buffering, source switching, ref-counted snapshots and stream conversion. -- Tree transformation, adapters, update/reason filtering, source merge operators, optional/iterable helpers and a framework-neutral observable collection. - -## Sample app - -The sample has light and dark themes and seven workspaces: - -1. Live cache: generated assets, search, reactive sector filters, sorting, paging, batch edits and change records. -2. Ordered lists: append, move, replace, delete and batched edits. -3. Groups and joins: live sector membership and coverage updates from either side of a join. -4. Stream lifecycle: property refresh, timed expiry and stale async result cancellation. -5. Operator recipes: 73 executable recipes with source and emitted output. -6. Performance: actual browser timings for up to 100,000 items, with a bounded rendered window. -7. API reference: searchable runtime exports and links to the compatibility report. - -The recipes demonstrate the major families; they are not a separate example for every upstream overload. The test suite covers many additional operators and failure paths. - -## Change model and performance - -A `ChangeSet` is array-compatible and contains `Change` or `ListChange` records with camelCase and PascalCase properties. Cache records carry `key`; list records carry indices and may contain a range. Source cache filtering/transformation emits incremental deltas without rebuilding full snapshots. Sorted/windowed streams additionally expose `.items`, `.keys`, `.sortedItems`, and `.response` where applicable. - -Cache edits are expected O(1) per key. Cache filter/transform and several aggregates are incremental. Sorting uses binary placement but still shifts/copies arrays and produces ordered snapshots. Generic list reordering can be quadratic, and full regrouping/min/max scan retained items. `npm run benchmark` measures these costs separately; [BENCHMARK.json](docs/BENCHMARK.json) records one local run, not a portable performance guarantee. - -## Source and licensing - -DynamicData is MIT licensed, Copyright (c) Roland Pheasant 2011–2022. Its license is retained in `LICENSE`. RxJS is Apache-2.0 licensed; its notice and license are retained in `docs/RXJS-LICENSE.txt`. See `NOTICE`. This port is not an official release from either upstream project. - -The upstream inventory includes all public types and method signatures at the pinned revision, plus immutable source links. [UPSTREAM-API.md](docs/UPSTREAM-API.md), [API-MAPPING.md](docs/API-MAPPING.md), and the machine-readable JSON files allow independent review. No unimplemented overload is certified merely because an export has the same name. - -## Releases and npm publishing +Source builds require the .NET 10 SDK with .NET 8 targeting support. The Server sample uses `/probe/`. CI restores the actual nupkg into both target frameworks and hosts, testing native collections, notifications, streams, Razor callbacks and remounting. -Tagged versions are available on [npm](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb), GitHub Packages, and [GitHub Releases](https://github.com/wieslawsoltes/DynamicDataWeb/releases). Each release includes the npm tarball, browser bundles, complete showcase, and SHA-256 checksums. Publishing verifies the exact tarball before and after npm publication, including installed ESM, CommonJS and strict TypeScript consumers and RxJS interoperability. See [publishing instructions](docs/publishing.md) for release preparation, token setup, provenance and safe retries. +`blazor/Version.props` owns NuGet versioning separately from npm. Version-changing main merges publish after validation using `NUGET_API_KEY` (`NUGET_TOKEN`/`NUGET_KEY` aliases), verify the public payload and create `blazor-v*` releases with symbols, runnable samples and checksums. Native API/browser boundaries remain applicable; this wrapper is not an exhaustive generated C# DynamicData port. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/README.web.md b/README.web.md new file mode 100644 index 0000000..6190a7f --- /dev/null +++ b/README.web.md @@ -0,0 +1,133 @@ +# DynamicDataWeb + +[![CI and distribution](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wieslawsoltes/DynamicDataWeb/actions/workflows/ci.yml) +[![npm version](https://img.shields.io/npm/v/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) +[![npm downloads](https://img.shields.io/npm/dm/%40wieslawsoltes%2Fdynamicdataweb)](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb) +[![Latest release](https://img.shields.io/github/v/release/wieslawsoltes/DynamicDataWeb)](https://github.com/wieslawsoltes/DynamicDataWeb/releases/latest) +[![License](https://img.shields.io/github/license/wieslawsoltes/DynamicDataWeb)](LICENSE) +[![Live demo](https://img.shields.io/badge/demo-GitHub%20Pages-blue)](https://wieslawsoltes.github.io/DynamicDataWeb/) + +[Web demo](https://wieslawsoltes.github.io/DynamicDataWeb/) · +[Releases](https://github.com/wieslawsoltes/DynamicDataWeb/releases) · +[Compatibility](docs/COMPATIBILITY.md) · [Publishing](docs/publishing.md) + +Reactive keyed caches and ordered lists for JavaScript and TypeScript, built on **RxJS 7.8.2**. The package supplies actual RxJS observables, pipeable collection operators, and an opt-in-per-instance fluent API. It does not modify the global RxJS Observable prototype. + +This is a substantial web adaptation of DynamicData, pinned to upstream commit `ef790af138811c3268c9bb687886c2acbd0b66c0` (10.0 preview). It includes counterparts for all 118 core cache/list method names. **Name coverage does not establish identical behavior for all 437 C# overloads.** Read [the compatibility report](docs/COMPATIBILITY.md) for exact adaptations, tested contracts and remaining boundaries. + +## Run the project + +Use **Node 22 or newer** for development and Node consumers. + +```sh +npm ci +npm test +npm run check:types +npm run build +npm run demo +``` + +The demo opens at `http://localhost:4173`. The CI and distribution workflow validates Node 22 and 24 consumers, publishes immutable release archives, and deploys the built demo using GitHub's official Pages artifact and deployment actions. It can also be run manually. Deployment runs from `main` through the `github-pages` environment. No backend, account, API key, CDN or remote runtime dependency is required. The sample market data is generated locally and is not financial data. + +## Use in a JavaScript application + +Install the public npm package together with its RxJS peer: + +```sh +npm install @wieslawsoltes/dynamicdataweb rxjs@^7.8.2 +``` + +```js +import { BehaviorSubject } from 'rxjs'; +import { SourceCache, filter, transform, sort, bind } from '@wieslawsoltes/dynamicdataweb'; + +const assets = new SourceCache(asset => asset.id); +const predicate$ = new BehaviorSubject(asset => asset.enabled); +const visibleAssets = []; + +const subscription = assets.connect().pipe( + filter(predicate$), + transform(asset => ({ ...asset, displayPrice: asset.price.toFixed(2) })), + sort((a, b) => a.name.localeCompare(b.name)), + bind(visibleAssets) +).subscribe(); + +assets.edit(cache => { + cache.addOrUpdate({ id: 1, name: 'Lumen', price: 142.80, enabled: true }); + cache.addOrUpdate({ id: 2, name: 'Atlas', price: 98.20, enabled: false }); +}); +predicate$.next(asset => asset.price > 100); + +// Dispose the pipeline when its owner goes away. +subscription.unsubscribe(); +assets.dispose(); +``` + +`bind` also accepts a callback, RxJS subject, or supported observable collection. A callback integrates directly with a component's state setter. You own the subscription and must release it when the component is unmounted. + +```js +// Familiar C#-style naming, with explicit web binding instead of an out parameter: +const subscription = assets.Connect() + .Filter(asset => asset.enabled) + .Transform(asset => ({ ...asset, label: asset.name.toUpperCase() })) + .Sort((a, b) => a.label.localeCompare(b.label)) + .Bind(render) + .Subscribe(); +subscription.Dispose(); +``` + +## Plain HTML and browser modules + +The self-contained browser ESM and global bundles include RxJS and work without a bundler: + +```html + +``` + +Or load `dist/dynamicdata.global.js` with a normal script tag and use `DynamicData.SourceCache`, `DynamicData.Filter`, and `DynamicData.rxjs`. Node CommonJS consumers can `require('@wieslawsoltes/dynamicdataweb')`. The normal package entry point keeps RxJS external for dependency sharing. Imports from `@wieslawsoltes/dynamicdataweb/core` and `@wieslawsoltes/dynamicdataweb/operators` avoid fluent registration and allow narrower bundles. + +## Included capabilities + +- Keyed caches, ordered lists with duplicates, change-aware collections, intermediate caches, read-only views, Optional values, batching, preview, key watching, rollback on failed edits, notification suspension and ordered reentrant delivery. +- Static and observable filters, cached transforms, safe and inline transforms, nested child collections, asynchronous and observable transforms, key conversion, runtime casts and distinct values. +- Stable sorting, dynamic comparers, paging, index-window virtualisation, reverse/top, binding and collection snapshots. +- Live/immutable/property/observable grouping; specified groups; all four join kinds and their Many variants; static and dynamic union/intersection/exclusion/exclusive membership. +- Sum/count/average/minimum/maximum/standard deviation, aggregation change enumeration and update diagnostics. +- Property notification proxies, per-item subscriptions, disposal and async disposal, auto-refresh, expiry, retention limits, buffering, source switching, ref-counted snapshots and stream conversion. +- Tree transformation, adapters, update/reason filtering, source merge operators, optional/iterable helpers and a framework-neutral observable collection. + +## Sample app + +The sample has light and dark themes and seven workspaces: + +1. Live cache: generated assets, search, reactive sector filters, sorting, paging, batch edits and change records. +2. Ordered lists: append, move, replace, delete and batched edits. +3. Groups and joins: live sector membership and coverage updates from either side of a join. +4. Stream lifecycle: property refresh, timed expiry and stale async result cancellation. +5. Operator recipes: 73 executable recipes with source and emitted output. +6. Performance: actual browser timings for up to 100,000 items, with a bounded rendered window. +7. API reference: searchable runtime exports and links to the compatibility report. + +The recipes demonstrate the major families; they are not a separate example for every upstream overload. The test suite covers many additional operators and failure paths. + +## Change model and performance + +A `ChangeSet` is array-compatible and contains `Change` or `ListChange` records with camelCase and PascalCase properties. Cache records carry `key`; list records carry indices and may contain a range. Source cache filtering/transformation emits incremental deltas without rebuilding full snapshots. Sorted/windowed streams additionally expose `.items`, `.keys`, `.sortedItems`, and `.response` where applicable. + +Cache edits are expected O(1) per key. Cache filter/transform and several aggregates are incremental. Sorting uses binary placement but still shifts/copies arrays and produces ordered snapshots. Generic list reordering can be quadratic, and full regrouping/min/max scan retained items. `npm run benchmark` measures these costs separately; [BENCHMARK.json](docs/BENCHMARK.json) records one local run, not a portable performance guarantee. + +## Source and licensing + +DynamicData is MIT licensed, Copyright (c) Roland Pheasant 2011–2022. Its license is retained in `LICENSE`. RxJS is Apache-2.0 licensed; its notice and license are retained in `docs/RXJS-LICENSE.txt`. See `NOTICE`. This port is not an official release from either upstream project. + +The upstream inventory includes all public types and method signatures at the pinned revision, plus immutable source links. [UPSTREAM-API.md](docs/UPSTREAM-API.md), [API-MAPPING.md](docs/API-MAPPING.md), and the machine-readable JSON files allow independent review. No unimplemented overload is certified merely because an export has the same name. + +## Releases and npm publishing + +Tagged versions are available on [npm](https://www.npmjs.com/package/@wieslawsoltes/dynamicdataweb), GitHub Packages, and [GitHub Releases](https://github.com/wieslawsoltes/DynamicDataWeb/releases). Each release includes the npm tarball, browser bundles, complete showcase, and SHA-256 checksums. Publishing verifies the exact tarball before and after npm publication, including installed ESM, CommonJS and strict TypeScript consumers and RxJS interoperability. See [publishing instructions](docs/publishing.md) for release preparation, token setup, provenance and safe retries. diff --git a/blazor/INTEGRATION.md b/blazor/INTEGRATION.md new file mode 100644 index 0000000..308833a --- /dev/null +++ b/blazor/INTEGRATION.md @@ -0,0 +1,17 @@ +# Blazor hosting and interop contract + +Use interactive WebAssembly or Interactive Server. Static SSR can render a provider/loading fragment but cannot initialize the browser engine. Wait for `Ready`, and keep browser sessions per app/circuit rather than cross-user singletons. Package assets resolve relative to the base URI under `_content/DynamicDataWeb.Blazor/`. + +`DynamicDataView` is an ordinary Razor view. Optional native DOM factories use `BrowserTemplate` and `BrowserFunction.RazorTemplate`; register `builder.Services.AddDynamicDataWebBlazor()` and, on WASM, `builder.RootComponents.RegisterDynamicDataWebBlazor()`. Server registration is `AddRazorComponents().AddInteractiveServerComponents(options => options.RootComponents.RegisterDynamicDataWebBlazor())`. + +Native templates are independent Blazor roots. They support nested components, callbacks and shadow-DOM input binding, but do not inherit outer cascading values automatically. Declare required `CascadingValue` components inside templates and keep durable state outside recreated/virtualized roots. Use unique template IDs and explicit serializable native-model fields or DTO contexts. + +`BrowserModule` supports constructor/invoke/call/get/set/events. Use `InvokeReferenceAsync`, `CallReferenceAsync` and `GetReferenceAsync` for returned native functions, then `CallFunctionAsync` or pass the handle into another native method. Synchronous native selectors/comparers remain browser functions; `BrowserFunction.DotNet` is asynchronous, only appropriate for promise-compatible native APIs. Cancelling an interop wait does not stop arbitrary synchronous native computation. + +`CallJsonAsync`, `InvokeJsonAsync`, `GetJsonAsync` and binary equivalents transfer complete data through streams (64 MiB explicit default). `SubscribeJsonAsync` provides complete JSON DTO notifications; `SubscribeAsync` is a bounded diagnostic snapshot of live graphs. `CallBatchAsync` preserves order, not atomicity. Generic application arguments should use `BrowserValue.Literal` so `$fn` data is not interpreted as callbacks. Never use untrusted module URLs as executable callbacks. + +Dispose subscriptions, collections and owned modules asynchronously. Dispose borrowed native handles without calling native disposal; `ReleaseAsync` is for resources you own. JSON values are copies, unlike identity-preserving native references. Rendering notifications are dispatched through the component's Blazor renderer. + +Source builds use recursive submodule initialization, npm ci/build and `node blazor/build.mjs`, then .NET build/pack. Consumers require neither Node nor Dockyard. The WASM and Server (`/probe/`) samples are restored from the produced nupkg in CI for net8.0/net10.0 and checked in Chromium for native collection behavior, callbacks, full streams and remounting. These tests do not qualify all browsers, hybrid WebViews or physical GPUs. + +NuGet versions are independent of npm in `Version.props`. Version-changing main merges publish after validation using `NUGET_API_KEY` (`NUGET_TOKEN`/`NUGET_KEY` aliases), reject conflicting immutable versions, verify downloaded public package payloads and attach symbols, runnable samples and checksums to `blazor-v*` releases. diff --git a/blazor/README.md b/blazor/README.md index 08025e7..8c88a9d 100644 --- a/blazor/README.md +++ b/blazor/README.md @@ -1,56 +1,23 @@ # DynamicDataWeb.Blazor -Self-contained .NET 8 / .NET 10 components and typed collection services backed by the actual DynamicDataWeb/RxJS browser implementation. +Install `DynamicDataWeb.Blazor` 0.2.0 for .NET 8/.NET 10. The package includes the actual JavaScript engine and RxJS as local static assets for interactive WebAssembly and Server. -```sh -dotnet add package DynamicDataWeb.Blazor --version 0.2.0 -``` - -`DynamicDataProvider` manages the browser session and supplies a `BrowserModule` through `Ready` and a `RenderFragment` child context. `DynamicDataView` observes a native collection and renders a `RenderFragment>` whenever the collection changes. These are lifecycle-aware Blazor components, not placeholder HTML controls. - -```csharp -// Execute from an interactive component's Ready/OnAfterRenderAsync, not during prerender. -await using var module = new DynamicDataModule(JS); -await using var cache = await module.CreateCacheAsync("id"); -await cache.AddOrUpdateAsync(new Person(1, "Ada")); -await using var subscription = await cache.ObserveItemsAsync(items => - InvokeAsync(() => { people = items; StateHasChanged(); })); -``` - -Keep the module, collection and subscription alive for the component's lifetime in a real application; the using declarations illustrate ownership, not a one-shot live UI scope. Key selectors refer to JSON property names (normally camelCase). - -## Typed collection APIs - -`SourceCache` supports creation from a key property or native callback, single/batch add-or-update, key removal, keys, lookup, count, items, clear, Connect and collection observation. `SourceList` supports add/range-add, insert, remove/range-remove, replacement, moving, loading, refreshing, indexing, clear and observation. `Handle` retains native identity; `Module` accesses the underlying session. - -`ObserveItemsAsync` reports typed arrays, suppresses completion as a data item and forwards stream errors explicitly. `DynamicDataView` handles subscriptions and renderer dispatch automatically without owning the supplied collection. Updates in JavaScript are snapshots across the .NET boundary, not modifications to arbitrary CLR object identity. Dispose collections after their views/subscriptions detach. - -## Every bundled native export - -`BrowserModule` exposes `GetExportsAsync`, `CreateAsync`, `InvokeAsync`, `CallAsync`, `GetAsync`, `SetAsync`, `SubscribeAsync`, and `ReleaseAsync`. Native DynamicData exports are available at the root, with RxJS under `Rx` and RxJS operators under `RxOperators`. `ConnectAsync` returns the real fluent DataObservable. Invoke its Filter, Transform, Sort, Group, Page, Virtualise and other operators through native references, then subscribe or bind to their results. Generic interop is not an exhaustive strongly typed C# reimplementation of every operator. - -`BrowserFunction.Property`, `Setter` and `Constant` create synchronous native functions. `BrowserFunction.Module("./selectors.js", "predicate")` loads exported JavaScript callbacks without eval, supporting synchronous selectors/comparers and high-throughput operators. `BrowserFunction.DotNet` is asynchronous and only valid for promise-aware operators such as asynchronous transforms; it cannot replace a synchronous key selector on Blazor Server. Cancellation of a .NET interop wait does not necessarily stop an already running JavaScript algorithm. - -Callbacks preserve ordering. Session disposal removes listeners, native resources and .NET references; it tolerates disconnected Server circuits. Module instances belong to a component or circuit, never a process-wide singleton. `CreateAsync` results are session-owned; native objects returned by other calls remain parent-owned unless explicitly released. `IJSObjectReference.DisposeAsync` releases the interop handle, while `ReleaseAsync` also invokes native cleanup. Do not call native methods after disposal. - -## Hosting and build +## Collections and Razor views -The package contains its JavaScript and RxJS dependency. Consumers need no Node, npm, CDN, global script or Dockyard NuGet package. Use interactive WebAssembly/Server render modes. Static prerender avoids JavaScript; assets resolve relative to the app base URI under `_content/DynamicDataWeb.Blazor`. +Use `DynamicDataProvider` to own an initialized `BrowserModule`, then `SourceCache.CreateAsync(module, "id")` or `SourceList.CreateAsync(module, items)`. Key property names use serialized JSON casing. Cache operations include add/update, bulk add/update, key removal, keys, count, items and optional lookup; list operations include insert, range operations, replacement, movement, refresh, load and indexed reads. -```sh -git submodule update --init --recursive -npm ci -npm run build -node blazor/build.mjs -dotnet run --project blazor/sample/Sample.csproj -dotnet run --project blazor/server/Server.csproj --urls http://localhost:5080 -# Server sample: http://localhost:5080/probe/ +```razor + + + @foreach (var item in items) {

@item.Name

} +
+
``` -The common lifecycle code, sample hosts and test harness come from the commit-pinned `blazor/runtime-source` submodule. `blazor/config.json` deterministically generates namespace-specific projects and runtime code before packing; project-specific services, Razor view and sample are reviewed source in this repository. Generated files are ignored. Each published package is independent of that source-build dependency. +Create the cache after provider `Ready`, and keep it in the parent as shown in the [sample](sample/Demo.razor). `DynamicDataView` handles subscription replacement and disposal. `ObserveItemsAsync` provides complete JSON DTO arrays rather than truncated diagnostic snapshots; returned subscriptions are asynchronously disposable. Typed application item/key parameters are literal data and cannot be interpreted as executable callback descriptors. -CI compiles/packs both frameworks, runs bridge and managed lifecycle/prerender tests, inspects the nupkg, restores both sample hosts from the package and drives them in Chromium. The sample checks native cache lookup/count and an actual RxJS notification, then unmounts/remounts. Packages, samples and screenshots are retained as artifacts. Source and workflow pins must be updated together through PRs. +## Native operators -## NuGet releases +`ConnectAsync` returns the real change stream. Use native method calls and the bundled `Rx` namespace through `BrowserModule`; returned operator functions use `InvokeReferenceAsync` and can be passed into native pipelines without losing identity. Selectors/comparers required synchronously by the engine must execute as browser callbacks, not Server .NET delegates. -`blazor/Version.props` controls an independent NuGet version. Validated version-changing PRs merged to main publish with `NUGET_API_KEY`, falling back to `NUGET_TOKEN` or `NUGET_KEY`. Manual dispatch is validation-only unless publication is selected. GitHub releases use `blazor-v` and attach packages and the runnable WebAssembly sample without changing npm releases. Existing DynamicDataWeb compatibility limits still apply. +See [INTEGRATION.md](INTEGRATION.md) for hosting, optional native Razor factories, streaming and ownership. The package is self-contained; its pinned shared source is not a Dockyard runtime dependency. The wrapper complements typed helpers with native API access and retains the engine's documented compatibility limits. diff --git a/blazor/RELEASE.md b/blazor/RELEASE.md index de047f5..af8d182 100644 --- a/blazor/RELEASE.md +++ b/blazor/RELEASE.md @@ -1,5 +1,9 @@ # DynamicDataWeb.Blazor 0.2.0 -Add .NET 8 / .NET 10 browser-engine services, typed SourceCache/SourceList wrappers, DynamicDataProvider and a live Razor-template DynamicDataView. Bundle DynamicDataWeb and RxJS as local static assets. Include native callback descriptors, ordered observable notifications, lifetime management, complete generic native-export access, WebAssembly/Server samples, package-consumer tests and validated NuGet publishing. +- Self-contained .NET 8/.NET 10 native engine/RxJS package with typed cache/list services and Razor live collection views. +- Full streamed collection and lookup results, non-truncating DTO subscriptions and literal application item/key arguments. +- Opaque native function references for returned operators, shared Razor template roots, scoped ownership and deterministic subscription disposal. +- Actual-package WebAssembly/Server samples, native collection notification checks, Unicode/binary transfer, template callbacks and remount tests. +- Complete root/Blazor documentation and validation-gated NuGet publication with public-payload verification, symbols and runnable samples. -The implementation wraps the browser engine rather than replacing the native .NET DynamicData library. Synchronous selectors run in JavaScript; generic native interop covers operators beyond the typed convenience API. +Native engine constraints remain applicable. Generic interop complements typed helpers, rather than claiming an exhaustive generated C# DynamicData port. Synchronous native callbacks remain browser functions. diff --git a/blazor/runtime-source b/blazor/runtime-source index 154351f..4da3066 160000 --- a/blazor/runtime-source +++ b/blazor/runtime-source @@ -1 +1 @@ -Subproject commit 154351f159587a0e523f3a62884b1458f66a20e8 +Subproject commit 4da3066e32f61c05b42563f952281fa78e1bb96a diff --git a/blazor/sample/Demo.razor b/blazor/sample/Demo.razor index b0a6c09..ef79321 100644 --- a/blazor/sample/Demo.razor +++ b/blazor/sample/Demo.razor @@ -1,5 +1,5 @@ @page "/" -@namespace DynamicDataWeb.Blazor.Sample +@namespace DynamicDataWebBlazorSample @using DynamicDataWeb.Blazor @using Microsoft.AspNetCore.Components @implements IAsyncDisposable @@ -20,6 +20,7 @@ } @_status@_result + @code { public sealed record Item(int Id, string Name); diff --git a/blazor/src/Components.cs b/blazor/src/Components.cs index 3dc7e46..f3fd374 100644 --- a/blazor/src/Components.cs +++ b/blazor/src/Components.cs @@ -16,14 +16,14 @@ public abstract class BrowserCollection(BrowserModule module, IJSObjectRefere private int _disposed; private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true }; public ValueTask GetCountAsync() => Module.GetAsync(Handle, "Count"); - public ValueTask GetItemsAsync() => Module.GetAsync(Handle, "Items"); + public ValueTask GetItemsAsync() => Module.GetJsonAsync(Handle, "Items"); public ValueTask ClearAsync() => Module.CallVoidAsync(Handle, "Clear"); public ValueTask ConnectAsync(object? predicate = null) => predicate is null ? Module.CallAsync(Handle, "Connect") : Module.CallAsync(Handle, "Connect", [predicate]); public async ValueTask ObserveItemsAsync(Func next, Func? error = null) { await using var changes = await ConnectAsync(); await using var items = await Module.CallAsync(changes, "ToCollection"); - return await Module.SubscribeAsync(items, "", value => + return await Module.SubscribeJsonAsync(items, "", value => { if (value.ValueKind == JsonValueKind.Array) return next(value.Deserialize(Json) ?? []); if (value.ValueKind == JsonValueKind.Object && value.TryGetProperty("error", out var failure)) return error is not null ? error(failure) : Task.FromException(new JSException(failure.ToString())); @@ -43,30 +43,30 @@ public sealed class SourceCache : BrowserCollection private SourceCache(BrowserModule module, IJSObjectReference handle) : base(module, handle) { } public static async ValueTask> CreateAsync(BrowserModule module, string keyProperty) => new(module, await module.CreateAsync("SourceCache", [BrowserFunction.Property(keyProperty)])); public static async ValueTask> CreateWithSelectorAsync(BrowserModule module, object selector) => new(module, await module.CreateAsync("SourceCache", [selector])); - public ValueTask AddOrUpdateAsync(T item) => Module.CallVoidAsync(Handle, "AddOrUpdate", [item]); - public ValueTask AddOrUpdateManyAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddOrUpdate", [items]); - public ValueTask RemoveKeyAsync(TKey key) => Module.CallVoidAsync(Handle, "RemoveKey", [key]); - public ValueTask RemoveKeysAsync(IEnumerable keys) => Module.CallVoidAsync(Handle, "RemoveKeys", [keys]); - public ValueTask GetKeysAsync() => Module.GetAsync(Handle, "Keys"); + public ValueTask AddOrUpdateAsync(T item) => Module.CallVoidAsync(Handle, "AddOrUpdate", [BrowserValue.Literal(item)]); + public ValueTask AddOrUpdateManyAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddOrUpdate", [BrowserValue.Literal(items)]); + public ValueTask RemoveKeyAsync(TKey key) => Module.CallVoidAsync(Handle, "RemoveKey", [BrowserValue.Literal(key)]); + public ValueTask RemoveKeysAsync(IEnumerable keys) => Module.CallVoidAsync(Handle, "RemoveKeys", [BrowserValue.Literal(keys)]); + public ValueTask GetKeysAsync() => Module.GetJsonAsync(Handle, "Keys"); public async ValueTask<(bool Found, T? Value)> LookupAsync(TKey key) { - await using var optional = await Module.CallAsync(Handle, "Lookup", [key]); + await using var optional = await Module.CallAsync(Handle, "Lookup", [BrowserValue.Literal(key)]); var found = await Module.GetAsync(optional, "HasValue"); - return (found, found ? await Module.GetAsync(optional, "Value") : default); + return (found, found ? await Module.GetJsonAsync(optional, "Value") : default); } } public sealed class SourceList : BrowserCollection { private SourceList(BrowserModule module, IJSObjectReference handle) : base(module, handle) { } - public static async ValueTask> CreateAsync(BrowserModule module, IEnumerable? initial = null) => new(module, await module.CreateAsync("SourceList", [initial ?? []])); - public ValueTask AddAsync(T item) => Module.CallVoidAsync(Handle, "Add", [item]); - public ValueTask AddRangeAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddRange", [items]); - public ValueTask InsertAsync(int index, T item) => Module.CallVoidAsync(Handle, "Insert", [index, item]); + public static async ValueTask> CreateAsync(BrowserModule module, IEnumerable? initial = null) => new(module, await module.CreateAsync("SourceList", [BrowserValue.Literal(initial ?? [])])); + public ValueTask AddAsync(T item) => Module.CallVoidAsync(Handle, "Add", [BrowserValue.Literal(item)]); + public ValueTask AddRangeAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "AddRange", [BrowserValue.Literal(items)]); + public ValueTask InsertAsync(int index, T item) => Module.CallVoidAsync(Handle, "Insert", [index, BrowserValue.Literal(item)]); public ValueTask RemoveAtAsync(int index) => Module.CallVoidAsync(Handle, "RemoveAt", [index]); public ValueTask RemoveRangeAsync(int index, int count) => Module.CallVoidAsync(Handle, "RemoveRange", [index, count]); - public ValueTask ReplaceAtAsync(int index, T item) => Module.CallVoidAsync(Handle, "ReplaceAt", [index, item]); + public ValueTask ReplaceAtAsync(int index, T item) => Module.CallVoidAsync(Handle, "ReplaceAt", [index, BrowserValue.Literal(item)]); public ValueTask MoveAsync(int oldIndex, int newIndex) => Module.CallVoidAsync(Handle, "Move", [oldIndex, newIndex]); - public ValueTask LoadAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "Load", [items]); + public ValueTask LoadAsync(IEnumerable items) => Module.CallVoidAsync(Handle, "Load", [BrowserValue.Literal(items)]); public ValueTask RefreshAtAsync(int index) => Module.CallVoidAsync(Handle, "RefreshAt", [index]); - public ValueTask GetAsync(int index) => Module.CallAsync(Handle, "Get", [index]); + public ValueTask GetAsync(int index) => Module.CallJsonAsync(Handle, "Get", [index]); } diff --git a/package.json b/package.json index e9b65ed..ce14096 100644 --- a/package.json +++ b/package.json @@ -1,151 +1 @@ -{ - "name": "@wieslawsoltes/dynamicdataweb", - "version": "0.1.1", - "description": "Reactive keyed caches and ordered lists for JavaScript and TypeScript, powered by RxJS.", - "main": "./dist/cjs/index.js", - "scripts": { - "test": "node --test test/*.test.js", - "build": "node scripts/build.mjs && node scripts/prepare-demo.mjs", - "benchmark": "node scripts/benchmark.mjs", - "check:types": "tsc --noEmit --strict --skipLibCheck --module nodenext --moduleResolution nodenext test/types.ts test/reactive-interop-types.ts", - "demo": "node scripts/serve.mjs", - "audit:api": "node docs/generate-api-mapping.mjs", - "prepack": "npm run build", - "test:demo": "node --test test/demo.test.js", - "test:package": "npm run build && node scripts/package-test.mjs", - "check": "npm run build && npm test && npm run check:types && npm run audit:api" - }, - "keywords": [ - "dynamicdata", - "rxjs", - "reactive", - "collections", - "cache", - "mvvm" - ], - "author": "", - "license": "MIT", - "type": "module", - "devDependencies": { - "esbuild": "^0.28.2", - "happy-dom": "^20.14.3", - "rxjs": "^7.8.2", - "typescript": "^7.0.2" - }, - "module": "./src/index.js", - "types": "./types/index.d.ts", - "sideEffects": [ - "./src/index.js" - ], - "files": [ - "src", - "dist", - "types", - "README.md", - "LICENSE", - "NOTICE", - "docs" - ], - "exports": { - ".": { - "import": { - "types": "./types/index.d.ts", - "default": "./src/index.js" - }, - "require": { - "types": "./dist/cjs/index.d.ts", - "default": "./dist/cjs/index.js" - } - }, - "./browser": "./dist/dynamicdata.js", - "./package.json": "./package.json", - "./core": { - "import": { - "types": "./types/core.d.ts", - "default": "./src/core.js" - }, - "require": { - "types": "./dist/cjs/core.d.ts", - "default": "./dist/cjs/core.js" - } - }, - "./operators": { - "import": { - "types": "./types/operators.d.ts", - "default": "./src/operators.js" - }, - "require": { - "types": "./dist/cjs/operators.d.ts", - "default": "./dist/cjs/operators.js" - } - }, - "./advanced": { - "import": { - "types": "./types/advanced.d.ts", - "default": "./src/advanced.js" - }, - "require": { - "types": "./dist/cjs/advanced.d.ts", - "default": "./dist/cjs/advanced.js" - } - }, - "./lifecycle": { - "import": { - "types": "./types/lifecycle.d.ts", - "default": "./src/lifecycle.js" - }, - "require": { - "types": "./dist/cjs/lifecycle.d.ts", - "default": "./dist/cjs/lifecycle.js" - } - }, - "./extras": { - "import": { - "types": "./types/extras.d.ts", - "default": "./src/extras.js" - }, - "require": { - "types": "./dist/cjs/extras.d.ts", - "default": "./dist/cjs/extras.js" - } - }, - "./helpers": { - "import": { - "types": "./types/helpers.d.ts", - "default": "./src/helpers.js" - }, - "require": { - "types": "./dist/cjs/helpers.d.ts", - "default": "./dist/cjs/helpers.js" - } - }, - "./kernel": { - "import": { - "types": "./types/kernel.d.ts", - "default": "./src/kernel.js" - }, - "require": { - "types": "./dist/cjs/kernel.d.ts", - "default": "./dist/cjs/kernel.js" - } - } - }, - "peerDependencies": { - "rxjs": "^7.8.2" - }, - "repository": { - "type": "git", - "url": "https://github.com/wieslawsoltes/DynamicDataWeb.git" - }, - "homepage": "https://wieslawsoltes.github.io/DynamicDataWeb/", - "bugs": { - "url": "https://github.com/wieslawsoltes/DynamicDataWeb/issues" - }, - "engines": { - "node": ">=22" - }, - "publishConfig": { - "access": "public", - "provenance": true - } -} +{"name":"@wieslawsoltes/dynamicdataweb","version":"0.1.1","description":"Reactive keyed caches and ordered lists for JavaScript and TypeScript, powered by RxJS.","main":"./dist/cjs/index.js","scripts":{"test":"node --test test/*.test.js","build":"node scripts/build.mjs && node scripts/prepare-demo.mjs","benchmark":"node scripts/benchmark.mjs","check:types":"tsc --noEmit --strict --skipLibCheck --module nodenext --moduleResolution nodenext test/types.ts test/reactive-interop-types.ts","demo":"node scripts/serve.mjs","audit:api":"node docs/generate-api-mapping.mjs","prepack":"npm run build","test:demo":"node --test test/demo.test.js","test:package":"npm run build && node scripts/package-test.mjs","check":"npm run build && npm test && npm run check:types && npm run audit:api"},"keywords":["dynamicdata","rxjs","reactive","collections","cache","mvvm"],"author":"","license":"MIT","type":"module","devDependencies":{"esbuild":"^0.28.2","happy-dom":"^20.14.3","rxjs":"^7.8.2","typescript":"^7.0.2"},"module":"./src/index.js","types":"./types/index.d.ts","sideEffects":["./src/index.js"],"files":["src","dist","types","README.md","LICENSE","NOTICE","docs","README.web.md","blazor/README.md","blazor/INTEGRATION.md"],"exports":{".":{"import":{"types":"./types/index.d.ts","default":"./src/index.js"},"require":{"types":"./dist/cjs/index.d.ts","default":"./dist/cjs/index.js"}},"./browser":"./dist/dynamicdata.js","./package.json":"./package.json","./core":{"import":{"types":"./types/core.d.ts","default":"./src/core.js"},"require":{"types":"./dist/cjs/core.d.ts","default":"./dist/cjs/core.js"}},"./operators":{"import":{"types":"./types/operators.d.ts","default":"./src/operators.js"},"require":{"types":"./dist/cjs/operators.d.ts","default":"./dist/cjs/operators.js"}},"./advanced":{"import":{"types":"./types/advanced.d.ts","default":"./src/advanced.js"},"require":{"types":"./dist/cjs/advanced.d.ts","default":"./dist/cjs/advanced.js"}},"./lifecycle":{"import":{"types":"./types/lifecycle.d.ts","default":"./src/lifecycle.js"},"require":{"types":"./dist/cjs/lifecycle.d.ts","default":"./dist/cjs/lifecycle.js"}},"./extras":{"import":{"types":"./types/extras.d.ts","default":"./src/extras.js"},"require":{"types":"./dist/cjs/extras.d.ts","default":"./dist/cjs/extras.js"}},"./helpers":{"import":{"types":"./types/helpers.d.ts","default":"./src/helpers.js"},"require":{"types":"./dist/cjs/helpers.d.ts","default":"./dist/cjs/helpers.js"}},"./kernel":{"import":{"types":"./types/kernel.d.ts","default":"./src/kernel.js"},"require":{"types":"./dist/cjs/kernel.d.ts","default":"./dist/cjs/kernel.js"}}},"peerDependencies":{"rxjs":"^7.8.2"},"repository":{"type":"git","url":"https://github.com/wieslawsoltes/DynamicDataWeb.git"},"homepage":"https://wieslawsoltes.github.io/DynamicDataWeb/","bugs":{"url":"https://github.com/wieslawsoltes/DynamicDataWeb/issues"},"engines":{"node":">=22"},"publishConfig":{"access":"public","provenance":true}}