Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.1.1

- Observe native ReactiveWeb `Changed`/`PropertyChanged` streams without proxies,
including nested path replacement, unspecified-property notifications and
shared observer cleanup.
- Support `.Dispose()` and `.DisposeAsync()` resources in lifecycle operators.
- Bind original change batches through `ApplyChanges`/`applyChanges` targets and
support collections with PascalCase editing methods.
- Release item subscriptions after synchronous source errors and continue cleanup
when individual disposers throw.
- Share `ItemWithIndex` identity between helper and kernel/root entry points.
- Add native reactive-model, lifecycle, binding and strict type regression tests.

## 0.1.0

First public npm release of DynamicDataWeb as `@wieslawsoltes/dynamicdataweb`.
Expand Down
2 changes: 1 addition & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en" data-theme="dark">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101216"><meta name="description" content="Explore reactive collections with DynamicData and RxJS. Live cache, list, grouping, joining and lifecycle examples."><title>DynamicData RxJS · Collection Lab</title><link rel="stylesheet" href="./style.css"></head>
<body>
<header class="app-header"><a class="brand" href="#cache" aria-label="DynamicData home"><span class="brand-icon">D<span>↗</span></span><span>DynamicData <b>RxJS</b></span></a><div class="header-middle"><span class="dot"></span> COLLECTION LAB <span class="version">v0.1.0</span></div><div class="header-actions"><a href="./wieslawsoltes-dynamicdataweb-0.1.0.tgz" download class="download">↓ <span>Download package</span></a><button id="theme" aria-label="Toggle color theme" title="Toggle color theme">◐</button></div></header>
<header class="app-header"><a class="brand" href="#cache" aria-label="DynamicData home"><span class="brand-icon">D<span>↗</span></span><span>DynamicData <b>RxJS</b></span></a><div class="header-middle"><span class="dot"></span> COLLECTION LAB <span class="version">v0.1.0</span></div><div class="header-actions"><a href="./wieslawsoltes-dynamicdataweb-0.1.1.tgz" download class="download">↓ <span>Download package</span></a><button id="theme" aria-label="Toggle color theme" title="Toggle color theme">◐</button></div></header>
<div class="shell"><aside class="sidebar"><p class="nav-label">WORKSPACE</p><nav aria-label="Examples"><button data-view="cache" class="active"><span>▦</span>Live collection <small>01</small></button><button data-view="list"><span>☷</span>Ordered lists <small>02</small></button><button data-view="groups"><span>◫</span>Groups & joins <small>03</small></button><button data-view="lifecycle"><span>◷</span>Stream lifecycle <small>04</small></button><button data-view="recipes"><span>⌘</span>Operator recipes <small>05</small></button><button data-view="performance"><span>↗</span>Performance <small>06</small></button><button data-view="api"><span>⌑</span>API reference <small>07</small></button></nav><div class="sidebar-bottom"><div class="rx-badge">Rx</div><strong>Built on real observables</strong><p>Compose with RxJS. Bind to any framework.</p><a href="https://github.com/reactivemarbles/DynamicData" target="_blank" rel="noreferrer">Upstream DynamicData ↗</a><a href="./README.md" target="_blank">Integration guide ↗</a></div></aside>
<main><div class="page-heading"><div><div class="eyebrow" id="eyebrow">REACTIVE COLLECTIONS / SOURCECACHE</div><h1 id="title">Data in motion.</h1><p id="description">One source. Composable operators. A view that stays in sync.</p></div><button class="button subtle" id="reset">↻ Reset example</button></div><div id="workspace"></div></main></div>
<div id="toast" role="status" aria-live="polite"></div>
Expand Down
18 changes: 18 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,21 @@ The implementation chunks large range insertions to avoid JavaScript argument-co
The tests in `test/core.test.js`, `test/operators.test.js`, `test/advanced.test.js`, `test/lifecycle.test.js`, and `test/extras.test.js` cover source transactions, duplicate list occurrences, change replay, filter/projection transitions, sorted windows, grouping/join ownership, timers, cancellation/cleanup, and utility behavior. They are targeted JavaScript regressions; the full upstream C# fixture suite has not been mechanically translated or differentially executed against this package.

The remaining compatibility work includes overload-by-overload differential tests, identical change-batch compression, full expression-tree property dependency tracking, the full .NET collection binding/context option contract, numeric-type fidelity where needed, and helper/type members marked missing in the generated mapping. Platform thread/parallel APIs require a separately designed web worker contract. Export aliases and interface declarations should not be used to close those items without exercising their behavior.

## Native ReactiveWeb interoperability (0.1.1)

