effectweb 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -4
- package/dist/AsyncContent.d.ts +1 -1
- package/dist/AsyncContent.js +18 -35
- package/dist/boundary.d.ts +11 -0
- package/dist/boundary.js +66 -0
- package/dist/cache-internals.d.ts +4 -0
- package/dist/cache.d.ts +21 -5
- package/dist/cache.js +178 -25
- package/dist/collection.d.ts +1 -1
- package/dist/collection.js +1 -3
- package/dist/component.d.ts +3 -0
- package/dist/component.js +73 -10
- package/dist/dom.d.ts +35 -24
- package/dist/dom.js +177 -128
- package/dist/effectEvent.d.ts +5 -4
- package/dist/effectEvent.js +29 -38
- package/dist/form.d.ts +2 -1
- package/dist/http.d.ts +2 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- package/dist/infinite-query.d.ts +42 -0
- package/dist/infinite-query.js +150 -0
- package/dist/jsx-runtime.d.ts +6 -0
- package/dist/jsx-runtime.js +14 -1
- package/dist/jsx.d.ts +2 -1
- package/dist/keyed-tasks.d.ts +30 -0
- package/dist/keyed-tasks.js +102 -0
- package/dist/lazy.d.ts +2 -1
- package/dist/lazy.js +20 -7
- package/dist/load.d.ts +1 -1
- package/dist/load.js +1 -1
- package/dist/mount.d.ts +13 -2
- package/dist/mount.js +82 -21
- package/dist/owner.d.ts +9 -5
- package/dist/owner.js +27 -9
- package/dist/pages.d.ts +16 -15
- package/dist/pages.js +8 -5
- package/dist/program.d.ts +14 -2
- package/dist/program.js +96 -36
- package/dist/query-internals.d.ts +5 -3
- package/dist/query.d.ts +7 -1
- package/dist/query.js +7 -3
- package/dist/resource.js +3 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +3 -1
- package/dist/session.js +23 -7
- package/dist/settlement.d.ts +8 -0
- package/dist/settlement.js +32 -0
- package/dist/sharing.js +16 -13
- package/dist/snapshot.d.ts +1 -1
- package/dist/snapshot.js +31 -25
- package/dist/task.d.ts +1 -1
- package/dist/task.js +3 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +3 -1
- package/dist/testing.d.ts +5 -4
- package/dist/testing.js +4 -2
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# effectweb
|
|
2
2
|
|
|
3
|
-
Immutable Effect models and JSX
|
|
3
|
+
Immutable Effect models and JSX with direct DOM rendering.
|
|
4
4
|
|
|
5
5
|
Use with `@effectweb/compiler/vite` and Effect `4.0.0-rc.112`. The package includes programs, named tasks, async presentation, query caching, DOM lifetimes, and `effectweb/testing` helpers.
|
|
6
6
|
|
|
@@ -8,6 +8,37 @@ See [setup and example](https://github.com/DerpyCrabs/EffectWeb#vite-setup).
|
|
|
8
8
|
|
|
9
9
|
Runtime and compiler versions advance together. Client-side only. Persistence and multi-tab coordination belong to the application.
|
|
10
10
|
|
|
11
|
+
## Views and list identity
|
|
12
|
+
|
|
13
|
+
`view(render)` evaluates `render(model, send)` as ordinary JavaScript on each immutable model publication. Calls, local variables, destructuring, loops, conditionals, and JSX helpers retain their JavaScript behavior. The renderer reconciles the returned content; the compiler does not infer dependencies or cache helper results.
|
|
14
|
+
|
|
15
|
+
Use `list(rows, render)` to preserve domain identity:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { entities, list, view } from 'effectweb';
|
|
19
|
+
|
|
20
|
+
type Todo = { id: string; title: string };
|
|
21
|
+
const Todos = view<{ todos: Todo[] }>((model) => (
|
|
22
|
+
<ul>
|
|
23
|
+
{list(entities(model.todos), (todo) => (
|
|
24
|
+
<li>{todo.title}</li>
|
|
25
|
+
))}
|
|
26
|
+
</ul>
|
|
27
|
+
));
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`collection(identity).from(items)` supplies a custom domain identity; `sequence(items)` supplies positional identity. `list(rawArray, render)` uses each item itself as its identity, so repeated values or references require an explicit collection identity. Duplicate identities throw with both positions. The callback receives the exact current item, including a replacement object with the same ID. `Rows.map` and ordinary array `.map` remain normal JavaScript mapping operations; their rendered arrays use positional identity.
|
|
31
|
+
|
|
32
|
+
`slot(render)` is a typed render callback. Call it explicitly, including `footer()` for a `Slot<void>`; a function is not implicit JSX content. See the [0.4.0 migration guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/migration-0.4.0.md).
|
|
33
|
+
|
|
34
|
+
## DOM and Effect lifetimes
|
|
35
|
+
|
|
36
|
+
`domMount(acquire)` uses the acquisition function as its identity. Declare it outside the view body when its lifetime should survive model updates. `domBinding(data, acquire)` keeps a stable acquisition while supplying fresh data through `input()`. Return `{ update, dispose }` when the integration must apply changed data, or a scoped Effect that remains active for the lifetime. A new acquisition function replaces the old lifetime.
|
|
37
|
+
|
|
38
|
+
Fresh event callbacks see current model data without canceling already running listener work. An Effect event's `replace` policy cancels the previous request when a new event submits work; removing the handler or unmounting disposes its owned work.
|
|
39
|
+
|
|
40
|
+
`dispose()` interrupts owned work synchronously. `close()`, `awaitIdle()`, and `awaitStopped()` return Effects; merely creating or JavaScript-awaiting one does not execute it. Use `yield* owner.close()` inside an Effect or `await Effect.runPromise(owner.close())` at a Promise integration boundary. `close()` waits for finalizers; `awaitIdle()` waits without closing the owner. A mount returned by `mountView` also has `close()` for its DOM-owned work. The supplied program keeps its separate ownership.
|
|
41
|
+
|
|
11
42
|
## Query identity and account ownership
|
|
12
43
|
|
|
13
44
|
A query definition has its own identity. Within one cache, all request arguments form its key automatically:
|
|
@@ -76,7 +107,7 @@ Queue progress continues after successes, typed failures, or defects. Component
|
|
|
76
107
|
|
|
77
108
|
`<Portal mount={model.dialogHost}>...</Portal>` renders into a supplied HTML or SVG element. Omitting `mount` uses the document body. Portal content retains its owner, events, and cleanup; changing between HTML and SVG targets rebuilds content in the correct namespace.
|
|
78
109
|
|
|
79
|
-
## Inspect source
|
|
110
|
+
## Inspect source bindings
|
|
80
111
|
|
|
81
112
|
Mount a development panel before mounting the application so it sees initial evaluations:
|
|
82
113
|
|
|
@@ -88,11 +119,11 @@ const removeInspector = mountBindingInspector(document.querySelector<HTMLElement
|
|
|
88
119
|
// removeInspector();
|
|
89
120
|
```
|
|
90
121
|
|
|
91
|
-
The live, filterable table shows original file/line/column, source expressions,
|
|
122
|
+
The live, filterable table shows original file/line/column, source expressions, and binding evaluation counts. Development metadata records the expressions actually evaluated for text and attributes; it does not infer which model fields a helper reads. Production builds emit none. Counts include initial evaluations, aggregate instances of the same source expression, and measure binding evaluations rather than DOM writes. Change reasons compare evaluated values without retaining previous or next values.
|
|
92
123
|
|
|
93
124
|
For custom tooling, `inspectBindings({ limit: 200 })` returns `entries()`, `subscribe(listener)`, `clear()`, and `dispose()`. Entries are immutable metadata, newest first, with at most 1000 source records. Least recently updated sources are evicted and start fresh if seen again. `dispose()` unsubscribes and clears retained metadata. `mountBindingInspector(element, inspector)` can share an inspector; removing that panel leaves the supplied inspector running. Low-level `observeBindings` remains available.
|
|
94
125
|
|
|
95
|
-
The inspector covers instrumented
|
|
126
|
+
The inspector covers instrumented text/attribute bindings; it is not a snapshot recorder, time-travel debugger, or complete profile of view evaluation and list reconciliation. Use application profiling for expensive render helpers.
|
|
96
127
|
|
|
97
128
|
## Immutable inputs and outputs
|
|
98
129
|
|
package/dist/AsyncContent.d.ts
CHANGED
package/dist/AsyncContent.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as _ew_dom from "./dom.js";
|
|
2
2
|
import { commandSlot } from "./program.js";
|
|
3
|
-
import
|
|
3
|
+
import * as Cause from "effect/Cause";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
4
6
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
5
7
|
import { component } from "./component.js";
|
|
6
8
|
import { view } from "./index.js";
|
|
@@ -47,40 +49,21 @@ const implementation = component({
|
|
|
47
49
|
...model,
|
|
48
50
|
visible: true
|
|
49
51
|
} }),
|
|
50
|
-
view:
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
_ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_4().result.waiting, (_ew_scope_1, _ew_parent_15, _ew_before_16) => {
|
|
66
|
-
_ew_dom.text(_ew_scope_1, _ew_parent_15, _ew_before_16, () => [_ew_derived_4()?.refreshing], () => _ew_derived_4().refreshing);
|
|
67
|
-
}, (_ew_scope_1, _ew_parent_15, _ew_before_16) => { });
|
|
68
|
-
_ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_17, _ew_before_18) => {
|
|
69
|
-
_ew_dom.text(_ew_scope_1, _ew_parent_17, _ew_before_18, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
|
|
70
|
-
}, (_ew_scope_1, _ew_parent_17, _ew_before_18) => { });
|
|
71
|
-
}, (_ew_scope_1, _ew_parent_13, _ew_before_14) => {
|
|
72
|
-
_ew_dom.branch(_ew_scope_1, _ew_parent_13, _ew_before_14, () => _ew_derived_10() && _ew_derived_4().failure, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
|
|
73
|
-
_ew_dom.text(_ew_scope_1, _ew_parent_19, _ew_before_20, () => [_ew_derived_4(), _ew_derived_10()?.cause], () => _ew_derived_4().failure(_ew_derived_10().cause));
|
|
74
|
-
}, (_ew_scope_1, _ew_parent_19, _ew_before_20) => {
|
|
75
|
-
_ew_dom.branch(_ew_scope_1, _ew_parent_19, _ew_before_20, () => _ew_scope_1.value.pending, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
|
|
76
|
-
_ew_dom.branch(_ew_scope_1, _ew_parent_21, _ew_before_22, () => _ew_scope_1.value.visible, (_ew_scope_1, _ew_parent_23, _ew_before_24) => {
|
|
77
|
-
_ew_dom.text(_ew_scope_1, _ew_parent_23, _ew_before_24, () => [_ew_derived_4()?.pending], () => _ew_derived_4().pending);
|
|
78
|
-
}, (_ew_scope_1, _ew_parent_23, _ew_before_24) => { });
|
|
79
|
-
}, (_ew_scope_1, _ew_parent_21, _ew_before_22) => {
|
|
80
|
-
_ew_dom.text(_ew_scope_1, _ew_parent_21, _ew_before_22, () => [_ew_derived_4()?.empty], () => _ew_derived_4().empty);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
});
|
|
52
|
+
view: view((model, _send) => {
|
|
53
|
+
const props = model.props;
|
|
54
|
+
const data = AsyncResult.value(props.result);
|
|
55
|
+
const failure = AsyncResult.isFailure(props.result) && !props.result.waiting ? props.result : undefined;
|
|
56
|
+
if (Option.isSome(data))
|
|
57
|
+
return [
|
|
58
|
+
props.content(data.value),
|
|
59
|
+
props.result.waiting ? props.refreshing : null,
|
|
60
|
+
failure && props.failure ? props.failure(failure.cause) : null
|
|
61
|
+
];
|
|
62
|
+
if (failure && props.failure)
|
|
63
|
+
return props.failure(failure.cause);
|
|
64
|
+
if (model.pending)
|
|
65
|
+
return model.visible ? props.pending : null;
|
|
66
|
+
return props.empty;
|
|
84
67
|
})
|
|
85
68
|
});
|
|
86
69
|
/** Presentation only: resource owners choose identity, loading, caching and cancellation. */
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type View } from './dom.js';
|
|
2
|
+
import { type ReportError } from './errors.js';
|
|
3
|
+
import { type Snapshot } from './snapshot.js';
|
|
4
|
+
export declare function errorBoundary<Model, Message>(content: View<Model, Message>, options: {
|
|
5
|
+
readonly fallback: View<{
|
|
6
|
+
readonly model: NoInfer<Model>;
|
|
7
|
+
readonly error: unknown;
|
|
8
|
+
}, NoInfer<Message>>;
|
|
9
|
+
readonly reset?: (model: Snapshot<NoInfer<Model>>) => unknown;
|
|
10
|
+
readonly onError?: ReportError;
|
|
11
|
+
}): View<Model, Message>;
|
package/dist/boundary.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { child, compiled, viewRegion } from './dom.js';
|
|
2
|
+
import { reportError, reportSafely } from './errors.js';
|
|
3
|
+
import { protectSnapshot } from './snapshot.js';
|
|
4
|
+
export function errorBoundary(content, options) {
|
|
5
|
+
return compiled((scope, parent, before) => {
|
|
6
|
+
let failed = false;
|
|
7
|
+
let error;
|
|
8
|
+
let generation = 0;
|
|
9
|
+
let reset = options.reset?.(scope.value);
|
|
10
|
+
const fallback = compiled((scope, parent, before) => {
|
|
11
|
+
child(scope, parent, before, options.fallback, () => [scope.value], () => protectSnapshot({ model: scope.value, error }), scope.send);
|
|
12
|
+
});
|
|
13
|
+
const region = { manual: true, report: scope.report };
|
|
14
|
+
const render = viewRegion(scope, parent, before, region);
|
|
15
|
+
function showFallback() {
|
|
16
|
+
if (scope.disposed || !failed)
|
|
17
|
+
return;
|
|
18
|
+
region.report = scope.report;
|
|
19
|
+
try {
|
|
20
|
+
render(fallback);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
reportSafely(scope.report, error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fail(cause, attempt) {
|
|
27
|
+
if (scope.disposed || attempt !== generation) {
|
|
28
|
+
reportSafely(options.onError ?? reportError, cause);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
failed = true;
|
|
32
|
+
error = cause;
|
|
33
|
+
const token = ++generation;
|
|
34
|
+
render(undefined);
|
|
35
|
+
reportSafely(options.onError ?? reportError, cause);
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (token === generation)
|
|
38
|
+
showFallback();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const update = () => {
|
|
42
|
+
const next = options.reset?.(scope.value);
|
|
43
|
+
if (!Object.is(reset, next)) {
|
|
44
|
+
reset = next;
|
|
45
|
+
generation++;
|
|
46
|
+
render(undefined);
|
|
47
|
+
failed = false;
|
|
48
|
+
error = undefined;
|
|
49
|
+
}
|
|
50
|
+
if (failed)
|
|
51
|
+
showFallback();
|
|
52
|
+
else {
|
|
53
|
+
const attempt = generation;
|
|
54
|
+
region.report = (cause) => fail(cause, attempt);
|
|
55
|
+
try {
|
|
56
|
+
render(content);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
fail(error, attempt);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
scope.jobs.push(update);
|
|
64
|
+
update();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -5,6 +5,10 @@ import type { QueryCache } from './cache.js';
|
|
|
5
5
|
import type { Query } from './query.js';
|
|
6
6
|
import type { Snapshot } from './snapshot.js';
|
|
7
7
|
export interface CacheInternals<R> {
|
|
8
|
+
refresh(atom: Atom.Atom<unknown>): void;
|
|
9
|
+
readonly disposed: () => boolean;
|
|
10
|
+
revision<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): number | undefined;
|
|
11
|
+
retain(atom: Atom.Atom<unknown>): () => void;
|
|
8
12
|
readonly registry: AtomRegistry.AtomRegistry;
|
|
9
13
|
readonly generation: Atom.Writable<number>;
|
|
10
14
|
query<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { type Snapshot } from './snapshot.js';
|
|
2
|
-
import
|
|
3
|
-
import { type Query } from './query.js';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import { type Query, type QueryGroup } from './query.js';
|
|
4
4
|
import type { UiRuntime } from './runtime.js';
|
|
5
5
|
export { loadEffect, type UiLoad } from './load.js';
|
|
6
6
|
export { shareValue } from './share.js';
|
|
7
|
-
export
|
|
8
|
-
|
|
7
|
+
export interface QueryCacheOptions {
|
|
8
|
+
readonly retention?: number;
|
|
9
|
+
readonly unused?: 'retain' | 'cancel' | undefined;
|
|
10
|
+
}
|
|
11
|
+
export declare function makeQueryCache(options?: QueryCacheOptions): QueryCache<never>;
|
|
12
|
+
export declare function makeQueryCache<R>(runtime: UiRuntime<R>, options?: QueryCacheOptions): QueryCache<R>;
|
|
9
13
|
/** A cache owns one registry and the query resources published through it. */
|
|
10
14
|
export interface QueryCache<R = never> {
|
|
11
|
-
|
|
15
|
+
batch(work: () => void): void;
|
|
16
|
+
getQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Snapshot<A> | undefined;
|
|
17
|
+
invalidateWhere<Args, A, E>(definition: Query<Args, A, E, R>, predicate: (args: Snapshot<Args>) => boolean): void;
|
|
18
|
+
invalidateGroup(group: QueryGroup): void;
|
|
19
|
+
/** Cancel requests while retaining the last success. Explicit refresh restarts them. */
|
|
20
|
+
cancelQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
|
|
21
|
+
/** Clear cached data and cancel requests; a subsequent selection or refresh reloads. */
|
|
22
|
+
removeQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
|
|
23
|
+
prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, options?: {
|
|
24
|
+
readonly refresh?: boolean;
|
|
25
|
+
}): Effect.Effect<Snapshot<A>, E>;
|
|
12
26
|
/** Publish a protected success, including undefined, and supersede any pending load for this key. */
|
|
13
27
|
setQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, value: NoInfer<A> | Snapshot<NoInfer<A>>): Snapshot<A>;
|
|
14
28
|
/**
|
|
@@ -21,5 +35,7 @@ export interface QueryCache<R = never> {
|
|
|
21
35
|
/** Observe account resets without exposing registry mutation. */
|
|
22
36
|
onReset(listener: () => void): () => void;
|
|
23
37
|
resetResources(): void;
|
|
38
|
+
/** Interrupt every request and wait for its finalizers, including previously canceled requests. */
|
|
39
|
+
close(): Effect.Effect<void>;
|
|
24
40
|
dispose(): void;
|
|
25
41
|
}
|
package/dist/cache.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { Settlement } from './settlement.js';
|
|
1
2
|
import { protectSnapshot } from './snapshot.js';
|
|
2
|
-
import
|
|
3
|
+
import * as Deferred from 'effect/Deferred';
|
|
4
|
+
import * as Effect from 'effect/Effect';
|
|
5
|
+
import * as Option from 'effect/Option';
|
|
3
6
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
4
7
|
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
5
8
|
import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
|
|
@@ -10,18 +13,29 @@ import { registerCache } from './cache-internals.js';
|
|
|
10
13
|
import { queryDefinition } from './query-internals.js';
|
|
11
14
|
export { loadEffect } from './load.js';
|
|
12
15
|
export { shareValue } from './share.js';
|
|
13
|
-
export function makeQueryCache(
|
|
14
|
-
return
|
|
16
|
+
export function makeQueryCache(runtimeOrOptions, options = {}) {
|
|
17
|
+
return runtimeOrOptions && 'provide' in runtimeOrOptions
|
|
18
|
+
? createQueryCache(runtimeOrOptions, options)
|
|
19
|
+
: createQueryCache(undefined, runtimeOrOptions);
|
|
15
20
|
}
|
|
16
|
-
function createQueryCache(runtime) {
|
|
17
|
-
const
|
|
21
|
+
function createQueryCache(runtime, options = {}) {
|
|
22
|
+
const retention = options.retention ?? 30_000;
|
|
23
|
+
if (!Number.isFinite(retention) || retention < 0)
|
|
24
|
+
throw new RangeError('Query retention must be finite and nonnegative.');
|
|
25
|
+
const registry = AtomRegistry.make({ defaultIdleTTL: retention });
|
|
26
|
+
const cancelValue = Symbol('cancel');
|
|
27
|
+
const removeValue = Symbol('remove');
|
|
18
28
|
const generation = Atom.keepAlive(Atom.make(0));
|
|
19
29
|
const resources = new Map();
|
|
30
|
+
const entriesByAtom = new WeakMap();
|
|
20
31
|
// Follow registry-node lifetime; retaining definitions must not retain evicted data.
|
|
21
32
|
const values = new WeakMap();
|
|
22
33
|
const identities = new WeakMap();
|
|
23
34
|
let disposed = false;
|
|
35
|
+
const settlement = new Settlement();
|
|
36
|
+
let closing;
|
|
24
37
|
let nextId = 0;
|
|
38
|
+
let nextRevision = 0;
|
|
25
39
|
const identity = (definition) => {
|
|
26
40
|
let id = identities.get(definition);
|
|
27
41
|
if (id === undefined) {
|
|
@@ -37,28 +51,54 @@ function createQueryCache(runtime) {
|
|
|
37
51
|
else {
|
|
38
52
|
const loaded = Atom.make((get) => {
|
|
39
53
|
const previous = Option.flatMap(get.self(), AsyncResult.value);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
next.revision = ++nextRevision;
|
|
55
|
+
next.canceled = false;
|
|
56
|
+
const revision = next.revision;
|
|
57
|
+
return Effect.suspend(() => {
|
|
58
|
+
const finish = settlement.begin();
|
|
59
|
+
return loadEffect(() => next.load()).pipe(Effect.map((value) => {
|
|
60
|
+
const shared = Option.isSome(previous)
|
|
61
|
+
? share(previous.value, value)
|
|
62
|
+
: value;
|
|
63
|
+
const snapshot = protectSnapshot(shared);
|
|
64
|
+
if (!disposed && revision === next.revision)
|
|
65
|
+
remember(next, snapshot);
|
|
66
|
+
return snapshot;
|
|
67
|
+
}), Effect.ensuring(Effect.sync(finish)));
|
|
68
|
+
});
|
|
48
69
|
});
|
|
49
70
|
const next = {
|
|
50
71
|
load,
|
|
72
|
+
users: 0,
|
|
73
|
+
prefetches: new Set(),
|
|
74
|
+
revision: 0,
|
|
51
75
|
atom: Atom.writable(loaded.read, (context, value) => {
|
|
52
76
|
Atom.batch(() => {
|
|
77
|
+
next.revision = ++nextRevision;
|
|
53
78
|
// Refresh disposes the load lifetime; setSelf publishes without starting another load.
|
|
54
79
|
context.refreshSelf();
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
if (value === cancelValue || value === removeValue) {
|
|
81
|
+
const previous = value === removeValue ? undefined : previousValue(next);
|
|
82
|
+
next.canceled = true;
|
|
83
|
+
if (value === removeValue) {
|
|
84
|
+
delete next.loadedAt;
|
|
85
|
+
const node = registry.getNodes().get(next.atom);
|
|
86
|
+
if (node)
|
|
87
|
+
values.delete(node);
|
|
88
|
+
}
|
|
89
|
+
context.setSelf(previous ? AsyncResult.success(previous.value) : AsyncResult.initial());
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
next.canceled = false;
|
|
93
|
+
remember(next, value);
|
|
94
|
+
context.setSelf(AsyncResult.success(value));
|
|
95
|
+
}
|
|
57
96
|
});
|
|
58
97
|
}),
|
|
59
98
|
};
|
|
60
99
|
entry = next;
|
|
61
100
|
resources.set(key, entry);
|
|
101
|
+
entriesByAtom.set(entry.atom, entry);
|
|
62
102
|
}
|
|
63
103
|
// Bound definitions as well as the registry's values. Mounted resources stay shared.
|
|
64
104
|
if (resources.size > 512) {
|
|
@@ -90,26 +130,117 @@ function createQueryCache(runtime) {
|
|
|
90
130
|
const config = queryDefinition(definition);
|
|
91
131
|
protectSnapshot(args);
|
|
92
132
|
const { entry, atom } = acquire(queryKey(definition, args), () => {
|
|
93
|
-
const effect = Effect.suspend(() => config.load(args));
|
|
133
|
+
const effect = Effect.suspend(() => config.load(args, previousValue(resources.get(queryKey(definition, args)))?.value));
|
|
94
134
|
return runtime ? runtime.provide(effect) : effect;
|
|
95
135
|
}, config.share);
|
|
96
136
|
entry.query = definition;
|
|
137
|
+
entry.args = args;
|
|
138
|
+
entry.groups = config.groups;
|
|
139
|
+
entry.unused = config.unused ?? options.unused;
|
|
97
140
|
return { entry, atom, config };
|
|
98
141
|
};
|
|
142
|
+
const refresh = (entry) => {
|
|
143
|
+
entry.revision = ++nextRevision;
|
|
144
|
+
registry.refresh(entry.atom);
|
|
145
|
+
};
|
|
99
146
|
const selectQuery = (definition, args) => {
|
|
100
147
|
const { entry, atom, config } = acquireQuery(definition, args);
|
|
148
|
+
if (entry.canceled && !previousValue(entry))
|
|
149
|
+
refresh(entry);
|
|
101
150
|
if (registry.getNodes().has(atom) &&
|
|
102
151
|
entry.loadedAt !== undefined &&
|
|
103
152
|
Date.now() - entry.loadedAt >= config.staleTime) {
|
|
104
153
|
const current = registry.get(atom);
|
|
105
154
|
if (!current.waiting)
|
|
106
|
-
|
|
155
|
+
refresh(entry);
|
|
107
156
|
}
|
|
108
157
|
return atom;
|
|
109
158
|
};
|
|
159
|
+
const retain = (atom) => {
|
|
160
|
+
const entry = entriesByAtom.get(atom);
|
|
161
|
+
if (!entry)
|
|
162
|
+
return () => { };
|
|
163
|
+
entry.users++;
|
|
164
|
+
let released = false;
|
|
165
|
+
return () => {
|
|
166
|
+
if (released)
|
|
167
|
+
return;
|
|
168
|
+
released = true;
|
|
169
|
+
entry.users--;
|
|
170
|
+
if (!disposed &&
|
|
171
|
+
entry.users === 0 &&
|
|
172
|
+
entry.unused === 'cancel' &&
|
|
173
|
+
registry.getNodes().has(entry.atom) &&
|
|
174
|
+
registry.get(entry.atom).waiting)
|
|
175
|
+
registry.set(entry.atom, cancelValue);
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
const abortPrefetches = (entry) => {
|
|
179
|
+
// oxlint-disable-next-line unicorn/no-useless-spread -- Cancellation can add or remove observers reentrantly.
|
|
180
|
+
for (const abort of [...entry.prefetches])
|
|
181
|
+
abort();
|
|
182
|
+
};
|
|
110
183
|
const cache = {
|
|
111
|
-
|
|
112
|
-
|
|
184
|
+
batch: Atom.batch,
|
|
185
|
+
getQueryData(definition, args) {
|
|
186
|
+
return previousValue(resources.get(queryKey(definition, args)))?.value;
|
|
187
|
+
},
|
|
188
|
+
invalidateWhere(definition, predicate) {
|
|
189
|
+
const selected = [...resources.values()].filter((entry) => entry.query === definition && predicate(entry.args));
|
|
190
|
+
Atom.batch(() => {
|
|
191
|
+
for (const entry of selected)
|
|
192
|
+
refresh(entry);
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
invalidateGroup(group) {
|
|
196
|
+
Atom.batch(() => {
|
|
197
|
+
for (const entry of resources.values())
|
|
198
|
+
if (entry.groups?.includes(group))
|
|
199
|
+
refresh(entry);
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
cancelQuery(definition, ...selected) {
|
|
203
|
+
Atom.batch(() => {
|
|
204
|
+
for (const [key, entry] of resources)
|
|
205
|
+
if (entry.query === definition &&
|
|
206
|
+
(!selected.length || key === queryKey(definition, selected[0])))
|
|
207
|
+
if (registry.getNodes().has(entry.atom) && registry.get(entry.atom).waiting) {
|
|
208
|
+
abortPrefetches(entry);
|
|
209
|
+
registry.set(entry.atom, cancelValue);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
removeQuery(definition, ...selected) {
|
|
214
|
+
Atom.batch(() => {
|
|
215
|
+
for (const [key, entry] of resources)
|
|
216
|
+
if (entry.query === definition &&
|
|
217
|
+
(!selected.length || key === queryKey(definition, selected[0])))
|
|
218
|
+
if (registry.getNodes().has(entry.atom)) {
|
|
219
|
+
abortPrefetches(entry);
|
|
220
|
+
registry.set(entry.atom, removeValue);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
prefetch(definition, args, options) {
|
|
225
|
+
return Effect.suspend(() => {
|
|
226
|
+
checkWritable();
|
|
227
|
+
const atom = selectQuery(definition, args);
|
|
228
|
+
const release = retain(atom);
|
|
229
|
+
const entry = entriesByAtom.get(atom);
|
|
230
|
+
const signal = Deferred.makeUnsafe();
|
|
231
|
+
const abort = () => {
|
|
232
|
+
Deferred.doneUnsafe(signal, Effect.interrupt);
|
|
233
|
+
};
|
|
234
|
+
entry.prefetches.add(abort);
|
|
235
|
+
return Effect.suspend(() => {
|
|
236
|
+
if (options?.refresh && registry.getNodes().has(atom) && !registry.get(atom).waiting)
|
|
237
|
+
refresh(entry);
|
|
238
|
+
return Effect.raceFirst(Deferred.await(signal), AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }));
|
|
239
|
+
}).pipe(Effect.ensuring(Effect.sync(() => {
|
|
240
|
+
entry.prefetches.delete(abort);
|
|
241
|
+
release();
|
|
242
|
+
})));
|
|
243
|
+
});
|
|
113
244
|
},
|
|
114
245
|
setQueryData(definition, args, value) {
|
|
115
246
|
checkWritable();
|
|
@@ -140,12 +271,10 @@ function createQueryCache(runtime) {
|
|
|
140
271
|
if (selected.length) {
|
|
141
272
|
const entry = resources.get(queryKey(definition, selected[0]));
|
|
142
273
|
if (entry)
|
|
143
|
-
|
|
274
|
+
refresh(entry);
|
|
144
275
|
}
|
|
145
276
|
else {
|
|
146
|
-
|
|
147
|
-
if (entry.query === definition)
|
|
148
|
-
registry.refresh(entry.atom);
|
|
277
|
+
cache.invalidateWhere(definition, () => true);
|
|
149
278
|
}
|
|
150
279
|
},
|
|
151
280
|
onReset(listener) {
|
|
@@ -155,21 +284,45 @@ function createQueryCache(runtime) {
|
|
|
155
284
|
resetResources() {
|
|
156
285
|
Atom.batch(() => {
|
|
157
286
|
for (const entry of resources.values()) {
|
|
287
|
+
abortPrefetches(entry);
|
|
158
288
|
entry.load = () => Effect.interrupt;
|
|
159
|
-
|
|
289
|
+
refresh(entry);
|
|
160
290
|
}
|
|
161
291
|
resources.clear();
|
|
162
292
|
registry.update(generation, (value) => value + 1);
|
|
163
293
|
});
|
|
164
294
|
},
|
|
295
|
+
close() {
|
|
296
|
+
if (!closing) {
|
|
297
|
+
closing = Effect.suspend(() => {
|
|
298
|
+
cache.dispose();
|
|
299
|
+
return settlement.wait();
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return closing;
|
|
303
|
+
},
|
|
165
304
|
dispose() {
|
|
166
305
|
if (disposed)
|
|
167
306
|
return;
|
|
168
307
|
disposed = true;
|
|
308
|
+
for (const entry of resources.values())
|
|
309
|
+
abortPrefetches(entry);
|
|
169
310
|
registry.dispose();
|
|
170
311
|
resources.clear();
|
|
171
312
|
},
|
|
172
313
|
};
|
|
173
|
-
registerCache(cache, {
|
|
314
|
+
registerCache(cache, {
|
|
315
|
+
disposed: () => disposed,
|
|
316
|
+
refresh: (atom) => {
|
|
317
|
+
const entry = entriesByAtom.get(atom);
|
|
318
|
+
if (entry)
|
|
319
|
+
refresh(entry);
|
|
320
|
+
},
|
|
321
|
+
registry,
|
|
322
|
+
generation,
|
|
323
|
+
query: selectQuery,
|
|
324
|
+
retain,
|
|
325
|
+
revision: (definition, args) => resources.get(queryKey(definition, args))?.revision,
|
|
326
|
+
});
|
|
174
327
|
return Object.freeze(cache);
|
|
175
328
|
}
|
package/dist/collection.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface Rows<A> {
|
|
|
9
9
|
slice(start?: number, end?: number): Rows<A>;
|
|
10
10
|
}
|
|
11
11
|
/** Validate without retaining values. Positions are zero-based array indices. */
|
|
12
|
-
export declare function validateIdentities<A>(items: readonly A[], identity: (item: A, index: number) =>
|
|
12
|
+
export declare function validateIdentities<A, K>(items: readonly A[], identity: (item: A, index: number) => K): K[];
|
|
13
13
|
export interface Collection<A> {
|
|
14
14
|
from(this: void, items: readonly (A | Snapshot<A>)[]): Rows<Snapshot<A>>;
|
|
15
15
|
share<B extends A | Snapshot<A>>(this: void, previous: readonly B[], next: readonly B[]): readonly Snapshot<B>[];
|
package/dist/collection.js
CHANGED
|
@@ -2,10 +2,8 @@ import { shareData } from './sharing.js';
|
|
|
2
2
|
/** Validate without retaining values. Positions are zero-based array indices. */
|
|
3
3
|
export function validateIdentities(items, identity) {
|
|
4
4
|
const seen = new Map();
|
|
5
|
-
return
|
|
5
|
+
return Array.from(items, (item, index) => {
|
|
6
6
|
const key = identity(item, index);
|
|
7
|
-
if (typeof key !== 'string' && typeof key !== 'number')
|
|
8
|
-
throw new Error(`Invalid collection identity at index ${index}. Use a string or number domain identity, or sequence(items) for positional identity.`);
|
|
9
7
|
const previous = seen.get(key);
|
|
10
8
|
if (previous !== undefined)
|
|
11
9
|
throw new Error(`Duplicate collection identity at indices ${previous} and ${index}. Identity must be unique within the collection; use a composite domain identity when IDs are only locally unique.`);
|
package/dist/component.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Transition } from './program.js';
|
|
|
4
4
|
import { type UiRuntime } from './runtime.js';
|
|
5
5
|
/** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
|
|
6
6
|
export declare function localComponent<Props, State extends object>(definition: {
|
|
7
|
+
identity?: (props: Snapshot<Props>) => unknown;
|
|
7
8
|
init: (props: Snapshot<Props>) => (State | Snapshot<State>) & {
|
|
8
9
|
readonly props?: never;
|
|
9
10
|
};
|
|
@@ -17,6 +18,7 @@ export declare function localComponent<Props, State extends object>(definition:
|
|
|
17
18
|
export declare function component<Props, Model extends {
|
|
18
19
|
readonly props: Props;
|
|
19
20
|
}, Message, R = never>(definition: {
|
|
21
|
+
identity?: (props: Snapshot<Props>) => unknown;
|
|
20
22
|
init: (props: Snapshot<Props>) => Model | Snapshot<Model>;
|
|
21
23
|
receive?: (model: Snapshot<Model>, props: Snapshot<Props>) => Transition<Model, Message, R>;
|
|
22
24
|
update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
|
|
@@ -28,6 +30,7 @@ export declare function component<Props, Model extends {
|
|
|
28
30
|
})): View<Props, never>;
|
|
29
31
|
/** Mount an existing program without introducing a second state owner. */
|
|
30
32
|
export declare function programView<Props, Model, Message>(definition: {
|
|
33
|
+
identity?: (props: Snapshot<Props>) => unknown;
|
|
31
34
|
create: (props: Snapshot<Props>) => import('./program').Program<Model, Message>;
|
|
32
35
|
receive: (source: import('./program').Program<Model, Message>, props: Snapshot<Props>) => void;
|
|
33
36
|
view: View<Model, Message>;
|