diff --git a/.github/workflows/blazor.yml b/.github/workflows/blazor.yml new file mode 100644 index 0000000..6dff663 --- /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@4da3066e32f61c05b42563f952281fa78e1bb96a + 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/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/.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/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 new file mode 100644 index 0000000..8c88a9d --- /dev/null +++ b/blazor/README.md @@ -0,0 +1,23 @@ +# DynamicDataWeb.Blazor + +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. + +## Collections and Razor views + +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. + +```razor + + + @foreach (var item in items) {

@item.Name

} +
+
+``` + +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. + +## Native operators + +`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. + +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 new file mode 100644 index 0000000..af8d182 --- /dev/null +++ b/blazor/RELEASE.md @@ -0,0 +1,9 @@ +# DynamicDataWeb.Blazor 0.2.0 + +- 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. + +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/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..4da3066 --- /dev/null +++ b/blazor/runtime-source @@ -0,0 +1 @@ +Subproject commit 4da3066e32f61c05b42563f952281fa78e1bb96a diff --git a/blazor/sample/Demo.razor b/blazor/sample/Demo.razor new file mode 100644 index 0000000..ef79321 --- /dev/null +++ b/blazor/sample/Demo.razor @@ -0,0 +1,57 @@ +@page "/" +@namespace DynamicDataWebBlazorSample +@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..f3fd374 --- /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.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.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())); + 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", [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", [BrowserValue.Literal(key)]); + var found = await Module.GetAsync(optional, "HasValue"); + 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", [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, 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", [BrowserValue.Literal(items)]); + public ValueTask RefreshAtAsync(int index) => Module.CallVoidAsync(Handle, "RefreshAt", [index]); + public ValueTask GetAsync(int index) => Module.CallJsonAsync(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 }; 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}}