The property operators recognize structural RxJS `Changed`, `PropertyChanged`
and lowercase equivalents in addition to the port's observable-object proxies.
ReactiveWeb `PropertyName`/`Value`/`OldValue` events and unspecified-property
notifications refresh the matching subscriptions. Nested paths rewire when an
intermediate object is replaced, and aliases do not duplicate subscriptions.
Lifecycle resources may expose `.Dispose()` or `.DisposeAsync()` as well as
JavaScript/RxJS disposal methods.

`Bind` forwards the original batch to targets exposing `ApplyChanges` or
`applyChanges`, which lets ReactiveWeb retain indexed changes and object
lifetimes. PascalCase `Edit`/`Clear`/`AddRange` collection targets are also
recognized. These structural hooks do not add a dependency on ReactiveWeb.

`test/reactive-interop.test.js` and `test/reactive-interop-types.ts` verify these
contracts, cleanup after synchronous errors and shared public helper identity.
23 changes: 16 additions & 7 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
# DynamicDataWeb 0.1.0
DynamicDataWeb 0.1.1 adds native ReactiveWeb integration and lifecycle fixes.

First public npm release: `@wieslawsoltes/dynamicdataweb@0.1.0`.
- ReactiveObject property streams now drive AutoRefresh, property observation,
property filters and grouping directly, including nested object replacement.
- Collection binding accepts original change batches through ApplyChanges and
supports .NET-style collection editing.
- Lifecycle operators dispose .Dispose/.DisposeAsync resources and correctly
release subscriptions after synchronous errors and throwing cleanup callbacks.
- Helper/root ItemWithIndex exports share their class identity.

Install:

```sh
npm install @wieslawsoltes/dynamicdataweb@0.1.0 rxjs
npm install @wieslawsoltes/dynamicdataweb@0.1.1 rxjs
```

Includes keyed caches, ordered lists, incremental operators, per-instance fluent .NET-style naming, TypeScript declarations, and independently usable browser bundles. CommonJS subpaths share their module graph and retain the application's external RxJS peer.

The release pipeline verifies source and installed consumers on Node 22 and 24, publishes immutable archives with SHA-256 checksums, deploys the showcase, and publishes the same npm tarball with provenance. Public registry checks verify downloaded bytes, installed consumers and fresh package-name installation.
The same verified tarball is distributed through GitHub Packages and npm with
provenance. The release includes standalone browser modules, a static showcase,
SHA-256 checksums and complete source archives.

