effectweb 0.4.0 → 0.5.1

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.
@@ -1,3 +1,4 @@
1
+ import type * as Scope from 'effect/Scope';
1
2
  import type * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
2
3
  import type * as Atom from 'effect/unstable/reactivity/Atom';
3
4
  import type * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
@@ -7,11 +8,11 @@ import type { Snapshot } from './snapshot.js';
7
8
  export interface CacheInternals<R> {
8
9
  refresh(atom: Atom.Atom<unknown>): void;
9
10
  readonly disposed: () => boolean;
10
- revision<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): number | undefined;
11
+ revision<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: Args | Snapshot<Args>): number | undefined;
11
12
  retain(atom: Atom.Atom<unknown>): () => void;
12
13
  readonly registry: AtomRegistry.AtomRegistry;
13
14
  readonly generation: Atom.Writable<number>;
14
- query<Args, A, E>(definition: Query<Args, A, E, R>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
15
+ query<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: Args | Snapshot<Args>): Atom.Atom<AsyncResult.AsyncResult<Snapshot<A>, E>>;
15
16
  }
16
17
  export declare function registerCache<R>(cache: QueryCache<R>, internals: CacheInternals<R>): void;
17
18
  /** Private implementation access; deliberately absent from package exports. */
package/dist/cache.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
2
  import * as Effect from 'effect/Effect';
3
+ import * as Scope from 'effect/Scope';
3
4
  import { type Query, type QueryGroup } from './query.js';
4
- import type { UiRuntime } from './runtime.js';
5
+ import { type UiRuntime } from './runtime.js';
5
6
  export { loadEffect, type UiLoad } from './load.js';
6
7
  export { shareValue } from './share.js';
7
8
  export interface QueryCacheOptions {
@@ -10,32 +11,34 @@ export interface QueryCacheOptions {
10
11
  }
11
12
  export declare function makeQueryCache(options?: QueryCacheOptions): QueryCache<never>;
12
13
  export declare function makeQueryCache<R>(runtime: UiRuntime<R>, options?: QueryCacheOptions): QueryCache<R>;
14
+ /** Capture application services and clock, and close the cache with the current Effect scope. */
15
+ export declare const scopedQueryCache: <R = never>(options?: QueryCacheOptions) => Effect.Effect<QueryCache<R>, never, R | Scope.Scope>;
13
16
  /** A cache owns one registry and the query resources published through it. */
14
17
  export interface QueryCache<R = never> {
15
18
  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;
19
+ getQueryData<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>): Snapshot<A> | undefined;
20
+ invalidateWhere<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, predicate: (args: Snapshot<Args>) => boolean): void;
18
21
  invalidateGroup(group: QueryGroup): void;
19
22
  /** 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;
23
+ cancelQuery<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
21
24
  /** 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?: {
25
+ removeQuery<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
26
+ prefetch<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, options?: {
24
27
  readonly refresh?: boolean;
25
28
  }): Effect.Effect<Snapshot<A>, E>;
26
29
  /** Publish a protected success, including undefined, and supersede any pending load for this key. */
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>;
30
+ setQueryData<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, value: NoInfer<A> | Snapshot<NoInfer<A>>): Snapshot<A>;
28
31
  /**
29
32
  * Update an existing success, including one retained during refresh or failure, and supersede its load.
30
33
  * No success or an undefined updater return skips the write. Use setQueryData to seed or store undefined.
31
34
  * Updaters run synchronously on readonly data; a throw leaves the cached value unchanged.
32
35
  */
33
- 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;
34
- invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
36
+ updateQueryData<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, args: NoInfer<Args> | Snapshot<NoInfer<Args>>, update: (previous: Snapshot<A>) => NoInfer<A> | Snapshot<NoInfer<A>> | undefined): Snapshot<A> | undefined;
37
+ invalidateQuery<Args, A, E>(definition: Query<Args, A, E, R | Scope.Scope>, ...selected: [] | [NoInfer<Args> | Snapshot<NoInfer<Args>>]): void;
35
38
  /** Observe account resets without exposing registry mutation. */
36
39
  onReset(listener: () => void): () => void;
37
40
  resetResources(): void;
38
- /** Interrupt every request and wait for its finalizers, including previously canceled requests. */
41
+ /** Release retained acquisitions and join all load/resource finalizers, including canceled or evicted entries. */
39
42
  close(): Effect.Effect<void>;
40
43
  dispose(): void;
41
44
  }
