effectweb 0.4.0 → 0.5.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/dist/index.js CHANGED
@@ -1,15 +1,17 @@
1
- export { modelOwner } from './owner.js';
1
+ export { modelOwner, makeModelOwner, } from './owner.js';
2
2
  export { collection, entities, sequence } from './collection.js';
3
3
  export { component, localComponent, programView } from './component.js';
4
4
  export { patchModel } from './state.js';
5
5
  export { defineActions } from './actions.js';
6
6
  export { taskComponent, taskControls } from './task.js';
7
- export { effectEvent } from './effectEvent.js';
8
- export { mountView, view, list, ViewBinding } from './dom.js';
7
+ export { effectEvent, makeEffectHandler } from './effectEvent.js';
8
+ export { mountView, view, list, listView, memoView, observe, ViewBinding, } from './dom.js';
9
+ export { mount } from './render.js';
10
+ export { mapSource, fromStream, fromSubscriptionRef, fromAtom, projectionSource, } from './source.js';
9
11
  export { lazyView } from './lazy.js';
10
- export { Portal, domBinding, domMount } from './mount.js';
12
+ export { Portal, domBinding, domMount, makeDomMount, makeDomBinding, } from './mount.js';
11
13
  export { pages, } from './pages.js';
12
- export { program, mapTransition, actionCommand, effectCommand, commandSlot, } from './program.js';
14
+ export { program, makeProgram, mapTransition, actionCommand, effectCommand, commandSlot, } from './program.js';
13
15
  export { available, resourceComponent, resourceError, } from './resource.js';
14
16
  export { fromPromise } from './load.js';
15
17
  export { observeBindings, inspectBindings, mountBindingInspector, observePrograms, } from './diagnostics.js';
@@ -17,9 +19,9 @@ export { inputText, inputChecked, inputNumber, submit, defineField, } from './fo
17
19
  export { slot } from './dom.js';
18
20
  export { AsyncContent } from './AsyncContent.js';
19
21
  export { defineTasks, } from './tasks.js';
20
- export { uiRuntime } from './runtime.js';
21
- export { query, queryGroup } from './query.js';
22
- export { makeQueryCache } from './cache.js';
22
+ export { uiRuntime, makeUiRuntime } from './runtime.js';
23
+ export { query, queryGroup, } from './query.js';
24
+ export { makeQueryCache, scopedQueryCache, } from './cache.js';
23
25
  export { queryResource, observeQuery, lifetime, projectionCache, sessionGroup, } from './session.js';
24
26
  export { httpJson } from './http.js';
25
27
  export { infiniteQuery, infiniteResource, } from './infinite-query.js';
@@ -1,5 +1,6 @@
1
1
  import * as Effect from 'effect/Effect';
2
- import { type Query, type QueryArgs, type QueryGroup } from './query.js';
2
+ import type * as Scope from 'effect/Scope';
3
+ import { type Query, type QueryKey, type QueryArgs, type QueryGroup } from './query.js';
3
4
  import type { QueryCache } from './cache.js';
4
5
  import { type QueryResource } from './session.js';
5
6
  import { type Snapshot } from './snapshot.js';
@@ -18,20 +19,23 @@ export interface InfiniteQuery<Args, A, Param, E = never, R = never> {
18
19
  }, A, E, R>;
19
20
  readonly maxPages: number;
20
21
  readonly next: (value: Snapshot<A>, param: Snapshot<Param>) => Param | undefined;
22
+ readonly paramKey: (param: Param | Snapshot<Param> | undefined) => string;
21
23
  }
22
24
  /** Shared pages with explicit cursor identity. Refresh preserves retained page parameters by default. */