See [the live collection laboratory](https://wieslawsoltes.github.io/DynamicDataWeb/) and the source compatibility report for supported contracts and remaining .NET overload adaptations.
The compatibility report documents the web adaptations; this release does not
claim exact equivalence to every DynamicData C# overload or native collection API.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "@wieslawsoltes/dynamicdataweb",
"version": "0.1.0",
"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",
"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",
Expand Down
13 changes: 12 additions & 1 deletion scripts/package-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ assert.deepEqual(Reflect.ownKeys(Observable.prototype), before, 'Import must not
for (const subpath of ${JSON.stringify(subpaths.slice(1))}) {
const module = LOAD(${name} + subpath.slice(1));
assert(Object.keys(module).length > 0, 'Subpath exports must be usable');
for (const [key, value] of Object.entries(module)) if (key in root && !(subpath === './helpers' && key === 'ItemWithIndex')) assert.equal(value, root[key], 'Shared module identity: ' + subpath + '/' + key);
for (const [key, value] of Object.entries(module)) if (key in root) assert.equal(value, root[key], 'Shared module identity: ' + subpath + '/' + key);
}
const source = new root.SourceCache(item => item.id);
assert(source.connect() instanceof Observable, 'RxJS must remain an external peer');
Expand All @@ -60,6 +60,17 @@ const listSubscription = list.connect().pipe(root.toCollection()).subscribe(item
list.addRange(['a', 'b', 'a']); list.move(2, 0);
assert.deepEqual(lists.at(-1), ['a', 'a', 'b']);
listSubscription.unsubscribe(); list.dispose();
const { Subject } = LOAD('rxjs');
const model = { id: 1, Score: 1, Changed: new Subject() };
const native = new root.SourceCache(item => item.id); const nativeValues = [];
const nativeSubscription = native.Connect().pipe(root.AutoRefresh('Score'), root.Filter(item => item.Score >= 2), root.Bind(nativeValues)).subscribe();
native.AddOrUpdate(model); assert.equal(nativeValues.length, 0);
model.Score = 2; model.Changed.next({ Sender: model, PropertyName: 'Score', Value: 2, OldValue: 1 });
assert.equal(nativeValues[0], model, 'Native property streams must update cache pipelines');
let disposed = 0;
const lifetime = native.Connect().pipe(root.SubscribeMany(() => ({ Dispose() { disposed++; } }))).subscribe();
native.RemoveKey(1); assert.equal(disposed, 1); assert.equal(nativeValues.length, 0);
lifetime.unsubscribe(); nativeSubscription.unsubscribe(); native.Dispose();
`;
await writeFile(join(consumer, 'consumer.mjs'), `import assert from 'node:assert/strict';\nimport { Observable } from 'rxjs';\n${behavior.replaceAll('LOAD(', 'await import(')}`);
await writeFile(join(consumer, 'consumer.cjs'), `const assert = require('node:assert/strict');\nconst { Observable } = require('rxjs');\n${behavior.replaceAll('LOAD(', 'require(')}`);
Expand Down
28 changes: 23 additions & 5 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,12 @@ class SourceBase {
if (predicate != null && typeof predicate !== 'function') throw new TypeError('Predicate must be a function');
return observableDecorator(new Observable(observer => {
if (this._disposed) { observer.complete(); return; }
let version = this._version - Number(!!this._inPreview), starting = true;
let version = this._version - Number(!!this._inPreview), starting = true, draining = false;
const pending = [];
let projection;
try { projection = predicate ? (this.kind === 'cache' ? cacheFilter(this._data, predicate) : listFilter(this._data, predicate)) : null; }
catch (error) { observer.error(error); return; }
const receive = event => {
if (starting) { pending.push(event); return; }
if (observer.closed) return;
try {
const segments = event.segments.filter(segment => segment.version > version);
Expand All @@ -247,16 +246,35 @@ class SourceBase {
if (changes.length || !suppressEmpty) observer.next(changes);
} catch (error) { observer.error(error); }
};
// Initial snapshot callbacks can mutate and terminate the source before they return.
// Keep both deltas and terminal events in order, including writes made while draining.
const drain = () => {
if (starting || draining) return;
draining = true;
try {
for (let index = 0; index < pending.length && !observer.closed; index++) {
const notification = pending[index];
if (notification.kind === 'next') receive(notification.event);
else if (notification.kind === 'error') observer.error(notification.error);
else observer.complete();
}
} finally { pending.length = 0; draining = false; }
};
const enqueue = notification => { if (!observer.closed) { pending.push(notification); drain(); } };
const stream = preview ? this._previews : this._events;
if (stream.hasError) { observer.error(stream.thrownError); return; }
const sub = stream.isStopped ? null : stream.subscribe({ next: receive, error: e => observer.error(e), complete: () => observer.complete() });
const sub = stream.isStopped ? null : stream.subscribe({
next: event => enqueue({ kind: 'next', event }),
error: error => enqueue({ kind: 'error', error }),
complete: () => enqueue({ kind: 'complete' })
});
if (!preview) {
const initial = projection ? projection.initial() : snapshotChanges(this._data, this.kind, this.keySelector);
if (initial.length || !suppressEmpty) observer.next(initial);
}
starting = false;
for (const event of pending) receive(event);
if (stream.isStopped) observer.complete();
if (!sub) pending.push({ kind: 'complete' });
drain();
return sub;
}));
}
Expand Down
10 changes: 2 additions & 8 deletions src/helpers.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Subject, Subscription, asyncScheduler, observeOn, retry, timer } from 'rxjs';
import { ChangeSet, ChangeType, ListChange, ObservableCache, Optional, SourceCache, SourceList } from './core.js';
import { ChangeSummary, collectUpdateStats } from './extras.js';
import { ItemWithIndex } from './kernel.js';

const streamOf = source => typeof source?.connect === 'function' ? source.connect() : source;
const equality = comparer => typeof comparer === 'function' ? comparer : comparer?.equals?.bind(comparer) ?? comparer?.Equals?.bind(comparer) ?? Object.is;
Expand Down Expand Up @@ -44,14 +45,7 @@ export function getChangeType(reason) {
throw new RangeError(`Unknown list change reason: ${reason}`);
}

export class ItemWithIndex {
constructor(item, index) { this.item = item; this.index = index; }
get Item() { return this.item; }
get Index() { return this.index; }
equals(other) { return other instanceof ItemWithIndex && Object.is(this.item, other.item); }
Equals(other) { return this.equals(other); }
toString() { return `${this.item} (${this.index})`; }
}
export { ItemWithIndex } from './kernel.js';

export function indexOfOptional(source, item, comparer) {
const equals = equality(comparer);
Expand Down
Loading