effectweb 0.2.3 → 0.3.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 +93 -1
- package/dist/AsyncContent.d.ts +2 -2
- package/dist/AsyncContent.js +6 -3
- package/dist/actions.d.ts +4 -3
- package/dist/actions.js +1 -0
- package/dist/cache-internals.d.ts +14 -0
- package/dist/cache-internals.js +11 -0
- package/dist/cache.d.ts +17 -14
- package/dist/cache.js +98 -26
- package/dist/collection.d.ts +10 -9
- package/dist/collection.js +32 -6
- package/dist/component.d.ts +8 -7
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +167 -1
- package/dist/dom-attributes.json +71 -0
- package/dist/dom.d.ts +33 -12
- package/dist/dom.js +466 -80
- package/dist/form.d.ts +61 -0
- package/dist/form.js +98 -0
- package/dist/html-attributes.d.ts +602 -0
- package/dist/html-attributes.js +1 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +7 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +7 -5
- package/dist/jsx.d.ts +165 -33
- package/dist/jsx.js +2 -1
- package/dist/lazy.d.ts +23 -0
- package/dist/lazy.js +58 -0
- package/dist/load.d.ts +3 -3
- package/dist/mount.d.ts +8 -11
- package/dist/mount.js +1 -3
- package/dist/owner.d.ts +11 -9
- package/dist/owner.js +27 -36
- package/dist/pages.d.ts +23 -22
- package/dist/pages.js +15 -9
- package/dist/program.d.ts +19 -9
- package/dist/program.js +145 -49
- package/dist/query-internals.d.ts +11 -0
- package/dist/query-internals.js +10 -0
- package/dist/query.d.ts +24 -19
- package/dist/query.js +60 -7
- package/dist/resource.d.ts +4 -10
- package/dist/resource.js +14 -5
- package/dist/runtime.d.ts +1 -2
- package/dist/runtime.js +3 -3
- package/dist/session.d.ts +12 -15
- package/dist/session.js +15 -12
- package/dist/share.d.ts +5 -4
- package/dist/share.js +5 -44
- package/dist/sharing.d.ts +6 -0
- package/dist/sharing.js +93 -0
- package/dist/snapshot.d.ts +16 -5
- package/dist/snapshot.js +6 -7
- package/dist/svg-attributes.d.ts +12 -0
- package/dist/svg-attributes.js +1 -0
- package/dist/task.d.ts +9 -8
- package/dist/task.js +75 -47
- package/dist/tasks.d.ts +20 -15
- package/dist/tasks.js +39 -25
- package/dist/testing.d.ts +10 -14
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -6,4 +6,96 @@ Use with `@effectweb/compiler/vite` and Effect `4.0.0-rc.112`. The package inclu
|
|
|
6
6
|
|
|
7
7
|
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
|
+
|
|
11
|
+
## Query identity and account ownership
|
|
12
|
+
|
|
13
|
+
A query definition has its own identity. Within one cache, all request arguments form its key automatically:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const page = query({
|
|
17
|
+
name: 'message-page',
|
|
18
|
+
load: (args: { accountId: string; threadId: string; cursor?: string }) =>
|
|
19
|
+
api.messages(args.accountId, args.threadId, args.cursor),
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Request arguments use the same canonical encoding for cache lookup, prefetch, writes, invalidation, and `queryResource.select`. Object property order is ignored; array order matters. Strings, finite numbers, booleans, `null`, and explicit `undefined` are supported recursively in dense arrays and plain objects. Missing properties differ from properties containing `undefined`, and `0` differs from `-0`. Functions, symbols, bigint, nonfinite numbers, cycles, class instances, accessors, nonenumerable properties, sparse arrays, and arrays with extra properties throw `TypeError`. Repeated references to the same plain object are supported. Published argument objects are frozen and are not cloned.
|
|
24
|
+
|
|
25
|
+
Pass every load-relevant value as an argument, including account, filters, pagination, locale, and permissions when they affect the result. Custom `key` projections are rejected. Provide service instances through the Effect environment instead of query arguments. Separate query definitions do not share entries even if their names and arguments match. `select(undefined)` disables selection; an explicit `undefined` inside an argument object remains part of its identity. A query without arguments uses `true` for selection and prefetch.
|
|
26
|
+
|
|
27
|
+
Give authenticated data an account lifetime:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const account = modelOwner({ accountId }, { runtime });
|
|
31
|
+
const cache = account.own(makeQueryCache(runtime));
|
|
32
|
+
const messages = observeQuery(account, cache, page, (result) => {
|
|
33
|
+
// Publish the immutable result into application state.
|
|
34
|
+
});
|
|
35
|
+
messages.select({ accountId, threadId });
|
|
36
|
+
|
|
37
|
+
// Sign out or switch account: dispose this lifetime, then create the next one.
|
|
38
|
+
account.dispose();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Independent caches isolate account data even when services derive authentication from their environment. If an application deliberately reuses a cache across accounts, include account identity in every authenticated query key and call `cache.resetResources()` at the boundary before selecting the new account. Reset interrupts old resources and returns existing query observers to their initial state; select again to enter the new generation. Disposing only a query observer releases its subscription; it does not erase shared cached values. Own shared caches at the account or application scope, not in individual views.
|
|
42
|
+
|
|
43
|
+
Use `cache.setQueryData(query, args, value)` to seed or replace a cached success, and `cache.updateQueryData(query, args, update)` to change an existing success. The updater receives readonly data; returning `undefined` skips the write. Both publish to current observers and supersede pending loads for those arguments. Use `setQueryData` to store a successful `undefined` value. Query definitions and cache registries are opaque; use the public cache and observation methods.
|
|
44
|
+
|
|
45
|
+
## Write concurrency
|
|
46
|
+
|
|
47
|
+
`modelOwner.run(slot, effect, policy)` and `defineTasks(owner, definitions)` support these policies:
|
|
48
|
+
|
|
49
|
+
| Policy | Behavior for an occupied slot |
|
|
50
|
+
| --------------- | ----------------------------------------------------------------------- |
|
|
51
|
+
| `drop` | Ignore the new request. |
|
|
52
|
+
| `replace` | Cancel active work and discard pending requests; start the new request. |
|
|
53
|
+
| `parallel` | Start the new request alongside active work. |
|
|
54
|
+
| `queue` | Append the request; run it after earlier work finishes. |
|
|
55
|
+
| `latest-queued` | Keep active work; replace all pending requests with the newest request. |
|
|
56
|
+
|
|
57
|
+
Every command requires an explicit `policy`. Create stable operation identities with `commandSlot('save')`; equal diagnostic names do not share a slot. Component `defineTasks(...).tasks(...)` and `taskComponent` support all except `parallel`, since their single result slot represents serial work. Command mapping and runtime service provisioning preserve the policy. Actions sharing a slot share concurrency and cancellation. A queued request waits for every active request in that slot if policies are mixed; keep a consistent policy per slot for predictable write behavior.
|
|
58
|
+
|
|
59
|
+
Use `queue` when each accepted operation matters, such as appending messages. Use `latest-queued` when saving complete document snapshots and only the newest pending snapshot matters:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const writes = defineTasks(owner, {
|
|
63
|
+
save: { policy: 'latest-queued', run: (document: Snapshot<Document>) => storage.save(document) },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
writes.save(owner.read().document);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Component tasks capture the model snapshot and input when `Run` is submitted, including requests that wait in a queue. Editing fields afterward does not replace that captured model. Controller tasks retain their supplied arguments; their factories execute when work starts. Pass `owner.read()` data as arguments to capture submission state, or read inside the Effect when execution-time state is intended. Effects and inputs are retained, not deep-cloned; keep supplied data immutable. Dropped and coalesced pending factories are never invoked.
|
|
70
|
+
|
|
71
|
+
Queue progress continues after successes, typed failures, or defects. Component results remain `waiting` while more work is pending, publish each settlement, and retain the latest successful value if a later write fails. Action failures use the owner's error reporter. `cancel`, component reset or identity change, and disposal discard pending requests and interrupt active work; stale command completions cannot publish afterward. `awaitIdle` includes pending work. Cancellation cannot undo an external write that already completed. Transactions admit their whole command batch before starting Effects, so replacements and cancellation can remove superseded work without executing it.
|
|
72
|
+
|
|
73
|
+
## Lazy views and portals
|
|
74
|
+
|
|
75
|
+
`lazyView(() => fromPromise(() => import('./Reader').then((module) => module.Reader)))` loads a view when mounted. It accepts typed `pending` and `failure` views and a `runtime` when the loader requires services. Each unresolved placement owns its load; unmounting interrupts it. Successful definitions are cached for future mounts. Render a lazy view through `ViewBinding` with its current model and sender.
|
|
76
|
+
|
|
77
|
+
`<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
|
+
|
|
79
|
+
## Inspect source dependencies
|
|
80
|
+
|
|
81
|
+
Mount a development panel before mounting the application so it sees initial evaluations:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { mountBindingInspector } from 'effectweb/diagnostics';
|
|
85
|
+
|
|
86
|
+
const removeInspector = mountBindingInspector(document.querySelector<HTMLElement>('#inspector')!);
|
|
87
|
+
// Mount the application here. On teardown or HMR:
|
|
88
|
+
// removeInspector();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The live, filterable table shows original file/line/column, source expressions, inferred snapshot dependencies, the latest changed dependency names, and derive/binding evaluation counts. It uses development compiler metadata; production builds emit none. Counts include initial evaluations, aggregate instances of the same source expression, and measure evaluations rather than actual DOM writes. Change reasons use reference/value equality, without retaining previous or next values.
|
|
92
|
+
|
|
93
|
+
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
|
+
|
|
95
|
+
The inspector covers instrumented derivations and text/attribute bindings; it is not a snapshot recorder, time-travel debugger, or complete profile of branch/list reconciliation. Source labels describe the compiler's inferred dependencies, not a proof that an opaque helper has no hidden state.
|
|
96
|
+
|
|
97
|
+
## Immutable inputs and outputs
|
|
98
|
+
|
|
99
|
+
Published `Snapshot<T>` values are recursively readonly, including nested arrays/tuples and async success data. View/slot composition, component inputs, owner patches, and task field updates accept readonly branches without casts. Query cache reads, subscriptions, and prefetch publish the same readonly data. Plain objects and arrays are frozen before publication in every build; snapshot protection cannot be disabled.
|
|
100
|
+
|
|
101
|
+
Functions, Effects, DOM nodes, and explicitly marked service classes retain their own API. See the [authoring guide](https://github.com/DerpyCrabs/EffectWeb/blob/main/docs/authoring.md) for migration examples, `SnapshotOpaque`, state ownership, form composition, and reconciling async loads with live updates.
|
package/dist/AsyncContent.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export interface AsyncContentProps<A, E> {
|
|
|
13
13
|
}
|
|
14
14
|
type Props = AsyncContentProps<unknown, unknown>;
|
|
15
15
|
/** Presentation only: resource owners choose identity, loading, caching and cancellation. */
|
|
16
|
-
export declare const AsyncContent: {
|
|
17
|
-
<A, E>(props: AsyncContentProps<A, E>): JSX.Element;
|
|
16
|
+
export declare const AsyncContent: JSX.ComponentType & {
|
|
17
|
+
<A, E>(this: never, props: AsyncContentProps<A, E>): JSX.Element;
|
|
18
18
|
readonly build: View<Props, never>["build"];
|
|
19
19
|
};
|
|
20
20
|
export {};
|
package/dist/AsyncContent.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as _ew_dom from "./dom.js";
|
|
2
|
+
import { commandSlot } from "./program.js";
|
|
2
3
|
import { Cause, Effect, Option } from "effect";
|
|
3
4
|
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
4
5
|
import { component } from "./component.js";
|
|
5
6
|
import { view } from "./index.js";
|
|
6
7
|
import { effectCommand } from "./program.js";
|
|
8
|
+
const commandPending = commandSlot("pending");
|
|
7
9
|
const implementation = component({
|
|
8
10
|
init: (props) => ({
|
|
9
11
|
props,
|
|
@@ -19,7 +21,7 @@ const implementation = component({
|
|
|
19
21
|
pending: false,
|
|
20
22
|
visible: false
|
|
21
23
|
},
|
|
22
|
-
cancel: [
|
|
24
|
+
cancel: [commandPending]
|
|
23
25
|
};
|
|
24
26
|
if (model.pending && model.props.pendingDelay === props.pendingDelay)
|
|
25
27
|
return { model: {
|
|
@@ -33,8 +35,9 @@ const implementation = component({
|
|
|
33
35
|
pending: true,
|
|
34
36
|
visible: delay === 0
|
|
35
37
|
},
|
|
36
|
-
cancel: [
|
|
37
|
-
commands: delay === 0 ? [] : [effectCommand(
|
|
38
|
+
cancel: [commandPending],
|
|
39
|
+
commands: delay === 0 ? [] : [effectCommand(commandPending, () => Effect.sleep(delay), {
|
|
40
|
+
policy: "replace",
|
|
38
41
|
onSuccess: () => ({ type: "ShowPending" }),
|
|
39
42
|
onFailure: () => ({ type: "ShowPending" })
|
|
40
43
|
})]
|
package/dist/actions.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Send, Transition } from './program.js';
|
|
2
|
-
type
|
|
2
|
+
import type { Snapshot } from './snapshot.js';
|
|
3
|
+
type Handler<Model, R> = (model: Snapshot<Model>, ...args: never[]) => Transition<Model, unknown, R>;
|
|
3
4
|
type Arguments<F> = F extends (model: never, ...args: infer Args) => unknown ? Args : never;
|
|
4
5
|
type HandlerMessage<Handlers> = {
|
|
5
6
|
[Name in keyof Handlers]: {
|
|
@@ -18,9 +19,9 @@ type Creators<Handlers> = {
|
|
|
18
19
|
type Dispatch<Handlers> = {
|
|
19
20
|
[Name in keyof Handlers]: (...args: Arguments<Handlers[Name]>) => void;
|
|
20
21
|
};
|
|
21
|
-
export declare function defineActions<Model>(): <Handlers extends Record<string, Handler<Model>>>(handlers: Handlers) => {
|
|
22
|
+
export declare function defineActions<Model, R = never>(): <Handlers extends Record<string, Handler<Model, R>>>(handlers: Handlers) => {
|
|
22
23
|
message: Creators<Handlers>;
|
|
23
24
|
bind(this: void, send: Send<HandlerMessage<Handlers>>): Dispatch<Handlers>;
|
|
24
|
-
update(this: void, model: Model
|
|
25
|
+
update(this: void, model: Snapshot<Model>, action: HandlerMessage<Handlers>): ReturnType<Handlers[keyof Handlers]>;
|
|
25
26
|
};
|
|
26
27
|
export {};
|
package/dist/actions.js
CHANGED
|
@@ -23,6 +23,7 @@ export function defineActions() {
|
|
|
23
23
|
if (!Object.hasOwn(handlers, action.type))
|
|
24
24
|
throw new Error(`Unknown action: ${String(action.type)}`);
|
|
25
25
|
const handler = handlers[action.type];
|
|
26
|
+
// oxlint-disable-next-line typescript/no-unsafe-return -- The indexed handler and ReturnType refer to the same validated generic member.
|
|
26
27
|
return handler(model, ...action.args);
|
|
27
28
|
},
|
|
28
29
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
2
|
+
import type * as Atom from 'effect/unstable/reactivity/Atom';
|
|
3
|
+
import type * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
|
|
4
|
+
import type { QueryCache } from './cache.js';
|
|
5
|
+
import type { Query } from './query.js';
|
|
6
|
+
import type { Snapshot } from './snapshot.js';
|
|
7
|
+
export interface CacheInternals<R> {
|
|
8
|
+
readonly registry: AtomRegistry.AtomRegistry;
|
|
9
|
+
readonly generation: Atom.Writable<number>;
|
|
10
|
+
query<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerCache<R>(cache: QueryCache<R>, internals: CacheInternals<R>): void;
|
|
13
|
+
/** Private implementation access; deliberately absent from package exports. */
|
|
14
|
+
export declare function cacheInternals<R>(cache: QueryCache<R>): CacheInternals<R>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const caches = new WeakMap();
|
|
2
|
+
export function registerCache(cache, internals) {
|
|
3
|
+
caches.set(cache, internals);
|
|
4
|
+
}
|
|
5
|
+
/** Private implementation access; deliberately absent from package exports. */
|
|
6
|
+
export function cacheInternals(cache) {
|
|
7
|
+
const internals = caches.get(cache);
|
|
8
|
+
if (!internals)
|
|
9
|
+
throw new TypeError('Use makeQueryCache() to create a cache.');
|
|
10
|
+
return internals;
|
|
11
|
+
}
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
+
import { type Snapshot } from './snapshot.js';
|
|
1
2
|
import { Effect } from 'effect';
|
|
2
|
-
import
|
|
3
|
-
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
4
|
-
import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
|
|
5
|
-
import type { Query } from './query.js';
|
|
3
|
+
import { type Query } from './query.js';
|
|
6
4
|
import type { UiRuntime } from './runtime.js';
|
|
7
5
|
export { loadEffect, type UiLoad } from './load.js';
|
|
8
6
|
export { shareValue } from './share.js';
|
|
9
7
|
export declare function makeQueryCache(): QueryCache<never>;
|
|
10
8
|
export declare function makeQueryCache<R>(runtime: UiRuntime<R>): QueryCache<R>;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
/** A cache owns one registry and the query resources published through it. */
|
|
10
|
+
export interface QueryCache<R = never> {
|
|
11
|
+
prefetch<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Effect.Effect<Snapshot<A>, E>;
|
|
12
|
+
/** Publish a protected success, including undefined, and supersede any pending load for this key. */
|
|
13
|
+
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
|
+
/**
|
|
15
|
+
* Update an existing success, including one retained during refresh or failure, and supersede its load.
|
|
16
|
+
* No success or an undefined updater return skips the write. Use setQueryData to seed or store undefined.
|
|
17
|
+
* Updaters run synchronously on readonly data; a throw leaves the cached value unchanged.
|
|
18
|
+
*/
|
|
19
|
+
updateQueryData<Args, A, E>(definition: Query<Args, A, E, R>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, update: (previous: Snapshot<A>) => NoInfer<A> | Snapshot<NoInfer<A>> | undefined): Snapshot<A> | undefined;
|
|
20
|
+
invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
|
|
21
|
+
/** Observe account resets without exposing registry mutation. */
|
|
22
|
+
onReset(listener: () => void): () => void;
|
|
19
23
|
resetResources(): void;
|
|
20
24
|
dispose(): void;
|
|
21
|
-
}
|
|
22
|
-
export type QueryCache<R = never> = ReturnType<typeof createQueryCache<R>>;
|
|
25
|
+
}
|
package/dist/cache.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { protectSnapshot } from './snapshot.js';
|
|
1
2
|
import { Effect, Option } from 'effect';
|
|
2
3
|
import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
|
|
3
4
|
import * as Atom from 'effect/unstable/reactivity/Atom';
|
|
4
5
|
import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
|
|
5
6
|
import { loadEffect } from './load.js';
|
|
6
|
-
import {
|
|
7
|
+
import { shareData } from './sharing.js';
|
|
8
|
+
import { encodeQueryKey } from './query.js';
|
|
9
|
+
import { registerCache } from './cache-internals.js';
|
|
10
|
+
import { queryDefinition } from './query-internals.js';
|
|
7
11
|
export { loadEffect } from './load.js';
|
|
8
12
|
export { shareValue } from './share.js';
|
|
9
13
|
export function makeQueryCache(runtime) {
|
|
@@ -13,19 +17,44 @@ function createQueryCache(runtime) {
|
|
|
13
17
|
const registry = AtomRegistry.make({ defaultIdleTTL: 30_000 });
|
|
14
18
|
const generation = Atom.keepAlive(Atom.make(0));
|
|
15
19
|
const resources = new Map();
|
|
16
|
-
|
|
20
|
+
// Follow registry-node lifetime; retaining definitions must not retain evicted data.
|
|
21
|
+
const values = new WeakMap();
|
|
22
|
+
const identities = new WeakMap();
|
|
23
|
+
let disposed = false;
|
|
24
|
+
let nextId = 0;
|
|
25
|
+
const identity = (definition) => {
|
|
26
|
+
let id = identities.get(definition);
|
|
27
|
+
if (id === undefined) {
|
|
28
|
+
id = ++nextId;
|
|
29
|
+
identities.set(definition, id);
|
|
30
|
+
}
|
|
31
|
+
return id;
|
|
32
|
+
};
|
|
33
|
+
const acquire = (key, load, share = (previous, next) => shareData(previous, next)) => {
|
|
17
34
|
let entry = resources.get(key);
|
|
18
35
|
if (entry)
|
|
19
36
|
entry.load = load;
|
|
20
37
|
else {
|
|
38
|
+
const loaded = Atom.make((get) => {
|
|
39
|
+
const previous = Option.flatMap(get.self(), AsyncResult.value);
|
|
40
|
+
return loadEffect(() => next.load()).pipe(Effect.map((value) => {
|
|
41
|
+
const shared = Option.isSome(previous)
|
|
42
|
+
? share(previous.value, value)
|
|
43
|
+
: value;
|
|
44
|
+
const snapshot = protectSnapshot(shared);
|
|
45
|
+
remember(next, snapshot);
|
|
46
|
+
return snapshot;
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
21
49
|
const next = {
|
|
22
50
|
load,
|
|
23
|
-
atom: Atom.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
atom: Atom.writable(loaded.read, (context, value) => {
|
|
52
|
+
Atom.batch(() => {
|
|
53
|
+
// Refresh disposes the load lifetime; setSelf publishes without starting another load.
|
|
54
|
+
context.refreshSelf();
|
|
55
|
+
remember(next, value);
|
|
56
|
+
context.setSelf(AsyncResult.success(value));
|
|
57
|
+
});
|
|
29
58
|
}),
|
|
30
59
|
};
|
|
31
60
|
entry = next;
|
|
@@ -42,32 +71,71 @@ function createQueryCache(runtime) {
|
|
|
42
71
|
}
|
|
43
72
|
return { entry, atom: entry.atom };
|
|
44
73
|
};
|
|
45
|
-
const
|
|
46
|
-
|
|
74
|
+
const remember = (entry, value) => {
|
|
75
|
+
entry.loadedAt = Date.now();
|
|
76
|
+
const node = registry.getNodes().get(entry.atom);
|
|
77
|
+
if (node)
|
|
78
|
+
values.set(node, { value });
|
|
79
|
+
};
|
|
80
|
+
const previousValue = (entry) => {
|
|
81
|
+
const node = entry && registry.getNodes().get(entry.atom);
|
|
82
|
+
return node ? values.get(node) : undefined;
|
|
83
|
+
};
|
|
84
|
+
const checkWritable = () => {
|
|
85
|
+
if (disposed)
|
|
86
|
+
throw new Error('Cannot write to a disposed query cache.');
|
|
87
|
+
};
|
|
88
|
+
const queryKey = (definition, args) => `query:${identity(definition)}:${encodeQueryKey(args)}`;
|
|
89
|
+
const acquireQuery = (definition, args) => {
|
|
90
|
+
const config = queryDefinition(definition);
|
|
91
|
+
protectSnapshot(args);
|
|
47
92
|
const { entry, atom } = acquire(queryKey(definition, args), () => {
|
|
48
|
-
const effect = Effect.suspend(() =>
|
|
93
|
+
const effect = Effect.suspend(() => config.load(args));
|
|
49
94
|
return runtime ? runtime.provide(effect) : effect;
|
|
50
|
-
},
|
|
51
|
-
entry.
|
|
95
|
+
}, config.share);
|
|
96
|
+
entry.query = definition;
|
|
97
|
+
return { entry, atom, config };
|
|
98
|
+
};
|
|
99
|
+
const selectQuery = (definition, args) => {
|
|
100
|
+
const { entry, atom, config } = acquireQuery(definition, args);
|
|
52
101
|
if (registry.getNodes().has(atom) &&
|
|
53
102
|
entry.loadedAt !== undefined &&
|
|
54
|
-
Date.now() - entry.loadedAt >=
|
|
103
|
+
Date.now() - entry.loadedAt >= config.staleTime) {
|
|
55
104
|
const current = registry.get(atom);
|
|
56
105
|
if (!current.waiting)
|
|
57
106
|
registry.refresh(atom);
|
|
58
107
|
}
|
|
59
108
|
return atom;
|
|
60
109
|
};
|
|
61
|
-
|
|
62
|
-
registry,
|
|
63
|
-
generation,
|
|
64
|
-
resource(key, load) {
|
|
65
|
-
return acquire(key, load).atom;
|
|
66
|
-
},
|
|
67
|
-
query: selectQuery,
|
|
110
|
+
const cache = {
|
|
68
111
|
prefetch(definition, args) {
|
|
69
112
|
return Effect.suspend(() => AtomRegistry.getResult(registry, selectQuery(definition, args), { suspendOnWaiting: true }));
|
|
70
113
|
},
|
|
114
|
+
setQueryData(definition, args, value) {
|
|
115
|
+
checkWritable();
|
|
116
|
+
const { entry, config } = acquireQuery(definition, args);
|
|
117
|
+
const previous = previousValue(entry);
|
|
118
|
+
const next = protectSnapshot(value);
|
|
119
|
+
const shared = previous
|
|
120
|
+
? config.share
|
|
121
|
+
? config.share(previous.value, next)
|
|
122
|
+
: shareData(previous.value, next)
|
|
123
|
+
: next;
|
|
124
|
+
const snapshot = protectSnapshot(shared);
|
|
125
|
+
checkWritable();
|
|
126
|
+
registry.set(entry.atom, snapshot);
|
|
127
|
+
return snapshot;
|
|
128
|
+
},
|
|
129
|
+
updateQueryData(definition, args, update) {
|
|
130
|
+
checkWritable();
|
|
131
|
+
queryDefinition(definition);
|
|
132
|
+
protectSnapshot(args);
|
|
133
|
+
const previous = previousValue(resources.get(queryKey(definition, args)));
|
|
134
|
+
if (!previous)
|
|
135
|
+
return undefined;
|
|
136
|
+
const next = update(previous.value);
|
|
137
|
+
return next === undefined ? undefined : cache.setQueryData(definition, args, next);
|
|
138
|
+
},
|
|
71
139
|
invalidateQuery(definition, ...selected) {
|
|
72
140
|
if (selected.length) {
|
|
73
141
|
const entry = resources.get(queryKey(definition, selected[0]));
|
|
@@ -76,14 +144,13 @@ function createQueryCache(runtime) {
|
|
|
76
144
|
}
|
|
77
145
|
else {
|
|
78
146
|
for (const entry of resources.values())
|
|
79
|
-
if (entry.
|
|
147
|
+
if (entry.query === definition)
|
|
80
148
|
registry.refresh(entry.atom);
|
|
81
149
|
}
|
|
82
150
|
},
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
registry.refresh(entry.atom);
|
|
151
|
+
onReset(listener) {
|
|
152
|
+
registry.get(generation);
|
|
153
|
+
return registry.subscribe(generation, listener);
|
|
87
154
|
},
|
|
88
155
|
resetResources() {
|
|
89
156
|
Atom.batch(() => {
|
|
@@ -96,8 +163,13 @@ function createQueryCache(runtime) {
|
|
|
96
163
|
});
|
|
97
164
|
},
|
|
98
165
|
dispose() {
|
|
166
|
+
if (disposed)
|
|
167
|
+
return;
|
|
168
|
+
disposed = true;
|
|
99
169
|
registry.dispose();
|
|
100
170
|
resources.clear();
|
|
101
171
|
},
|
|
102
172
|
};
|
|
173
|
+
registerCache(cache, { registry, generation, query: selectQuery });
|
|
174
|
+
return Object.freeze(cache);
|
|
103
175
|
}
|
package/dist/collection.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Snapshot } from './snapshot.js';
|
|
1
2
|
export type Identity = string | number;
|
|
2
3
|
export interface Rows<A> {
|
|
3
4
|
readonly items: readonly A[];
|
|
@@ -7,16 +8,16 @@ export interface Rows<A> {
|
|
|
7
8
|
filter(predicate: (item: A, index: number) => boolean): Rows<A>;
|
|
8
9
|
slice(start?: number, end?: number): Rows<A>;
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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) => Identity): Identity[];
|
|
13
|
+
export interface Collection<A> {
|
|
14
|
+
from(this: void, items: readonly (A | Snapshot<A>)[]): Rows<Snapshot<A>>;
|
|
15
|
+
share<B extends A | Snapshot<A>>(this: void, previous: readonly B[], next: readonly B[]): readonly Snapshot<B>[];
|
|
16
|
+
}
|
|
17
|
+
export declare function collection<A>(identity: (item: Snapshot<A>, index: number) => Identity): Collection<A>;
|
|
17
18
|
/** Use positional identity for ordered values without stable entity IDs. */
|
|
18
|
-
export declare function sequence<A>(items: readonly A[]): Rows<A
|
|
19
|
+
export declare function sequence<A>(items: readonly A[]): Rows<Snapshot<A>>;
|
|
19
20
|
/** Rows keyed by their domain IDs. Reuses the wrapper for the same immutable array. */
|
|
20
21
|
export declare function entities<A extends {
|
|
21
22
|
readonly id: Identity;
|
|
22
|
-
}>(items: readonly A[] | undefined): Rows<A
|
|
23
|
+
}>(items: readonly A[] | undefined): Rows<Snapshot<A>>;
|
package/dist/collection.js
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { shareData } from './sharing.js';
|
|
2
|
+
/** Validate without retaining values. Positions are zero-based array indices. */
|
|
3
|
+
export function validateIdentities(items, identity) {
|
|
4
|
+
const seen = new Map();
|
|
5
|
+
return items.map((item, index) => {
|
|
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
|
+
const previous = seen.get(key);
|
|
10
|
+
if (previous !== undefined)
|
|
11
|
+
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.`);
|
|
12
|
+
seen.set(key, index);
|
|
13
|
+
return key;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function makeCollection(identity) {
|
|
3
17
|
const cache = new WeakMap();
|
|
18
|
+
const validated = new WeakSet();
|
|
19
|
+
const validate = (items) => {
|
|
20
|
+
if (validated.has(items))
|
|
21
|
+
return;
|
|
22
|
+
validateIdentities(items, identity);
|
|
23
|
+
validated.add(items);
|
|
24
|
+
};
|
|
4
25
|
const comparisons = new WeakMap();
|
|
5
26
|
const from = (items) => {
|
|
27
|
+
validate(items);
|
|
6
28
|
const cached = cache.get(items);
|
|
7
29
|
if (cached)
|
|
8
30
|
return cached;
|
|
@@ -18,6 +40,8 @@ export function collection(identity) {
|
|
|
18
40
|
return rows;
|
|
19
41
|
};
|
|
20
42
|
function share(previous, next) {
|
|
43
|
+
validate(previous);
|
|
44
|
+
validate(next);
|
|
21
45
|
if (previous === next)
|
|
22
46
|
return previous;
|
|
23
47
|
let pairs = comparisons.get(previous);
|
|
@@ -36,13 +60,10 @@ export function collection(identity) {
|
|
|
36
60
|
if (index >= previous.length || !Object.is(identity(old, index), key)) {
|
|
37
61
|
if (!byIdentity) {
|
|
38
62
|
byIdentity = new Map(previous.map((value, i) => [identity(value, i), value]));
|
|
39
|
-
const keys = next.map(identity);
|
|
40
|
-
if (byIdentity.size !== previous.length || new Set(keys).size !== keys.length)
|
|
41
|
-
throw new Error('Duplicate collection identity. Identity must be unique within the collection.');
|
|
42
63
|
}
|
|
43
64
|
old = byIdentity.get(key);
|
|
44
65
|
}
|
|
45
|
-
const value = old === undefined ? item :
|
|
66
|
+
const value = old === undefined ? item : shareData(old, item);
|
|
46
67
|
if (!Object.is(value, previous[index]))
|
|
47
68
|
equal = false;
|
|
48
69
|
if (!Object.is(value, item) && !result)
|
|
@@ -60,6 +81,11 @@ export function collection(identity) {
|
|
|
60
81
|
}
|
|
61
82
|
return { from, share };
|
|
62
83
|
}
|
|
84
|
+
export function collection(identity) {
|
|
85
|
+
// Snapshot changes access permissions, not runtime representation. The implementation
|
|
86
|
+
// only borrows supplied items, and never inserts values of a wider type.
|
|
87
|
+
return makeCollection(identity);
|
|
88
|
+
}
|
|
63
89
|
const positions = collection((_item, index) => index);
|
|
64
90
|
/** Use positional identity for ordered values without stable entity IDs. */
|
|
65
91
|
export function sequence(items) {
|
package/dist/component.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import type { Snapshot } from './snapshot.js';
|
|
1
2
|
import type { View } from './dom.js';
|
|
2
3
|
import type { Transition } from './program.js';
|
|
3
4
|
import { type UiRuntime } from './runtime.js';
|
|
4
5
|
/** Local fields with no command lifecycle. Sends are shallow patches, never updater callbacks. */
|
|
5
6
|
export declare function localComponent<Props, State extends object>(definition: {
|
|
6
|
-
init: (props: Props) => State & {
|
|
7
|
+
init: (props: Snapshot<Props>) => (State | Snapshot<State>) & {
|
|
7
8
|
readonly props?: never;
|
|
8
9
|
};
|
|
9
10
|
view: View<State & {
|
|
10
11
|
readonly props: Props;
|
|
11
|
-
}, Partial<State> & {
|
|
12
|
+
}, (Partial<State> | Partial<Snapshot<State>>) & {
|
|
12
13
|
readonly props?: never;
|
|
13
14
|
}>;
|
|
14
15
|
}): View<Props, never>;
|
|
@@ -16,9 +17,9 @@ export declare function localComponent<Props, State extends object>(definition:
|
|
|
16
17
|
export declare function component<Props, Model extends {
|
|
17
18
|
readonly props: Props;
|
|
18
19
|
}, Message, R = never>(definition: {
|
|
19
|
-
init: (props: Props) => Model
|
|
20
|
-
receive?: (model: Model
|
|
21
|
-
update: (model: Model
|
|
20
|
+
init: (props: Snapshot<Props>) => Model | Snapshot<Model>;
|
|
21
|
+
receive?: (model: Snapshot<Model>, props: Snapshot<Props>) => Transition<Model, Message, R>;
|
|
22
|
+
update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
|
|
22
23
|
view: View<Model, Message>;
|
|
23
24
|
} & ([R] extends [never] ? {
|
|
24
25
|
runtime?: UiRuntime<R>;
|
|
@@ -27,7 +28,7 @@ export declare function component<Props, Model extends {
|
|
|
27
28
|
})): View<Props, never>;
|
|
28
29
|
/** Mount an existing program without introducing a second state owner. */
|
|
29
30
|
export declare function programView<Props, Model, Message>(definition: {
|
|
30
|
-
create: (props: Props) => import('./program').Program<Model, Message>;
|
|
31
|
-
receive: (source: import('./program').Program<Model, Message>, props: Props) => void;
|
|
31
|
+
create: (props: Snapshot<Props>) => import('./program').Program<Model, Message>;
|
|
32
|
+
receive: (source: import('./program').Program<Model, Message>, props: Snapshot<Props>) => void;
|
|
32
33
|
view: View<Model, Message>;
|
|
33
34
|
}): View<Props, never>;
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface BindingUpdate {
|
|
|
10
10
|
readonly source: BindingSource;
|
|
11
11
|
readonly changed: readonly string[];
|
|
12
12
|
readonly kind: 'derive' | 'binding';
|
|
13
|
+
readonly reason: 'initial' | 'dependencies';
|
|
13
14
|
}
|
|
14
15
|
/** Each registration has its own lifetime; observers can be stopped in any order. */
|
|
15
16
|
export declare function observeBindings(next: (update: BindingUpdate) => void): () => void;
|
|
@@ -30,3 +31,27 @@ export declare function observePrograms(limit?: number): {
|
|
|
30
31
|
events: () => ProgramUpdate[];
|
|
31
32
|
dispose: () => boolean;
|
|
32
33
|
};
|
|
34
|
+
/** Counts aggregate all mounted instances of a source expression. */
|
|
35
|
+
export interface BindingInspection {
|
|
36
|
+
readonly source: BindingSource;
|
|
37
|
+
readonly derives: number;
|
|
38
|
+
readonly bindings: number;
|
|
39
|
+
readonly changed: readonly string[];
|
|
40
|
+
readonly reason: BindingUpdate['reason'];
|
|
41
|
+
}
|
|
42
|
+
export interface BindingInspector {
|
|
43
|
+
/** Most recently updated first; bounded by limit (default 200, maximum 1000). */
|
|
44
|
+
entries(): readonly BindingInspection[];
|
|
45
|
+
subscribe(next: () => void): () => void;
|
|
46
|
+
clear(): void;
|
|
47
|
+
/** Unsubscribe and release all metadata and listeners. */
|
|
48
|
+
dispose(): void;
|
|
49
|
+
}
|
|
50
|
+
/** Development metadata only: never reads or retains models, values, or DOM nodes. */
|
|
51
|
+
export declare function inspectBindings(options?: {
|
|
52
|
+
readonly limit?: number;
|
|
53
|
+
}): BindingInspector;
|
|
54
|
+
/** Mount a live source inspector. Mount before the application to include initial evaluations.
|
|
55
|
+
* The returned cleanup removes the panel; an externally supplied inspector keeps its lifetime.
|
|
56
|
+
*/
|
|
57
|
+
export declare function mountBindingInspector(target: HTMLElement, inspector?: BindingInspector): () => void;
|