package/dist/cache.js CHANGED
@@ -2,15 +2,19 @@ import { Settlement } from './settlement.js';
2
2
  import { protectSnapshot } from './snapshot.js';
3
3
  import * as Deferred from 'effect/Deferred';
4
4
  import * as Effect from 'effect/Effect';
5
+ import * as Scope from 'effect/Scope';
6
+ import * as Exit from 'effect/Exit';
5
7
  import * as Option from 'effect/Option';
6
8
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
7
9
  import * as Atom from 'effect/unstable/reactivity/Atom';
8
10
  import * as AtomRegistry from 'effect/unstable/reactivity/AtomRegistry';
9
11
  import { loadEffect } from './load.js';
10
12
  import { shareData } from './sharing.js';
11
- import { encodeQueryKey } from './query.js';
13
+ import { encodeQueryArguments } from './query.js';
12
14
  import { registerCache } from './cache-internals.js';
13
15
  import { queryDefinition } from './query-internals.js';
16
+ import { defaultUiRuntime, makeUiRuntime } from './runtime.js';
17
+ import { reportError, reportSafely } from './errors.js';
14
18
  export { loadEffect } from './load.js';
15
19
  export { shareValue } from './share.js';
16
20
  export function makeQueryCache(runtimeOrOptions, options = {}) {
@@ -23,6 +27,7 @@ function createQueryCache(runtime, options = {}) {
23
27
  if (!Number.isFinite(retention) || retention < 0)
24
28
  throw new RangeError('Query retention must be finite and nonnegative.');
25
29
  const registry = AtomRegistry.make({ defaultIdleTTL: retention });
30
+ const clock = (runtime ?? defaultUiRuntime).clock;
26
31
  const cancelValue = Symbol('cancel');
27
32
  const removeValue = Symbol('remove');
28
33
  const generation = Atom.keepAlive(Atom.make(0));
@@ -54,8 +59,28 @@ function createQueryCache(runtime, options = {}) {
54
59
  next.revision = ++nextRevision;
55
60
  next.canceled = false;
56
61
  const revision = next.revision;
62
+ const scope = Scope.makeUnsafe();
63
+ const completed = Deferred.makeUnsafe();
64
+ const finish = settlement.begin();
65
+ let started = false;
66
+ let stopping = false;
67
+ get.addFinalizer(() => {
68
+ stopping = true;
69
+ // The atom interrupts its load first. Join acquisition and its ensuring
70
+ // finalizers before closing resources, including late acquisitions.
71
+ Effect.runFork(Effect.uninterruptible(Effect.gen(function* () {
72
+ if (started)
73
+ yield* Deferred.await(completed);
74
+ yield* Scope.close(scope, Exit.void);
75
+ })).pipe(Effect.ensuring(Effect.sync(finish)))).addObserver((exit) => {
76
+ if (Exit.isFailure(exit))
77
+ reportSafely(reportError, exit.cause);
78
+ });
79
+ });
57
80
  return Effect.suspend(() => {
58
- const finish = settlement.begin();
81
+ if (stopping)
82
+ return Effect.interrupt;
83
+ started = true;
59
84
  return loadEffect(() => next.load()).pipe(Effect.map((value) => {
60
85
  const shared = Option.isSome(previous)
61
86
  ? share(previous.value, value)
@@ -64,7 +89,12 @@ function createQueryCache(runtime, options = {}) {
64
89
  if (!disposed && revision === next.revision)
65
90
  remember(next, snapshot);
66
91
  return snapshot;
67
- }), Effect.ensuring(Effect.sync(finish)));
92
+ }), Scope.provide(scope), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void).pipe(Effect.ensuring(Effect.sync(() => {
93
+ Deferred.doneUnsafe(completed, Effect.void);
94
+ // Successful loads retain their resources with the atom entry.
95
+ if (Exit.isFailure(exit))
96
+ finish();
97
+ })))));
68
98
  });
69
99
  });