23
25
  export declare function infiniteQuery<Args, A, Param, E = never, R = never>(definition: {
24
26
  readonly name: string;
25
27
  readonly initial: Param;
26
- readonly load: (args: Snapshot<Args>, param: Snapshot<Param>) => Effect.Effect<A, E, R>;
28
+ readonly load: (args: Snapshot<Args>, param: Snapshot<Param>) => Effect.Effect<A, E, R | Scope.Scope>;
27
29
  readonly next: (value: Snapshot<A>, param: Snapshot<Param>) => Param | undefined;
28
30
  readonly maxPages?: number;
29
31
  readonly refresh?: 'retained' | 'first';
30
32
  readonly groups?: readonly QueryGroup[];
31
- } & (Args extends QueryArgs<Args> ? unknown : {
32
- readonly nonSerializableQueryArguments: never;
33
- }) & (Param extends QueryArgs<Param> ? unknown : {
34
- readonly nonSerializablePageParameters: never;
33
+ readonly encodeArgs?: (args: Snapshot<Args>) => QueryKey;
34
+ readonly encodeParam?: (param: Snapshot<Param>) => QueryKey;
35
+ } & ([Args] extends [QueryArgs<Args>] ? unknown : {
36
+ readonly encodeArgs: (args: Snapshot<Args>) => QueryKey;
37
+ }) & ([Param] extends [QueryArgs<Param>] ? unknown : {
38
+ readonly encodeParam: (param: Snapshot<Param>) => QueryKey;
35
39
  })): InfiniteQuery<Args, A, Param, E, R>;
36
40
  export interface InfiniteResource<Args, A, Param, E = never> extends QueryResource<Args, InfiniteData<A, Param>, E> {
37
41
  loadNext(): Effect.Effect<Snapshot<InfiniteData<A, Param>>, E>;
@@ -39,4 +43,4 @@ export interface InfiniteResource<Args, A, Param, E = never> extends QueryResour
39
43
  seed(args: Args | Snapshot<Args>, data: InfiniteData<A, Param> | Snapshot<InfiniteData<A, Param>>): Snapshot<InfiniteData<A, Param>>;
40
44
  }
41
45
  /** Cache-owned results, observer-owned subscriptions. Operations return typed Effects for task composition. */
42
- export declare function infiniteResource<Args, A, Param, E, R>(cache: QueryCache<R>, definition: InfiniteQuery<Args, A, Param, E, NoInfer<R>>): InfiniteResource<Args, A, Param, E>;
46
+ export declare function infiniteResource<Args, A, Param, E, R>(cache: QueryCache<R>, definition: InfiniteQuery<Args, A, Param, E, NoInfer<R> | Scope.Scope>): InfiniteResource<Args, A, Param, E>;
@@ -1,5 +1,5 @@
1
1
  import * as Effect from 'effect/Effect';
2
- import { query, encodeQueryKey, } from './query.js';
2
+ import { query, encodeQueryKey, encodeQueryArguments, } from './query.js';
3
3
  import { cacheInternals } from './cache-internals.js';
4
4
  import { queryResource } from './session.js';
5
5
  import { protectSnapshot } from './snapshot.js';
@@ -9,19 +9,27 @@ export function infiniteQuery(definition) {
9
9
  const maxPages = config.maxPages ?? Infinity;
10
10
  if (maxPages !== Infinity && (!Number.isInteger(maxPages) || maxPages < 1))
11
11
  throw new RangeError('maxPages must be a positive integer.');
12
- encodeQueryKey(config.initial);
12
+ const encodeArgs = (args) => config.encodeArgs ? config.encodeArgs(args) : args;
13
+ const encodeParam = (param) => config.encodeParam ? config.encodeParam(param) : param;
14
+ const paramKey = (param) => encodeQueryKey(param === undefined ? undefined : encodeParam(param));
15
+ paramKey(config.initial);
13
16
  const initial = protectSnapshot(config.initial);
14
17
  const next = (value, param) => {
15
18
  const result = config.next(value, param);
16
- encodeQueryKey(result);
19
+ paramKey(result);
17
20
  return result;
18
21
  };
19
22
  const page = query({
20
23
  name: `${config.name}:page`,
24
+ encode: ({ args, param }) => ({
25
+ args: encodeArgs(args),
26
+ param: encodeParam(param),
27
+ }),
21
28
  load: ({ args, param }) => config.load(args, param),
22
29
  });
23
30
  const root = query({
24
31
  name: config.name,
32
+ encode: encodeArgs,
25
33
  ...(config.groups ? { groups: config.groups } : {}),
26
34
  load: (args, previous) => {
27
35
  const params = config.refresh !== 'first' && previous?.pages.length
@@ -36,7 +44,7 @@ export function infiniteQuery(definition) {
36
44
  }));
37
45
  },
38
46
  });
39
- return Object.freeze({ query: root, page, maxPages, next });
47
+ return Object.freeze({ query: root, page, maxPages, next, paramKey });
40
48
  }
41
49
  const operations = new WeakMap();