70
100
  const next = {
@@ -112,7 +142,7 @@ function createQueryCache(runtime, options = {}) {
112
142
  return { entry, atom: entry.atom };
113
143
  };
114
144
  const remember = (entry, value) => {
115
- entry.loadedAt = Date.now();
145
+ entry.loadedAt = clock.currentTimeMillisUnsafe();
116
146
  const node = registry.getNodes().get(entry.atom);
117
147
  if (node)
118
148
  values.set(node, { value });
@@ -125,13 +155,15 @@ function createQueryCache(runtime, options = {}) {
125
155
  if (disposed)
126
156
  throw new Error('Cannot write to a disposed query cache.');
127
157
  };
128
- const queryKey = (definition, args) => `query:${identity(definition)}:${encodeQueryKey(args)}`;
158
+ const queryKey = (definition, args) => `query:${identity(definition)}:${encodeQueryArguments(definition, args)}`;
129
159
  const acquireQuery = (definition, args) => {
130
160
  const config = queryDefinition(definition);
131
161
  protectSnapshot(args);
132
162
  const { entry, atom } = acquire(queryKey(definition, args), () => {
133
163
  const effect = Effect.suspend(() => config.load(args, previousValue(resources.get(queryKey(definition, args)))?.value));
134
- return runtime ? runtime.provide(effect) : effect;
164
+ return runtime
165
+ ? runtime.provideScoped(effect)
166
+ : effect;
135
167
  }, config.share);
136
168
  entry.query = definition;
137
169
  entry.args = args;
@@ -149,7 +181,7 @@ function createQueryCache(runtime, options = {}) {
149
181
  refresh(entry);
150
182
  if (registry.getNodes().has(atom) &&
151
183
  entry.loadedAt !== undefined &&
152
- Date.now() - entry.loadedAt >= config.staleTime) {
184
+ clock.currentTimeMillisUnsafe() - entry.loadedAt >= config.staleTime) {
153
185
  const current = registry.get(atom);
154
186
  if (!current.waiting)
155
187
  refresh(entry);
@@ -326,3 +358,8 @@ function createQueryCache(runtime, options = {}) {
326
358
  });
327
359
  return Object.freeze(cache);
328
360
  }
361
+ /** Capture application services and clock, and close the cache with the current Effect scope. */
362
+ export const scopedQueryCache = (options = {}) => Effect.gen(function* () {
363
+ const runtime = yield* makeUiRuntime();
364
+ return yield* Effect.acquireRelease(Effect.sync(() => makeQueryCache(runtime, options)), (cache) => cache.close());
365
+ });
@@ -31,7 +31,7 @@ export declare function component<Props, Model extends {
31
31
  /** Mount an existing program without introducing a second state owner. */
32
32
  export declare function programView<Props, Model, Message>(definition: {
33
33
  identity?: (props: Snapshot<Props>) => unknown;
34
- create: (props: Snapshot<Props>) => import('./program').Program<Model, Message>;
34
+ create: (props: Snapshot<Props>, runtime: UiRuntime<never>) => import('./program').Program<Model, Message>;
35
35
  receive: (source: import('./program').Program<Model, Message>, props: Snapshot<Props>) => void;
36
36
  view: View<Model, Message>;
37
37
  }): View<Props, never>;
package/dist/component.js CHANGED
@@ -19,8 +19,9 @@ export function localComponent(definition) {
19
19
  }
20
20
  /** A child model receives immutable parent inputs and owns its own message/command loop. */
21
21
  export function component(definition) {
22
- const runtime = definition.runtime ?? defaultUiRuntime;
23
22
  return identify(compiled((scope, parent, before) => {
23
+ const ownerRuntime = scope.settlement.runtime ?? defaultUiRuntime;
24
+ const runtime = definition.runtime ?? ownerRuntime;
24
25
  const wrap = (next) => ({
25
26
  model: next.model,
26
27
  ...(next.cancel ? { cancel: next.cancel } : {}),
@@ -33,6 +34,7 @@ export function component(definition) {
33
34
  const source = program({
34
35
  initial: definition.init(scope.value),
35
36
  onDefect: scope.report,
37
+ runtime: ownerRuntime,
36
38
  update: (model, envelope) => wrap(envelope.type === 'Input'
37
39
  ? (definition.receive?.(model, envelope.props) ?? {
38
40
  model: Object.is(model.props, envelope.props)
@@ -62,7 +64,7 @@ export function component(definition) {
62
64
  /** Mount an existing program without introducing a second state owner. */
63
65
  export function programView(definition) {
64
66
  return identify(compiled((scope, parent, before) => {
65
- const source = definition.create(scope.value);
67
+ const source = definition.create(scope.value, scope.settlement.runtime ?? defaultUiRuntime);
66
68
  if (scope.disposed) {
67
69
  closeProgram(scope, source);
68
70
  return;
package/dist/dom.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import * as Effect from 'effect/Effect';
2
- import type { Snapshot } from './snapshot.js';
2
+ import { type Snapshot } from './snapshot.js';
3
3
  import { type ReportError } from './errors.js';
4
4
  import { type BindingSource } from './diagnostics.js';
5
5
  import type { Rows } from './index.js';
6
6
  import { jsxComponent, type JSX } from './jsx.js';
7
7
  import type { DomMount } from './mount.js';
8
8
  import { Settlement } from './settlement.js';
9
- import type { Program, Send } from './program.js';
9
+ import type { Send } from './program.js';
10
+ import type { Source } from './source.js';
11
+ import type { UiRuntime } from './runtime.js';
10
12
  type Dependencies = () => readonly unknown[];
11
13
  type BindingLocation = BindingSource | (() => BindingSource | undefined);
12
14
  type Cleanup = () => void;
@@ -38,7 +40,7 @@ export declare const ViewBinding: (<M, E>(this: never, props: {
38
40
  [jsxComponent]: true;
39
41
  };
40
42
  /** Untyped callers get an actionable failure instead of silently losing child messages. */
41
- export declare function unboundSend(_message: never): never;
43
+ export declare function unboundSend(_message: unknown): never;
42
44
  declare const contentBrand: unique symbol;
43
45
  /** Owned markup accepted wherever JSX can render content. */
44
46
  export interface CompiledContent {
@@ -52,6 +54,8 @@ export declare function slot<A = void>(render: (value: Snapshot<A>) => JSX.Eleme
52
54
  /** Evaluate ordinary synchronous JavaScript for each immutable model publication. */
53
55
  export declare function view<M, E = never>(render: (model: Snapshot<M>, send: Send<E>) => JSX.Element): View<M, E>;
54
56
  export declare function compiled<M, E>(build: Build<M, E>): View<M, E>;
57
+ /** Skip a view only under the caller's explicit equality contract. */
58
+ export declare function memoView<M, E>(definition: View<M, E>, equals: (previous: Snapshot<M>, next: Snapshot<M>) => boolean): View<M, E>;
55
59
  type MarkupProps = Readonly<Record<string, unknown>> & {
56
60
  readonly children?: JSX.Element;
57
61
  };
@@ -60,18 +64,44 @@ export declare function markup(tag: string, sources?: Readonly<Record<string, Bi
60
64
  export declare function renderComponent(component: (props: never) => JSX.Element, props: Readonly<Record<string, unknown>>): JSX.Element;
61
65
  /** Preserve collection identity (or array reference identity) while rendering rows. */
62
66
  export declare function list<A>(rows: Rows<A> | readonly A[], render: (item: A, index: number) => JSX.Element): JSX.Element;
67
+ /**
68
+ * Define keyed row projection once. Every dependency is passed as an explicit input;
69
+ * only changed projected props enter the row view's renderer.
70
+ */
71
+ export declare function listView<A, Input, Props, Message = never>(options: {
72
+ readonly project: (item: A, index: number, input: Input) => Props | Snapshot<Props>;
73
+ readonly view: View<Props, Message>;
74
+ readonly equals?: (previous: Snapshot<Props>, next: Snapshot<Props>) => boolean;
75
+ }): (rows: Rows<A> | readonly A[], input: Input, ...dispatch: [Message] extends [never] ? [send?: Send<Message>] : [send: Send<Message>]) => JSX.Element;
76
+ /** Render a declared source in its own subscribed region. No dependencies are inferred. */
77
+ export declare function observe<A>(source: Source<A>, render: (value: Snapshot<A>) => JSX.Element): JSX.Element;
63
78
  export interface PortalProps {
64
79
  readonly children?: JSX.Element;
65
80
  readonly mount?: Element | undefined;
66
81
  }
67
82
  /** Owned content in an element target, defaulting to the document body. */
68
83
  export declare const Portal: View<PortalProps, never>;
69
- export declare function mountView<M, E>(parent: Node, definition: View<M, E>, source: Program<M, E>, options?: {
70
- onError?: ReportError;
71
- }): (() => void) & {
72
- dispose: () => void;
73
- close: () => Effect.Effect<void, never, never>;
74
- };
84
+ export interface MountOptions {
85
+ readonly onError?: ReportError;
86
+ readonly runtime?: UiRuntime<never>;
87
+ }
88
+ export interface Mounted {
89
+ (): void;
90
+ readonly dispose: () => void;
91
+ readonly close: () => Effect.Effect<void>;
92
+ }
93
+ export declare function mountView<M, E>(parent: Node, definition: View<M, E>, source: Source<M> & {
94
+ readonly send: Send<E>;
95
+ }, options?: MountOptions): Mounted;
96
+ export declare function mountView<M>(parent: Node, definition: View<M, never>, source: Source<M>, options?: MountOptions): Mounted;
97
+ export declare function mountView<M, E>(parent: Node, definition: View<M, E>, source: Source<M>, options: MountOptions & {
98
+ readonly send: Send<E>;
99
+ }): Mounted;
100
+ /** Internal mounting boundary. Its owner retains completion accounting if construction fails. */
101
+ export declare function mountViewWithSettlement<M, E>(parent: Node, definition: View<M, E>, source: Source<M>, options: {
102
+ readonly send: Send<E>;
103
+ readonly onError?: ReportError;
104
+ }, settlement: Settlement): Mounted;
75
105
  export declare function element(parent: Node, before: Node | null, tag: string): Element;
76
106
  export declare function attribute(element: Element, name: string, value: unknown): void;
77
107
  /** Compiler output for attributes whose unchanged value needs no DOM refresh. */
package/dist/dom.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import attributeData from './dom-attributes.json' with { type: 'json' };
2
2
  import * as Effect from 'effect/Effect';
3
+ import * as Exit from 'effect/Exit';
4
+ import { protectSnapshot } from './snapshot.js';
3
5
  import { validateIdentities } from './collection.js';
4
6
  import { runAll, reportError, reportSafely } from './errors.js';
5
7
  import { eventEffects } from './effectEvent.js';
@@ -130,6 +132,18 @@ export function compiled(build) {
130
132
  const definition = Object.assign((props) => new SlotPlacement(viewContent(definition), { model: props, send: unboundSend }), { build, [jsxComponent]: true });
131
133
  return definition;
132
134
  }
135
+ /** Skip a view only under the caller's explicit equality contract. */
136
+ export function memoView(definition, equals) {
137
+ return compiled((scope, parent, before) => {
138
+ const child = new Scope(scope.value, scope.send, scope.report, scope.settlement);
139
+ scope.cleanups.push(() => child.dispose());
140
+ definition.build(child, parent, before);
141
+ scope.jobs.push(() => {
142
+ if (!equals(child.value, scope.value))
143
+ child.set(scope.value);
144
+ });
145
+ });
146
+ }
133
147
  const viewDefinitions = new WeakMap();
134
148
  function viewContent(definition) {
135
149
  let content = viewDefinitions.get(definition);
@@ -195,6 +209,63 @@ const listDefinition = contentDefinition((scope, parent, before) => {
195
209
  export function list(rows, render) {
196
210
  return new SlotPlacement(listDefinition, { rows, render });
197
211
  }
212
+ /**
213
+ * Define keyed row projection once. Every dependency is passed as an explicit input;
214
+ * only changed projected props enter the row view's renderer.
215
+ */
216
+ export function listView(options) {
217
+ const equals = options.equals ?? Object.is;
218
+ const definition = contentDefinition((scope, parent, before) => {
219
+ each(scope, parent, before, () => scope.value.rows, () => [scope.value.input, scope.value.send], true, (row, parent, before) => {
220
+ const model = () => protectSnapshot(options.project(row.value[0], row.value[1], scope.value.input));
221
+ const child = new Scope(model(), (message) => scope.value.send(message), scope.report, scope.settlement);
222
+ row.cleanups.push(() => child.dispose());
223
+ options.view.build(child, parent, before);
224
+ row.jobs.push(() => {
225
+ const next = model();
226
+ if (!equals(child.value, next))
227
+ child.set(next);
228
+ });
229
+ });
230
+ });
231
+ return (rows, input, ...dispatch) => new SlotPlacement(definition, { rows, input, send: dispatch[0] ?? unboundSend });
232
+ }
233
+ const observationDefinition = contentDefinition((scope, parent, before) => {
234
+ let unsubscribe = () => { };
235
+ let source = scope.value.source;
236
+ const child = new Scope(source.model(), unboundSend, scope.report, scope.settlement);
237
+ scope.cleanups.push(() => child.dispose(), () => unsubscribe());
238
+ text(child, parent, before, () => [child.value, scope.value.render], () => scope.value.render(child.value));
239
+ const connect = () => {
240
+ if (scope.disposed)
241
+ return;
242
+ const current = source;
243
+ const release = current.subscribe((value) => {
244
+ if (!scope.disposed && source === current)
245
+ child.set(value);
246
+ });
247
+ if (scope.disposed || source !== current)
248
+ release();
249
+ else {
250
+ unsubscribe = release;
251
+ child.set(current.model());
252
+ }
253
+ };
254
+ connect();
255
+ scope.jobs.push(() => {
256
+ if (source !== scope.value.source) {
257
+ unsubscribe();
258
+ source = scope.value.source;
259
+ connect();
260
+ }
261
+ else
262
+ child.set(source.model());
263
+ });
264
+ });
265
+ /** Render a declared source in its own subscribed region. No dependencies are inferred. */
266
+ export function observe(source, render) {
267
+ return new SlotPlacement(observationDefinition, { source, render });
268
+ }
198
269
  /** Owned content in an element target, defaulting to the document body. */
199
270
  export const Portal = /* @__PURE__ */ compiled((scope) => {
200
271
  portal(scope, (child, parent, before) => {
@@ -261,9 +332,14 @@ function buildFragment(parent) {
261
332
  return fragment;
262
333
  }
263
334
  export function mountView(parent, definition, source, options = {}) {
335
+ return mountViewWithSettlement(parent, definition, source, { ...options, send: options.send ?? source.send ?? unboundSend }, new Settlement(options.runtime));
336
+ }
337
+ /** Internal mounting boundary. Its owner retains completion accounting if construction fails. */
338
+ export function mountViewWithSettlement(parent, definition, source, options, settlement) {
264
339
  return commitDom(() => {
340
+ const initial = source.model();
265
341
  const { start, end } = markers(parent, null);
266
- const scope = new Scope(source.model(), source.send, options.onError);
342
+ const scope = new Scope(initial, options.send, options.onError, settlement);
267
343
  let unsubscribe = () => { };
268
344
  let ready = false;
269
345
  let latest = scope.value;
@@ -281,13 +357,22 @@ export function mountView(parent, definition, source, options = {}) {
281
357
  scope.set(latest);
282
358
  }
283
359
  catch (error) {
360
+ settlement.exit = Exit.die(error);
284
361
  runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
285
362
  throw error;
286
363
  }
364
+ let disposed = false;
287
365
  const dispose = () => {
288
- if (scope.disposed)
366
+ if (disposed)
289
367
  return;
290
- runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
368
+ disposed = true;
369
+ const finish = settlement.begin();
370
+ try {
371
+ runAll([unsubscribe, () => scope.dispose(), () => remove(start, end)], scope.report);
372
+ }
373
+ finally {
374
+ finish();
375
+ }
291
376
  };
292
377
  return Object.assign(dispose, {
293
378
  dispose,
@@ -357,7 +442,9 @@ export function attribute(element, name, value) {
357
442
  style.setProperty(property, value);
358
443
  styleProperties.set(element, next);
359
444
  }
360
- else if (name === 'value' && 'value' in element) {
445
+ else if (name === 'value' && 'value' in element && element.localName !== 'option') {
446
+ // An <option> gets its value as an attribute: before its text child exists its value property reads "", so a
447
+ // property write of "" would be skipped and the option would later fall back to its text.
361
448
  const next = scalar(value);
362
449
  if (element.value !== next)
363
450
  element.value = next;
@@ -995,7 +1082,7 @@ export function attach(scope, element, dependencies, read) {
995
1082
  let active;
996
1083
  scope.cleanups.push(() => {
997
1084
  generation++;
998
- active?.dispose();
1085
+ active?.dispose(scope.settlement.exit);
999
1086
  active = undefined;
1000
1087
  });
1001
1088
  scope.watch(dependencies, () => {
@@ -1011,7 +1098,7 @@ export function attach(scope, element, dependencies, read) {
1011
1098
  if (!scope.disposed && token === generation) {
1012
1099
  const finished = scope.settlement.begin();
1013
1100
  try {
1014
- const acquired = prepareMount(element, mount, scope.report);
1101
+ const acquired = prepareMount(element, mount, scope.report, scope.settlement.runtime);
1015
1102
  Effect.runFork(acquired.closed).addObserver(finished);
1016
1103
  if (scope.disposed || token !== generation)
1017
1104
  acquired.dispose();
@@ -1,19 +1,22 @@
1
1
  import * as Effect from 'effect/Effect';
2
+ import type * as Scope from 'effect/Scope';
2
3
  import { type ReportError } from './errors.js';
3
4
  import type { Settlement } from './settlement.js';
4
5
  import { type UiRuntime } from './runtime.js';
5
6
  declare const tag: unique symbol;
6
7
  export interface EffectEventRequest {
7
8
  readonly [tag]: true;
8
- readonly policy: 'drop' | 'replace';
9
- readonly effect: Effect.Effect<unknown, unknown>;
9
+ readonly policy: 'drop' | 'replace' | 'parallel';
10
+ readonly effect: Effect.Effect<unknown, unknown, Scope.Scope>;
10
11
  }
11
12
  /** Capture/prevent native events on every call; the policy gates only Effect execution. */
12
- export declare function effectEvent<EventType extends Event, E>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E>): (event: EventType) => EffectEventRequest;
13
- export declare function effectEvent<EventType extends Event, E, R>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, R>, runtime: UiRuntime<R>): (event: EventType) => EffectEventRequest;
13
+ export declare function effectEvent<EventType extends Event, E>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, Scope.Scope>): (event: EventType) => EffectEventRequest;
14
+ export declare function effectEvent<EventType extends Event, E, R>(policy: 'drop' | 'replace', load: (event: EventType) => Effect.Effect<unknown, E, R | Scope.Scope>, runtime: UiRuntime<R>): (event: EventType) => EffectEventRequest;
15
+ /** Capture an operation's services in Effect setup; its event listener supplies the work scope. */
16
+ export declare const makeEffectHandler: <Args extends readonly unknown[], A, E, R = never>(work: (...args: Args) => Effect.Effect<A, E, R | Scope.Scope>) => Effect.Effect<(...args: Args) => Effect.Effect<A, E, Scope.Scope>, never, R>;
14
17
  /** Internal listener owner, allocated only when an event returns an owned request. */
15
18
  export declare function eventEffects(report: ReportError, settlement?: Settlement): {
16
- accept(request: EffectEventRequest): void;
19
+ accept(value: EffectEventRequest | Effect.Effect<unknown, unknown, Scope.Scope>): void;
17
20
  dispose(): void;
18
21
  };
19
22
  export {};
@@ -2,51 +2,67 @@ import * as Cause from 'effect/Cause';
2
2
  import * as Effect from 'effect/Effect';
3
3
  import * as Fiber from 'effect/Fiber';
4
4
  import { reportSafely } from './errors.js';
5
- import { defaultUiRuntime } from './runtime.js';
5
+ import { uiRuntime } from './runtime.js';
6
6
  const tag = Symbol('Effect event');
7
7
  export function effectEvent(policy, load, runtime) {
8
- const owner = runtime ?? defaultUiRuntime;
9
- return (event) => ({ [tag]: true, policy, effect: owner.provide(load(event)) });
8
+ return (event) => ({
9
+ [tag]: true,
10
+ policy,
11
+ effect: runtime
12
+ ? runtime.provideScoped(load(event))
13
+ : load(event),
14
+ });
10
15
  }
16
+ /** Capture an operation's services in Effect setup; its event listener supplies the work scope. */
17
+ export const makeEffectHandler = (work) => Effect.map(Effect.context(), (context) => {
18
+ const runtime = uiRuntime(context);
19
+ return (...args) => runtime.provideScoped(Effect.suspend(() => work(...args)));
20
+ });
11
21
  /** Internal listener owner, allocated only when an event returns an owned request. */
12
22
  export function eventEffects(report, settlement) {
13
- let active;
23
+ const active = new Set();
14
24
  let disposed = false;
15
25
  const stop = () => {
16
- const previous = active;
17
- active = undefined;
18
- if (previous?.fiber)
19
- Effect.runFork(Fiber.interrupt(previous.fiber));
26
+ const previous = [...active];
27
+ active.clear();
28
+ for (const token of previous)
29
+ if (token.fiber)
30
+ Effect.runFork(Fiber.interrupt(token.fiber));
20
31
  };
21
32
  return {
22
- accept(request) {
33
+ accept(value) {
34
+ // Direct Effects admit every event. Other policies are explicit requests.
35
+ const request = Effect.isEffect(value)
36
+ ? { policy: 'parallel', effect: value }
37
+ : value;
23
38
  if (disposed)
24
39
  return;
25
- if (request.policy === 'drop' && active)
40
+ if (request.policy === 'drop' && active.size)
26
41
  return;
27
- stop();
42
+ if (request.policy === 'replace')
43
+ stop();
28
44
  const token = {};
29
- active = token;
45
+ active.add(token);
30
46
  const finished = settlement?.begin();
31
47
  try {
32
- const fiber = Effect.runFork(request.effect);
48
+ const fiber = settlement?.runtime
49
+ ? settlement.runtime.runFork(request.effect)
50
+ : Effect.runFork(Effect.scoped(request.effect));
33
51
  token.fiber = fiber;
34
52
  fiber.addObserver((exit) => {
35
- const current = !disposed && active === token;
36
- if (current)
37
- active = undefined;
53
+ const current = !disposed && active.has(token);
54
+ active.delete(token);
38
55
  if (exit._tag === 'Failure' && (current || !Cause.hasInterruptsOnly(exit.cause)))
39
56
  reportSafely(report, exit.cause);
40
57
  finished?.();
41
58
  });
42
- if (disposed || active !== token) {
59
+ if (disposed || !active.has(token)) {
43
60
  Effect.runFork(Fiber.interrupt(fiber));
44
61
  }
45
62
  }
46
63
  catch (error) {
47
64
  finished?.();
48
- if (active === token)
49
- active = undefined;
65
+ active.delete(token);
50
66
  reportSafely(report, error);
51
67
  }
52
68
  },
package/dist/index.d.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  export type { Snapshot, SnapshotOpaque, snapshotOpaque } from './snapshot.js';
2
- export { modelOwner, type ModelOwner, type DisposableOwner, type TaskPolicy } from './owner.js';
2
+ export { modelOwner, makeModelOwner, type ModelOwner, type DisposableOwner, type TaskPolicy, } from './owner.js';
3
3
  export { collection, entities, sequence, type Rows, type Collection } from './collection.js';
4
4
  export { component, localComponent, programView } from './component.js';
5
5
  export { patchModel } from './state.js';
6
6
  export { defineActions, type ActionMessage } from './actions.js';
7
7
  export { taskComponent, taskControls, type TaskModel, type TaskMessage } from './task.js';
8
- export { effectEvent } from './effectEvent.js';
9
- export { mountView, view, list, ViewBinding, type View } from './dom.js';
8
+ export { effectEvent, makeEffectHandler } from './effectEvent.js';
9
+ export { mountView, view, list, listView, memoView, observe, ViewBinding, type View, type Mounted, } from './dom.js';
10
+ export { mount } from './render.js';
11
+ export { mapSource, fromStream, fromSubscriptionRef, fromAtom, projectionSource, type Source, } from './source.js';
10
12
  export { lazyView, type LazyViewOptions } from './lazy.js';
11
13
  export type { JSX } from './jsx.js';
12
- export { Portal, domBinding, domMount, type DomMount, type PortalProps } from './mount.js';
14
+ export { Portal, domBinding, domMount, makeDomMount, makeDomBinding, type DomMount, type PortalProps, } from './mount.js';
13
15
  export { pages, type PagesMessage, type PagesRequest, type PagesModel, type PagesProgram, type Pagination, } from './pages.js';
14
- export { program, mapTransition, actionCommand, effectCommand, commandSlot, type CommandSlot, type Command, type Program, type Send, type Transition, } from './program.js';
16
+ export { program, makeProgram, mapTransition, actionCommand, effectCommand, commandSlot, type CommandSlot, type Command, type Program, type Send, type Transition, } from './program.js';
15
17
  export { available, resourceComponent, resourceError, type ResourceMessage, type ResourceModel, } from './resource.js';
16
18
  export { fromPromise, type UiLoad, type UiPage } from './load.js';
17
19
  export { observeBindings, inspectBindings, mountBindingInspector, type BindingInspector, type BindingInspection, observePrograms, type BindingUpdate, type ProgramUpdate, } from './diagnostics.js';
@@ -19,9 +21,9 @@ export { inputText, inputChecked, inputNumber, submit, defineField, type FieldRe
19
21
  export { slot, type Slot, type CompiledContent } from './dom.js';
20
22
  export { AsyncContent, type AsyncContentProps } from './AsyncContent.js';
21
23
  export { defineTasks, type TaskDefinition, type TasksModel, type TasksMessage, type TaskResults, } from './tasks.js';
22
- export { uiRuntime, type UiRuntime } from './runtime.js';
23
- export { query, queryGroup, type Query, type QueryKey, type QueryGroup } from './query.js';
24
- export { makeQueryCache, type QueryCache, type QueryCacheOptions } from './cache.js';
24
+ export { uiRuntime, makeUiRuntime, type UiRuntime } from './runtime.js';
25
+ export { query, queryGroup, type Query, type QueryKey, type QueryGroup, type QueryEncoding, } from './query.js';
26
+ export { makeQueryCache, scopedQueryCache, type QueryCache, type QueryCacheOptions, } from './cache.js';
25
27
  export { queryResource, type QueryResource, observeQuery, lifetime, projectionCache, sessionGroup, type SessionContext, type Read, } from './session.js';
26
28
  export { httpJson } from './http.js';
27
29
  export { infiniteQuery, infiniteResource, type InfiniteData, type InfiniteQuery, type InfiniteResource, } from './infinite-query.js';