42
50
  function operationBook(cache, definition) {
@@ -70,11 +78,11 @@ export function infiniteResource(cache, definition) {
70
78
  const param = retry ? retry.param : current.next;
71
79
  if (param === undefined)
72
80
  return current;
73
- const pageKey = encodeQueryKey(param);
74
- const retained = current.pages.some((page) => encodeQueryKey(page.param) === pageKey);
75
- if (retry && !retained && encodeQueryKey(current.next) !== pageKey)
81
+ const pageKey = definition.paramKey(param);
82
+ const retained = current.pages.some((page) => definition.paramKey(page.param) === pageKey);
83
+ if (retry && !retained && definition.paramKey(current.next) !== pageKey)
76
84
  return yield* Effect.die(new RangeError('Retry a retained page or the next page parameter.'));
77
- const key = encodeQueryKey(args);
85
+ const key = encodeQueryArguments(definition.query, args);
78
86
  const revision = internal.revision(definition.query, args);
79
87
  let operation = book.get(key);
80
88
  if (!operation || operation.revision !== revision) {
@@ -95,8 +103,8 @@ export function infiniteResource(cache, definition) {
95
103
  book.get(key) !== state ||
96
104
  state.revision !== internal.revision(definition.query, args))
97
105
  return;
98
- const index = latest.pages.findIndex((page) => encodeQueryKey(page.param) === pageKey);
99
- if (index < 0 && (retained || encodeQueryKey(latest.next) !== pageKey))
106
+ const index = latest.pages.findIndex((page) => definition.paramKey(page.param) === pageKey);
107
+ if (index < 0 && (retained || definition.paramKey(latest.next) !== pageKey))
100
108
  return;
101
109
  if (index >= 0 && latest.pages[index].value === value)
102
110
  return;
@@ -134,8 +142,8 @@ export function infiniteResource(cache, definition) {
134
142
  seed(args, data) {
135
143
  if (!data.pages.length || data.pages.length > definition.maxPages)
136
144
  throw new RangeError('Seed pages must fit the retained page range.');
137
- encodeQueryKey(data.next);
138
- const keys = data.pages.map((page) => encodeQueryKey(page.param));
145
+ definition.paramKey(data.next);
146
+ const keys = data.pages.map((page) => definition.paramKey(page.param));
139
147
  if (new Set(keys).size !== keys.length)
140
148
  throw new TypeError('Seed page parameters must be unique.');
141
149
  return cache.setQueryData(definition.query, args, data);
package/dist/jsx.d.ts CHANGED
@@ -4,6 +4,8 @@ import type { SnapshotOpaque } from './snapshot.js';
4
4
  import type { EffectEventRequest } from './effectEvent.js';
5
5
  import type { CompiledContent } from './dom.js';
6
6
  import type { DomMount } from './mount.js';
7
+ import type * as Effect from 'effect/Effect';
8
+ import type * as Scope from 'effect/Scope';
7
9
  /** Nominal eligibility for compiled JSX components and compiler primitives. */
8
10
  export declare const jsxComponent: unique symbol;
9
11
  /** JSX is a compiler input. No renderer library owns its types. */
@@ -16,7 +18,7 @@ export declare namespace JSX {
16
18
  export interface ElementArray extends ReadonlyArray<Element>, SnapshotOpaque {
17
19
  }
18
20
  export type Element = string | number | boolean | null | undefined | CompiledContent | ElementArray;
19
- export type EventResult = void | boolean | EffectEventRequest;
21
+ export type EventResult = void | boolean | EffectEventRequest | Effect.Effect<unknown, unknown, Scope.Scope>;
20
22
  export type EventHandler<T, E extends Event> = (event: E & {
21
23
  currentTarget: T;
22
24
  target: EventTarget & globalThis.Element;
@@ -1,5 +1,6 @@
1
1
  import * as Cause from 'effect/Cause';
2
2
  import * as Effect from 'effect/Effect';
3
+ import type * as Scope from 'effect/Scope';
3
4
  import { type TaskPolicy } from './program.js';
4
5
  import type { DisposableOwner } from './owner.js';
5
6
  import { type UiRuntime } from './runtime.js';
@@ -20,8 +21,8 @@ export interface TaskHandle<A, E> {
20
21
  export declare function keyedTasks<Key, Input, A, E = never, R = never>(owner: DisposableOwner, definition: {
21
22
  readonly name: string;
22
23
  readonly policy: TaskPolicy;
23
- readonly run: (key: Key, input: Snapshot<Input>) => Effect.Effect<A, E, R>;
24
- }, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): {
24
+ readonly run: (key: Key, input: Snapshot<Input>) => Effect.Effect<A, E, R | Scope.Scope>;
25
+ }, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): {
25
26
  submit(key: Key, input: Input | Snapshot<Input>): TaskHandle<A, E>;
26
27
  cancel(key: Key): void;
27
28
  drain(...selected: [] | [Key]): Effect.Effect<void>;
@@ -66,7 +66,7 @@ export function keyedTasks(owner, definition, ...provided) {
66
66
  if (Exit.isFailure(exit))
67
67
  reportSafely(reportError, exit.cause);
68
68
  });
69
- const action = Effect.uninterruptibleMask((restore) => restore(Effect.suspend(() => definition.run(key, captured)).pipe(Effect.map((value) => protectSnapshot(value)))).pipe(Effect.exit, Effect.flatMap((exit) => Effect.sync(() => settle(Exit.isSuccess(exit)
69
+ const action = Effect.uninterruptibleMask((restore) => restore(Effect.scoped(Effect.suspend(() => definition.run(key, captured)).pipe(Effect.map((value) => protectSnapshot(value))))).pipe(Effect.exit, Effect.flatMap((exit) => Effect.sync(() => settle(Exit.isSuccess(exit)
70
70
  ? { _tag: 'Success', value: exit.value }
71
71
  : Cause.hasInterruptsOnly(exit.cause)
72
72
  ? { _tag: 'Cancelled' }
@@ -75,7 +75,7 @@ export function keyedTasks(owner, definition, ...provided) {
75
75
  command: {
76
76
  slot,
77
77
  policy: definition.policy,
78
- action: runtime.provide(action),
78
+ action: runtime.provideScoped(action),
79
79
  onDiscard: (_tag) => settle({ _tag }),
80
80
  },
81
81
  });
package/dist/lazy.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as Cause from 'effect/Cause';
2
2
  import * as Effect from 'effect/Effect';
3
+ import type * as Scope from 'effect/Scope';
3
4
  import { type View } from './dom.js';
4
5
  import { type UiRuntime } from './runtime.js';
5
6
  export interface LazyViewOptions<Model, Message, E> {
@@ -13,7 +14,7 @@ export interface LazyViewOptions<Model, Message, E> {
13
14
  * Mount-owned loading with current inputs and a reusable successful definition.
14
15
  * Unresolved placements own independent requests; unmount interrupts their work.
15
16
  */
16
- export declare function lazyView<Model, Message = never, E = never, R = never>(load: () => Effect.Effect<View<Model, Message>, E, R>, ...provided: [R] extends [never] ? [
17
+ export declare function lazyView<Model, Message = never, E = never, R = never>(load: () => Effect.Effect<View<Model, Message>, E, R | Scope.Scope>, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [
17
18
  options?: LazyViewOptions<NoInfer<Model>, NoInfer<Message>, NoInfer<E>> & {
18
19
  readonly runtime?: UiRuntime<R>;
19
20
  }
package/dist/lazy.js CHANGED
@@ -10,9 +10,10 @@ import { defaultUiRuntime } from './runtime.js';
10
10
  */
11
11
  export function lazyView(load, ...provided) {
12
12
  const options = provided[0];
13
- const runtime = options?.runtime ?? defaultUiRuntime;
14
13
  let loaded;
15
14
  return compiled((scope, parent, before) => {
15
+ const ownerRuntime = scope.settlement.runtime ?? defaultUiRuntime;
16
+ const runtime = options?.runtime ?? ownerRuntime;
16
17
  const render = viewRegion(scope, parent, before);
17
18
  if (loaded) {
18
19
  render(loaded);
@@ -29,7 +30,7 @@ export function lazyView(load, ...provided) {
29
30
  return;
30
31
  const finished = scope.settlement.begin();
31
32
  try {
32
- fiber = Effect.runFork(runtime.provide(Effect.suspend(load)));
33
+ fiber = ownerRuntime.runFork(runtime.provideScoped(Effect.suspend(load)));
33
34
  }
34
35
  catch (error) {
35
36
  finished();
package/dist/mount.d.ts CHANGED
@@ -1,33 +1,39 @@
1
1
  import type { Snapshot } from './snapshot.js';
2
2
  import { type ReportError } from './errors.js';
3
3
  import * as Effect from 'effect/Effect';
4
+ import * as Scope from 'effect/Scope';
5
+ import * as Exit from 'effect/Exit';
4
6
  import { type UiRuntime } from './runtime.js';
5
7
  export { Portal, type PortalProps } from './dom.js';
6
8
  type Work<R = never> = (() => void) | {
7
9
  dispose: () => void;
8
10
  update?: () => void;
9
- } | Effect.Effect<never, unknown, R>;
11
+ } | Effect.Effect<unknown, unknown, R | Scope.Scope>;
10
12
  export interface DomMount<T extends Element = Element> {
11
13
  readonly identity: unknown;
12
14
  readonly data: unknown;
13
15
  readonly acquire: (element: T, input: () => unknown) => Work;
14
16
  }
15
17
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
16
- export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
18
+ export declare function domMount<T extends Element, R = never>(start: (element: T) => Work<R>, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
17
19
  /** A stable DOM lifecycle with fresh immutable inputs, for focus, measurements and native events. */
18
- export declare function domBinding<T extends Element, A, R = never>(data: A | Snapshot<A>, acquire: (element: T, input: () => Snapshot<A>) => Work<R>, ...provided: [R] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
20
+ export declare function domBinding<T extends Element, A, R = never>(data: A | Snapshot<A>, acquire: (element: T, input: () => Snapshot<A>) => Work<R>, ...provided: [Exclude<R, Scope.Scope>] extends [never] ? [runtime?: UiRuntime<R>] : [runtime: UiRuntime<R>]): DomMount<T>;
21
+ /** Describe a DOM acquisition in Effect setup; services are captured once, resources belong to the element. */
22
+ export declare const makeDomMount: <T extends Element, R = never>(start: (element: T) => Work<R>) => Effect.Effect<DomMount<T>, never, R>;
23
+ /** Capture services once and bind changing inputs without replacing the acquisition lifetime. */
24
+ export declare const makeDomBinding: <T extends Element, A, R = never>(acquire: (element: T, input: () => Snapshot<A>) => Work<R>) => Effect.Effect<(data: A | Snapshot<A>) => DomMount<T>, never, R>;
19
25
  /** Allocate the lifetime before acquisition so reentrant publications can update or close it. */
20
- export declare function prepareMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError): {
26
+ export declare function prepareMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError, runtime?: UiRuntime<never>): {
21
27
  closed: Effect.Effect<void, never, never>;
22
28
  start(): void;
23
29
  close: () => Effect.Effect<void, never, never>;
24
30
  update(next: DomMount<T>): boolean;
25
- dispose: () => void;
31
+ dispose: (exit?: Exit.Exit<unknown, unknown>) => void;
26
32
  };
27
- export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError): {
33
+ export declare function startMount<T extends Element>(element: T, mount: DomMount<T>, report?: ReportError, runtime?: UiRuntime<never>): {
28
34
  closed: Effect.Effect<void, never, never>;
29
35
  start(): void;
30
36
  close: () => Effect.Effect<void, never, never>;
31
37
  update(next: DomMount<T>): boolean;
32
- dispose: () => void;
38
+ dispose: (exit?: Exit.Exit<unknown, unknown>) => void;
33
39
  };
package/dist/mount.js CHANGED
@@ -3,17 +3,19 @@ import * as Cause from 'effect/Cause';
3
3
  import * as Deferred from 'effect/Deferred';
4
4
  import * as Effect from 'effect/Effect';
5
5
  import * as Fiber from 'effect/Fiber';
6
- import { defaultUiRuntime } from './runtime.js';
6
+ import * as Scope from 'effect/Scope';
7
+ import * as Exit from 'effect/Exit';
8
+ import { uiRuntime } from './runtime.js';
7
9
  export { Portal } from './dom.js';
8
10
  /** Element-owned work. Changed acquisition functions replace and interrupt the prior lifetime. */
9
11
  export function domMount(start, ...provided) {
10
- const runtime = provided[0] ?? defaultUiRuntime;
12
+ const runtime = provided[0];
11
13
  return {
12
14
  identity: start,
13
15
  data: undefined,
14
16
  acquire: (element) => {
15
17
  const work = start(element);
16
- return Effect.isEffect(work) ? runtime.provide(work) : work;
18
+ return Effect.isEffect(work) && runtime ? runtime.provideScoped(work) : work;
17
19
  },
18
20
  };
19
21
  }
@@ -24,29 +26,66 @@ export function domBinding(data, acquire, ...provided) {
24
26
  data,
25
27
  acquire: (element, input) => {
26
28
  const work = acquire(element, input);
27
- const runtime = provided[0] ?? defaultUiRuntime;
28
- return Effect.isEffect(work) ? runtime.provide(work) : work;
29
+ const runtime = provided[0];
30
+ return Effect.isEffect(work) && runtime ? runtime.provideScoped(work) : work;
29
31
  },
30
32
  };
31
33
  }
34
+ /** Describe a DOM acquisition in Effect setup; services are captured once, resources belong to the element. */
35
+ export const makeDomMount = (start) => Effect.map(Effect.context(), (context) => {
36
+ const runtime = uiRuntime(context);
37
+ return {
38
+ identity: start,
39
+ data: undefined,
40
+ acquire: (element) => {
41
+ const work = start(element);
42
+ return Effect.isEffect(work) ? runtime.provideScoped(work) : work;
43
+ },
44
+ };
45
+ });
46
+ /** Capture services once and bind changing inputs without replacing the acquisition lifetime. */
47
+ export const makeDomBinding = (acquire) => Effect.map(Effect.context(), (context) => {
48
+ const runtime = uiRuntime(context);
49
+ return (data) => ({
50
+ identity: acquire,
51
+ data,
52
+ acquire: (element, input) => {
53
+ const work = acquire(element, input);
54
+ return Effect.isEffect(work) ? runtime.provideScoped(work) : work;
55
+ },
56
+ });
57
+ });
32
58
  /** Allocate the lifetime before acquisition so reentrant publications can update or close it. */
33
- export function prepareMount(element, mount, report = reportError) {
59
+ export function prepareMount(element, mount, report = reportError, runtime) {
34
60
  let data = mount.data;
35
61
  let work;
36
62
  let fiber;
63
+ const resourceScope = Scope.makeUnsafe();
37
64
  let started = false;
38
65
  let disposed = false;
39
66
  let released = false;
67
+ let closingExit;
40
68
  let changedDuringSetup = false;
41
69
  const completed = Deferred.makeUnsafe();
42
70
  const closed = Deferred.await(completed);
43
71
  const finish = () => Deferred.doneUnsafe(completed, Effect.void);
44
- const release = () => {
72
+ const release = (exit = Exit.void) => {
73
+ closingExit ??= exit;
45
74
  if (released || work === undefined || (Effect.isEffect(work) && !fiber))
46
75
  return;
47
76
  released = true;
48
- if (fiber)
49
- Effect.runFork(Fiber.interrupt(fiber));
77
+ if (fiber) {
78
+ // Acquisition may finish successfully while its resources remain mounted.
79
+ // Join acquisition before releasing its scope, including async finalizers.
80
+ Effect.runFork(Effect.gen(function* () {
81
+ yield* Fiber.interrupt(fiber);
82
+ yield* Scope.close(resourceScope, closingExit);
83
+ })).addObserver((exit) => {
84
+ if (exit._tag === 'Failure')
85
+ reportSafely(report, exit.cause);
86
+ finish();
87
+ });
88
+ }
50
89
  else {
51
90
  try {
52
91
  if (typeof work === 'function')
@@ -59,14 +98,14 @@ export function prepareMount(element, mount, report = reportError) {
59
98
  }
60
99
  }
61
100
  };
62
- const dispose = () => {
101
+ const dispose = (exit = Exit.void) => {
63
102
  if (disposed)
64
103
  return;
65
104
  disposed = true;
66
105
  if (!started)
67
106
  finish();
68
107
  else
69
- release();
108
+ release(exit);
70
109
  };
71
110
  return {
72
111
  closed,
@@ -77,11 +116,18 @@ export function prepareMount(element, mount, report = reportError) {
77
116
  try {
78
117
  work = mount.acquire(element, () => data);
79
118
  if (Effect.isEffect(work)) {
80
- fiber = Effect.runFork(work);
119
+ const acquisition = Scope.provide(work, resourceScope);
120
+ fiber = runtime
121
+ ? runtime.runFork(acquisition)
122
+ : Effect.runFork(Effect.scoped(acquisition));
81
123
  fiber.addObserver((exit) => {
124
+ // User reporting may synchronously unmount; retain the acquisition failure first.
125
+ if (exit._tag === 'Failure')
126
+ closingExit ??= exit;
82
127
  if (exit._tag === 'Failure' && (!disposed || !Cause.hasInterruptsOnly(exit.cause)))
83
128
  reportSafely(report, exit.cause);
84
- finish();
129
+ if (exit._tag === 'Failure')
130
+ release(exit);
85
131
  });
86
132
  }
87
133
  if (disposed)
@@ -90,7 +136,7 @@ export function prepareMount(element, mount, report = reportError) {
90
136
  work.update?.();
91
137
  }
92
138
  catch (error) {
93
- dispose();
139
+ dispose(Exit.die(error));
94
140
  if (work === undefined)
95
141
  finish();
96
142
  throw error;
@@ -113,8 +159,8 @@ export function prepareMount(element, mount, report = reportError) {
113
159
  dispose,
114
160
  };
115
161
  }
116
- export function startMount(element, mount, report = reportError) {
117
- const lifetime = prepareMount(element, mount, report);
162
+ export function startMount(element, mount, report = reportError, runtime) {
163
+ const lifetime = prepareMount(element, mount, report, runtime);
118
164
  lifetime.start();
119
165
  return lifetime;
120
166
  }
package/dist/owner.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type Snapshot } from './snapshot.js';
2
2
  import * as Effect from 'effect/Effect';
3
+ import type * as Scope from 'effect/Scope';
3
4
  import { type Program, type TaskPolicy, type CommandSlot } from './program.js';
4
5
  import { type ReportError } from './errors.js';
5
- import type { UiRuntime } from './runtime.js';
6
+ import { type UiRuntime } from './runtime.js';
6
7
  export type { TaskPolicy } from './program.js';
7
8
  interface OwnedResource {
8
9
  dispose(): void;
@@ -23,7 +24,7 @@ export interface ModelOwner<Model extends object, R = never> extends DisposableO
23
24
  readonly edit: <K extends keyof Model>(key: K, change: (value: Snapshot<Model[K]>) => Model[K] | Snapshot<Model[K]>) => void;
24
25
  /** Synchronous transaction. Reads see staged changes; a throw discards changes and work. */
25
26
  readonly transaction: <A>(work: () => A & (A extends PromiseLike<unknown> ? never : unknown)) => A;
26
- readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R>, policy: TaskPolicy) => void;
27
+ readonly run: (slot: CommandSlot, effect: Effect.Effect<unknown, unknown, R | Scope.Scope>, policy: TaskPolicy) => void;
27
28
  readonly cancel: (slot: CommandSlot) => void;
28
29
  readonly isRunning: (slot: CommandSlot) => boolean;
29
30
  readonly awaitIdle: (slot?: CommandSlot) => Effect.Effect<void>;
@@ -39,3 +40,5 @@ export declare function modelOwner<Model extends object>(initial: Model, options
39
40
  export declare function modelOwner<Model extends object, R>(initial: Model, options: Options & {
40
41
  runtime: UiRuntime<R>;
41
42
  }): ModelOwner<Model, R>;
43
+ /** Construct an owner using the current services and release it with the current Effect scope. */
44
+ export declare const makeModelOwner: <Model extends object, R = never>(initial: Model, options?: Options) => Effect.Effect<ModelOwner<Model, R>, never, R | Scope.Scope>;
package/dist/owner.js CHANGED
@@ -5,6 +5,7 @@ import * as Exit from 'effect/Exit';
5
5
  import { program, } from './program.js';
6
6
  import { patchModel } from './state.js';
7
7
  import { runAll, reportError } from './errors.js';
8
+ import { makeUiRuntime } from './runtime.js';
8
9
  export function modelOwner(initial, options = {}) {
9
10
  let disposed = false;
10
11
  let staged;
@@ -12,7 +13,15 @@ export function modelOwner(initial, options = {}) {
12
13
  let resourcesDisposed = false;
13
14
  const source = program({
14
15
  initial,
15
- ...options,
16
+ ...(options.name ? { name: options.name } : {}),
17
+ ...(options.onDefect ? { onDefect: options.onDefect } : {}),
18
+ ...(options.runtime
19
+ ? {
20
+ runtime: {
21
+ runFork: (effect) => options.runtime.runFork(effect),
22
+ },
23
+ }
24
+ : {}),
16
25
  update(snapshot, operations) {
17
26
  let model = snapshot;
18
27
  const single = operations.length === 1 ? operations[0] : undefined;
@@ -142,3 +151,8 @@ export function modelOwner(initial, options = {}) {
142
151
  dispose,
143
152
  };
144
153
  }
154
+ /** Construct an owner using the current services and release it with the current Effect scope. */
155
+ export const makeModelOwner = (initial, options = {}) => Effect.gen(function* () {
156
+ const runtime = yield* makeUiRuntime();
157
+ return yield* Effect.acquireRelease(Effect.sync(() => modelOwner(initial, { ...options, runtime })), (owner) => owner.close().pipe(Effect.orDie));
158
+ });
package/dist/program.d.ts CHANGED
@@ -2,6 +2,8 @@ import { type Snapshot } from './snapshot.js';
2
2
  import * as Cause from 'effect/Cause';
3
3
  import * as Effect from 'effect/Effect';
4
4
  import * as Stream from 'effect/Stream';
5
+ import type * as Scope from 'effect/Scope';
6
+ import { type UiRuntime } from './runtime.js';
5
7
  export type Send<Message> = (message: Message) => void;
6
8
  /** queue retains FIFO requests; latest-queued retains only the newest pending request. */
7
9
  export type TaskPolicy = 'replace' | 'drop' | 'parallel' | 'queue' | 'latest-queued';
@@ -18,15 +20,15 @@ export type Command<Message, R = never> = {
18
20
  /** Notification for work that never started. */
19
21
  readonly onDiscard?: (reason: 'Cancelled' | 'Superseded' | 'Dropped') => void;
20
22
  } & ({
21
- readonly effect: Effect.Effect<Message, never, R>;
23
+ readonly effect: Effect.Effect<Message, never, R | Scope.Scope>;
22
24
  readonly stream?: never;
23
25
  readonly action?: never;
24
26
  } | {
25
- readonly stream: Stream.Stream<Message, never, R>;
27
+ readonly stream: Stream.Stream<Message, never, R | Scope.Scope>;
26
28
  readonly effect?: never;
27
29
  readonly action?: never;
28
30
  } | {
29
- readonly action: Effect.Effect<void, unknown, R>;
31
+ readonly action: Effect.Effect<void, unknown, R | Scope.Scope>;
30
32
  readonly effect?: never;
31
33
  readonly stream?: never;
32
34
  });
@@ -67,5 +69,13 @@ export declare function program<Model, Message>(options: {
67
69
  name?: string;
68
70
  update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message>;
69
71
  onDefect?: (cause: unknown) => void;
72
+ runtime?: Pick<UiRuntime<never>, 'runFork'>;
70
73
  }): RunningProgram<Model, Message>;
74
+ /** Construct a program in the current Effect environment and own it in the current scope. */
75
+ export declare const makeProgram: <Model, Message, R = never>(options: {
76
+ initial: Model | Snapshot<Model>;
77
+ name?: string;
78
+ update: (model: Snapshot<Model>, message: Message) => Transition<Model, Message, R>;
79
+ onDefect?: (cause: unknown) => void;
80
+ }) => Effect.Effect<RunningProgram<Model, Message>, never, R | Scope.Scope>;
71
81
  export {};
package/dist/program.js CHANGED
@@ -6,13 +6,15 @@ import * as Effect from 'effect/Effect';
6
6
  import * as Fiber from 'effect/Fiber';
7
7
  import * as Option from 'effect/Option';
8
8
  import * as Stream from 'effect/Stream';
9
+ import { makeUiRuntime } from './runtime.js';
9
10
  export const commandSlot = (name) => Symbol(name);
10
11
  /** A lazy request settles successes, typed failures and defects through one message contract. */
11
12
  export function effectCommand(slot, load, handlers) {
12
13
  return {
13
14
  slot,
14
15
  policy: handlers.policy,
15
- effect: Effect.suspend(load).pipe(Effect.matchCause(handlers)),
16
+ // Release the operation with its actual exit before translating it into a message.
17
+ effect: Effect.scoped(Effect.suspend(load)).pipe(Effect.matchCause(handlers)),
16
18
  };
17
19
  }
18
20
  /** Owned work with no model result. Failures use the program's error reporter. */
@@ -152,7 +154,9 @@ export function program(options) {
152
154
  ? command.action.pipe(Effect.as(Option.none()))
153
155
  : command.effect.pipe(Effect.map(Option.some));
154
156
  live.add(task);
155
- const fiber = Effect.runFork(effect);
157
+ const fiber = options.runtime
158
+ ? options.runtime.runFork(effect)
159
+ : Effect.runFork(Effect.scoped(effect));
156
160
  task.fiber = fiber;
157
161
  fiber.addObserver((exit) => {
158
162
  live.delete(task);
@@ -320,3 +324,8 @@ export function program(options) {
320
324
  dispose,
321
325
  };
322
326
  }
327
+ /** Construct a program in the current Effect environment and own it in the current scope. */
328
+ export const makeProgram = (options) => Effect.gen(function* () {
329
+ const runtime = yield* makeUiRuntime();
330
+ return yield* Effect.acquireRelease(Effect.sync(() => runtime.program(options)), (source) => source.close());
331
+ });
@@ -1,13 +1,15 @@
1
1
  import type * as Effect from 'effect/Effect';
2
+ import type * as Scope from 'effect/Scope';
2
3
  import type { Snapshot } from './snapshot.js';
3
- import type { Query, QueryGroup } from './query.js';
4
+ import type { Query, QueryGroup, QueryKey } from './query.js';
4
5
  export interface QueryDefinition<Args, A, E, R> {
5
6
  readonly name: string;
6
- readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R>;
7
+ readonly load: (args: Snapshot<Args>, previous?: Snapshot<A>) => Effect.Effect<A, E, R | Scope.Scope>;
7
8
  readonly groups?: readonly QueryGroup[];
8
9
  readonly unused?: 'retain' | 'cancel';
9
10
  readonly staleTime: number;
10
11
  readonly share?: (previous: Snapshot<A>, next: A | Snapshot<A>) => A | Snapshot<A>;
12
+ readonly encode?: (args: Snapshot<Args>) => QueryKey;
11
13
  }
12
14
  export declare function registerQuery<Args, A, E, R>(query: Query<Args, A, E, R>, definition: QueryDefinition<Args, A, E, R>): void;
13
15
  export declare function queryDefinition<Args, A, E, R>(query: Query<Args, A, E, R>): QueryDefinition<Args, A, E, R>;