effectweb 0.2.2 → 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.
Files changed (65) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +94 -2
  3. package/dist/AsyncContent.d.ts +2 -2
  4. package/dist/AsyncContent.js +36 -24
  5. package/dist/actions.d.ts +4 -4
  6. package/dist/actions.js +1 -1
  7. package/dist/cache-internals.d.ts +14 -0
  8. package/dist/cache-internals.js +11 -0
  9. package/dist/cache.d.ts +17 -15
  10. package/dist/cache.js +98 -27
  11. package/dist/collection.d.ts +10 -10
  12. package/dist/collection.js +32 -6
  13. package/dist/component.d.ts +8 -7
  14. package/dist/diagnostics.d.ts +25 -0
  15. package/dist/diagnostics.js +167 -1
  16. package/dist/dom-attributes.json +71 -0
  17. package/dist/dom.d.ts +36 -13
  18. package/dist/dom.js +547 -84
  19. package/dist/form.d.ts +61 -0
  20. package/dist/form.js +98 -0
  21. package/dist/html-attributes.d.ts +602 -0
  22. package/dist/html-attributes.js +1 -0
  23. package/dist/http.d.ts +5 -0
  24. package/dist/http.js +7 -0
  25. package/dist/index.d.ts +12 -10
  26. package/dist/index.js +7 -5
  27. package/dist/jsx.d.ts +165 -33
  28. package/dist/jsx.js +2 -1
  29. package/dist/lazy.d.ts +23 -0
  30. package/dist/lazy.js +58 -0
  31. package/dist/load.d.ts +3 -4
  32. package/dist/mount.d.ts +8 -11
  33. package/dist/mount.js +1 -3
  34. package/dist/owner.d.ts +11 -9
  35. package/dist/owner.js +27 -38
  36. package/dist/pages.d.ts +23 -23
  37. package/dist/pages.js +15 -10
  38. package/dist/program.d.ts +19 -10
  39. package/dist/program.js +172 -66
  40. package/dist/query-internals.d.ts +11 -0
  41. package/dist/query-internals.js +10 -0
  42. package/dist/query.d.ts +24 -20
  43. package/dist/query.js +60 -7
  44. package/dist/resource.d.ts +4 -10
  45. package/dist/resource.js +14 -5
  46. package/dist/runtime.d.ts +1 -2
  47. package/dist/runtime.js +3 -3
  48. package/dist/session.d.ts +12 -18
  49. package/dist/session.js +15 -15
  50. package/dist/share.d.ts +5 -4
  51. package/dist/share.js +5 -44
  52. package/dist/sharing.d.ts +6 -0
  53. package/dist/sharing.js +93 -0
  54. package/dist/snapshot.d.ts +16 -5
  55. package/dist/snapshot.js +6 -7
  56. package/dist/state.d.ts +0 -1
  57. package/dist/state.js +0 -1
  58. package/dist/svg-attributes.d.ts +12 -0
  59. package/dist/svg-attributes.js +1 -0
  60. package/dist/task.d.ts +9 -8
  61. package/dist/task.js +75 -47
  62. package/dist/tasks.d.ts +20 -15
  63. package/dist/tasks.js +39 -25
  64. package/dist/testing.d.ts +10 -15
  65. package/package.json +5 -1
package/dist/resource.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { commandSlot } from './program.js';
1
2
  import { Cause, Effect, Option } from 'effect';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import { loadEffect } from './load.js';
4
5
  import { component } from './component.js';
5
6
  import { defaultUiRuntime } from './runtime.js';
6
7
  import { effectCommand } from './program.js';
8
+ const commandLoad = commandSlot('load');
7
9
  export const available = (result) => Option.getOrUndefined(AsyncResult.value(result));
8
10
  export const resourceError = (result) => {
9
11
  if (!AsyncResult.isFailure(result))
@@ -21,7 +23,7 @@ export function resourceComponent(definition) {
21
23
  if (!selected)
22
24
  return {
23
25
  model: { ...model, key: undefined, result: AsyncResult.initial() },
24
- cancel: ['load'],
26
+ cancel: [commandLoad],
25
27
  };
26
28
  return {
27
29
  model: {
@@ -30,9 +32,10 @@ export function resourceComponent(definition) {
30
32
  result: AsyncResult.waiting(selected.key === model.key ? model.result : AsyncResult.initial()),
31
33
  },
32
34
  commands: [
33
- effectCommand('load', () => runtime.provide(selected.delay
35
+ effectCommand(commandLoad, () => runtime.provide(selected.delay
34
36
  ? Effect.sleep(selected.delay).pipe(Effect.andThen(loadEffect(selected.load)))
35
37
  : loadEffect(selected.load)), {
38
+ policy: 'replace',
36
39
  onSuccess: (value) => ({ type: 'Loaded', value }),
37
40
  onFailure: (cause) => ({ type: 'Failed', cause }),
38
41
  }),
@@ -40,9 +43,15 @@ export function resourceComponent(definition) {
40
43
  };
41
44
  };
42
45
  return component({
43
- init: (props) => ({ props, key: undefined, result: AsyncResult.initial() }),
44
- receive: (model, props) => request({ ...model, props }),
45
- update: (model, message) => {
46
+ init: (props) => ({
47
+ props: props,
48
+ key: undefined,
49
+ result: AsyncResult.initial(),
50
+ }),
51
+ receive: (model, props) => request({ ...model, props: props }),
52
+ update: (snapshot, message) => {
53
+ // Internal commands preserve domain types; the public reducer boundary is immutable.
54
+ const model = snapshot;
46
55
  switch (message.type) {
47
56
  case 'Retry':
48
57
  return request(model, true);
package/dist/runtime.d.ts CHANGED
@@ -7,10 +7,9 @@ export interface UiRuntime<R> {
7
7
  readonly provide: <A, E>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>;
8
8
  readonly command: <M>(command: Command<M, R>) => Command<M>;
9
9
  readonly program: <M, Msg>(options: {
10
- initial: M;
10
+ initial: M | Snapshot<M>;
11
11
  update: (model: Snapshot<M>, message: Msg) => Transition<M, Msg, R>;
12
12
  name?: string;
13
- checkSnapshots?: boolean;
14
13
  onDefect?: (cause: unknown) => void;
15
14
  }) => RunningProgram<M, Msg>;
16
15
  }
package/dist/runtime.js CHANGED
@@ -3,10 +3,10 @@ import { program } from './program.js';
3
3
  export function uiRuntime(context) {
4
4
  const provide = (effect) => Effect.provideContext(effect, context);
5
5
  const command = (value) => value.stream
6
- ? { slot: value.slot, stream: value.stream.pipe(Stream.provideContext(context)) }
6
+ ? { ...value, stream: value.stream.pipe(Stream.provideContext(context)) }
7
7
  : value.action
8
- ? { slot: value.slot, action: provide(value.action) }
9
- : { slot: value.slot, effect: provide(value.effect) };
8
+ ? { ...value, action: provide(value.action) }
9
+ : { ...value, effect: provide(value.effect) };
10
10
  return {
11
11
  context,
12
12
  provide,
package/dist/session.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import type { Query } from './query.js';
1
+ import type { Snapshot } from './snapshot.js';
2
+ import { type Query } from './query.js';
2
3
  import type { DisposableOwner } from './owner.js';
3
4
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
4
5
  import type { QueryCache } from './cache.js';
5
- /** Own subscriptions, DOM adapters and other cleanup for one lifetime. */
6
6
  export declare function lifetime(): {
7
7
  add(cleanup: () => void): void;
8
8
  dispose(): void;
@@ -13,6 +13,14 @@ export interface SessionContext<R = never> {
13
13
  readonly cache: QueryCache<R>;
14
14
  readonly changed: () => void;
15
15
  }
16
+ /** One owned selection and immutable observation of a shared query resource. */
17
+ export interface QueryResource<Args, A, E = never> {
18
+ select(this: void, args: Args | Snapshot<Args> | undefined): void;
19
+ read(this: void): AsyncResult.AsyncResult<Snapshot<A>, E>;
20
+ subscribe(this: void, listener: (result: AsyncResult.AsyncResult<Snapshot<A>, E>) => void): () => void;
21
+ refresh(this: void): void;
22
+ dispose(this: void): void;
23
+ }
16
24
  /**
17
25
  * Idempotent query reconciliation for snapshot ticks. Freshness is checked when entering a
18
26
  * selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
@@ -20,21 +28,8 @@ export interface SessionContext<R = never> {
20
28
  export declare function queryResource<Args, A, E, R>(context: {
21
29
  cache: QueryCache<R>;
22
30
  changed?: () => void;
23
- }, definition: Query<Args, A, E, NoInfer<R>>): {
24
- select(args: Args | undefined): void;
25
- read: () => AsyncResult.AsyncResult<A, E>;
26
- subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
27
- refresh: () => void;
28
- dispose: () => void;
29
- };
30
- /** Publish Effect results directly; the owner releases the subscription on disposal. */
31
- export declare function observeQuery<Args, A, E, R>(owner: DisposableOwner, cache: QueryCache<R>, definition: Query<Args, A, E, NoInfer<R>>, changed: (result: AsyncResult.AsyncResult<A, E>) => void): {
32
- select(args: Args | undefined): void;
33
- read: () => AsyncResult.AsyncResult<A, E>;
34
- subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
35
- refresh: () => void;
36
- dispose: () => void;
37
- };
31
+ }, definition: Query<Args, A, E, NoInfer<R>>): QueryResource<Args, A, E>;
32
+ export declare function observeQuery<Args, A, E, R>(owner: DisposableOwner, cache: QueryCache<R>, definition: Query<Args, A, E, NoInfer<R>>, changed: (result: AsyncResult.AsyncResult<Snapshot<A>, E>) => void): QueryResource<Args, A, E>;
38
33
  /** Application selectors run once per explicit input version, with optional prior output sharing. */
39
34
  export declare function projectionCache(): {
40
35
  invalidate: () => void;
@@ -48,7 +43,6 @@ interface OwnedSession {
48
43
  refresh?(): void;
49
44
  subscribe?(changed: () => void): () => void;
50
45
  }
51
- /** One declaration owns subscriptions, refresh order and disposal for a group of sessions. */
52
46
  export declare function sessionGroup(sessions: readonly OwnedSession[], changed: () => void): {
53
47
  refresh: () => void;
54
48
  dispose: () => void;
package/dist/session.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { runAll, reportError, reportSafely } from './errors.js';
2
+ import { encodeQueryKey } from './query.js';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import * as Atom from 'effect/unstable/reactivity/Atom';
4
- /** Own subscriptions, DOM adapters and other cleanup for one lifetime. */
5
+ import { cacheInternals } from './cache-internals.js';
5
6
  export function lifetime() {
6
7
  const cleanups = [];
7
8
  let disposed = false;
@@ -28,6 +29,7 @@ export function lifetime() {
28
29
  * selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
29
30
  */
30
31
  export function queryResource(context, definition) {
32
+ const internal = cacheInternals(context.cache);
31
33
  let key;
32
34
  let generation = -1;
33
35
  let atom;
@@ -36,8 +38,8 @@ export function queryResource(context, definition) {
36
38
  let revision = 0;
37
39
  const initial = AsyncResult.initial();
38
40
  const listeners = new Set();
39
- const read = () => atom && !disposed && generation === context.cache.registry.get(context.cache.generation)
40
- ? context.cache.registry.get(atom)
41
+ const read = () => atom && !disposed && generation === internal.registry.get(internal.generation)
42
+ ? internal.registry.get(atom)
41
43
  : initial;
42
44
  let published;
43
45
  let notifying = false;
@@ -70,7 +72,7 @@ export function queryResource(context, definition) {
70
72
  published = result;
71
73
  const selected = revision;
72
74
  // A callback may select, reset, refresh or dispose. Finish only the current publication.
73
- // oxlint-disable-next-line unicorn/no-useless-spread
75
+ // oxlint-disable-next-line unicorn/no-useless-spread -- Iterate a snapshot because listeners can add or remove subscriptions.
74
76
  for (const listener of [...listeners]) {
75
77
  if (disposed || selected !== revision || result !== read())
76
78
  break;
@@ -89,8 +91,8 @@ export function queryResource(context, definition) {
89
91
  release?.();
90
92
  };
91
93
  // Initialize before subscribing; construction must not invoke application callbacks.
92
- context.cache.registry.get(context.cache.generation);
93
- const stopGeneration = context.cache.registry.subscribe(context.cache.generation, () => {
94
+ internal.registry.get(internal.generation);
95
+ const stopGeneration = internal.registry.subscribe(internal.generation, () => {
94
96
  revision++;
95
97
  disconnect();
96
98
  atom = undefined;
@@ -102,21 +104,21 @@ export function queryResource(context, definition) {
102
104
  select(args) {
103
105
  if (disposed)
104
106
  return;
105
- const nextGeneration = context.cache.registry.get(context.cache.generation);
106
- const nextKey = args === undefined ? undefined : definition.key(args);
107
+ const nextGeneration = internal.registry.get(internal.generation);
108
+ const nextKey = args === undefined ? undefined : encodeQueryKey(args);
107
109
  if (nextKey === key && nextGeneration === generation)
108
110
  return;
109
111
  const selected = ++revision;
110
112
  disconnect();
111
113
  key = nextKey;
112
114
  generation = nextGeneration;
113
- const nextAtom = args === undefined ? undefined : context.cache.query(definition, args);
115
+ const nextAtom = args === undefined ? undefined : internal.query(definition, args);
114
116
  if (disposed || selected !== revision)
115
117
  return;
116
118
  atom = nextAtom;
117
119
  if (nextAtom) {
118
120
  // Subscription setup itself can execute a synchronous Effect and reenter selection.
119
- const release = context.cache.registry.subscribe(nextAtom, notify);
121
+ const release = internal.registry.subscribe(nextAtom, notify);
120
122
  if (disposed || selected !== revision) {
121
123
  release();
122
124
  return;
@@ -137,9 +139,9 @@ export function queryResource(context, definition) {
137
139
  refresh: () => {
138
140
  if (atom &&
139
141
  !disposed &&
140
- generation === context.cache.registry.get(context.cache.generation) &&
141
- !context.cache.registry.get(atom).waiting)
142
- context.cache.registry.refresh(atom);
142
+ generation === internal.registry.get(internal.generation) &&
143
+ !internal.registry.get(atom).waiting)
144
+ internal.registry.refresh(atom);
143
145
  },
144
146
  dispose: () => {
145
147
  if (disposed)
@@ -154,7 +156,6 @@ export function queryResource(context, definition) {
154
156
  },
155
157
  };
156
158
  }
157
- /** Publish Effect results directly; the owner releases the subscription on disposal. */
158
159
  export function observeQuery(owner, cache, definition, changed) {
159
160
  const resource = queryResource({ cache }, definition);
160
161
  resource.subscribe(changed);
@@ -180,7 +181,6 @@ export function projectionCache() {
180
181
  select,
181
182
  };
182
183
  }
183
- /** One declaration owns subscriptions, refresh order and disposal for a group of sessions. */
184
184
  export function sessionGroup(sessions, changed) {
185
185
  const scope = lifetime();
186
186
  for (const session of sessions) {
package/dist/share.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- /** Override sharing for selected fields, for example a collection keyed by domain identity. */
1
+ import type { Snapshot } from './snapshot.js';
2
+ /** Field sharers borrow both inputs; returned branches are published as readonly data. */
2
3
  export type ShareFields<T> = {
3
- readonly [K in keyof T]?: (previous: T[K], next: T[K]) => T[K];
4
+ readonly [K in keyof T]?: (previous: Snapshot<T[K]>, next: Snapshot<T[K]>) => T[K] | Snapshot<T[K]>;
4
5
  };
5
- /** Share JSON-shaped data; Blob, typed arrays and class instances retain their own identity. */
6
- export declare function shareValue<T>(previous: T, next: T, fields?: ShareFields<T>): T;
6
+ /** Borrow shared immutable data. The result may retain either input and its nested branches. */
7
+ export declare function shareValue<T>(previous: T | Snapshot<T>, next: T | Snapshot<T>, fields?: ShareFields<T>): Snapshot<T>;
package/dist/share.js CHANGED
@@ -1,46 +1,7 @@
1
- // Immutable snapshots can reach the renderer through several projections. Reuse an
2
- // already compared object pair without retaining either snapshot after its owners release it.
3
- const sharedPairs = new WeakMap();
4
- /** Share JSON-shaped data; Blob, typed arrays and class instances retain their own identity. */
1
+ import { shareData } from './sharing.js';
2
+ /** Borrow shared immutable data. The result may retain either input and its nested branches. */
5
3
  export function shareValue(previous, next, fields) {
6
- if (Object.is(previous, next))
7
- return previous;
8
- if (!previous || !next || typeof previous !== 'object' || typeof next !== 'object')
9
- return next;
10
- const array = Array.isArray(next);
11
- if (array !== Array.isArray(previous))
12
- return next;
13
- if (!array &&
14
- (Object.getPrototypeOf(next) !== Object.prototype ||
15
- Object.getPrototypeOf(previous) !== Object.prototype))
16
- return next;
17
- let pairs = sharedPairs.get(previous);
18
- if (!fields && pairs?.has(next))
19
- return pairs.get(next);
20
- const old = previous;
21
- const value = next;
22
- const keys = Object.keys(value);
23
- let equal = keys.length === Object.keys(old).length;
24
- let unchangedNext = true;
25
- const shared = array ? [] : {};
26
- for (const key of keys) {
27
- const custom = fields && Object.hasOwn(fields, key) ? fields[key] : undefined;
28
- shared[key] =
29
- custom && Object.hasOwn(old, key)
30
- ? custom(old[key], value[key])
31
- : shareValue(old[key], value[key]);
32
- if (!Object.hasOwn(old, key) || shared[key] !== old[key])
33
- equal = false;
34
- if (shared[key] !== value[key])
35
- unchangedNext = false;
36
- }
37
- const result = equal ? previous : unchangedNext ? next : shared;
38
- if (fields)
39
- return result;
40
- if (!pairs) {
41
- pairs = new WeakMap();
42
- sharedPairs.set(previous, pairs);
43
- }
44
- pairs.set(next, result);
45
- return result;
4
+ // Snapshot changes access permissions, not representation. The private algorithm only
5
+ // borrows data; this boundary never exposes mutable aliases to its retained branches.
6
+ return shareData(previous, next, fields);
46
7
  }
@@ -0,0 +1,6 @@
1
+ /** Override sharing for selected fields, for example a collection keyed by domain identity. */
2
+ export type ShareFields<T> = {
3
+ readonly [K in keyof T]?: (previous: T[K], next: T[K]) => T[K];
4
+ };
5
+ /** Share immutable plain data; opaque objects, accessors, hidden fields and cycles retain next. */
6
+ export declare function shareData<T>(previous: T, next: T, fields?: ShareFields<T>): T;
@@ -0,0 +1,93 @@
1
+ // Internal representation-level sharing. Public callers borrow through share.ts.
2
+ // Immutable snapshots can reach the renderer through several projections. Reuse an
3
+ // already compared object pair without retaining either snapshot after its owners release it.
4
+ const sharedPairs = new WeakMap();
5
+ const cyclicData = new WeakMap();
6
+ function cyclic(value, active) {
7
+ const cached = cyclicData.get(value);
8
+ if (cached !== undefined)
9
+ return cached;
10
+ const prototype = Object.getPrototypeOf(value);
11
+ if (!Array.isArray(value) && prototype !== Object.prototype)
12
+ return false;
13
+ if (active.has(value))
14
+ return true;
15
+ active.add(value);
16
+ const found = Reflect.ownKeys(value).some((key) => {
17
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
18
+ const child = descriptor.value;
19
+ return child !== null && typeof child === 'object' && cyclic(child, active);
20
+ });
21
+ active.delete(value);
22
+ cyclicData.set(value, found);
23
+ return found;
24
+ }
25
+ /** Share immutable plain data; opaque objects, accessors, hidden fields and cycles retain next. */
26
+ export function shareData(previous, next, fields) {
27
+ if (Object.is(previous, next))
28
+ return previous;
29
+ // Check the entire next graph, including new branches with no previous counterpart.
30
+ // Otherwise a partial clone could keep a back-reference to the unshared root.
31
+ if (next && typeof next === 'object' && cyclic(next, new Set()))
32
+ return next;
33
+ return share(previous, next, fields);
34
+ }
35
+ function share(previous, next, fields) {
36
+ if (Object.is(previous, next))
37
+ return previous;
38
+ if (!previous || !next || typeof previous !== 'object' || typeof next !== 'object')
39
+ return next;
40
+ const array = Array.isArray(next);
41
+ if (array !== Array.isArray(previous))
42
+ return next;
43
+ if (!array &&
44
+ (Object.getPrototypeOf(next) !== Object.prototype ||
45
+ Object.getPrototypeOf(previous) !== Object.prototype))
46
+ return next;
47
+ let pairs = sharedPairs.get(previous);
48
+ if (!fields && pairs?.has(next))
49
+ return pairs.get(next);
50
+ const old = previous;
51
+ const value = next;
52
+ const keys = Object.keys(value);
53
+ const oldKeys = Object.keys(old);
54
+ // Symbol and nonenumerable fields are outside the plain-data sharing contract.
55
+ // Returning next preserves them instead of accidentally claiming value equality.
56
+ const extra = array ? 1 : 0; // Array length is nonenumerable.
57
+ if (Reflect.ownKeys(next).length !== keys.length + extra ||
58
+ Reflect.ownKeys(previous).length !== oldKeys.length + extra ||
59
+ keys.some((key) => !Object.hasOwn(Object.getOwnPropertyDescriptor(next, key), 'value')) ||
60
+ oldKeys.some((key) => !Object.hasOwn(Object.getOwnPropertyDescriptor(previous, key), 'value')))
61
+ return next;
62
+ let equal = keys.length === oldKeys.length && (!array || value.length === old.length);
63
+ let unchangedNext = true;
64
+ const shared = array ? [] : {};
65
+ if (array)
66
+ shared.length = value.length;
67
+ for (const key of keys) {
68
+ const custom = fields && Object.hasOwn(fields, key) ? fields[key] : undefined;
69
+ const item = custom && Object.hasOwn(old, key)
70
+ ? custom(old[key], value[key])
71
+ : share(Object.hasOwn(old, key) ? old[key] : undefined, value[key], undefined);
72
+ // Define an own data property, including __proto__, without invoking setters.
73
+ Object.defineProperty(shared, key, {
74
+ value: item,
75
+ writable: true,
76
+ enumerable: true,
77
+ configurable: true,
78
+ });
79
+ if (!Object.hasOwn(old, key) || !Object.is(item, old[key]))
80
+ equal = false;
81
+ if (!Object.is(item, value[key]))
82
+ unchangedNext = false;
83
+ }
84
+ const result = equal ? previous : unchangedNext ? next : shared;
85
+ if (fields)
86
+ return result;
87
+ if (!pairs) {
88
+ pairs = new WeakMap();
89
+ sharedPairs.set(previous, pairs);
90
+ }
91
+ pairs.set(next, result);
92
+ return result;
93
+ }
@@ -1,5 +1,16 @@
1
- /** Published fields cannot be reassigned. Nested domain types should also declare readonly data. */
2
- export type Snapshot<T> = Readonly<T>;
3
- export declare const checkSnapshotsByDefault: boolean;
4
- /** Development guard for immutable plain data. Opaque objects keep their own lifecycle. */
5
- export declare function protectSnapshot<T>(value: T, enabled: boolean): T;
1
+ import type { Effect } from 'effect';
2
+ import type * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
+ /** Mark an application service as opaque to snapshot typing; this is a type-only brand. */
4
+ export declare const snapshotOpaque: unique symbol;
5
+ export interface SnapshotOpaque {
6
+ readonly [snapshotOpaque]?: true;
7
+ }
8
+ /** Recursively immutable published data. Effects, functions and external resources retain their API. */
9
+ export type Snapshot<T> = ImmutableValue<T>;
10
+ type SnapshotRecord<T> = {
11
+ readonly [K in keyof T]: ImmutableValue<T[K]>;
12
+ };
13
+ type ImmutableValue<T> = T extends string | number | bigint | boolean | symbol | null | undefined ? T : T extends (...args: never[]) => unknown ? T : typeof snapshotOpaque extends keyof T ? symbol extends keyof T ? SnapshotRecord<T> : T : T extends AsyncResult.AsyncResult<infer A, infer E> ? AsyncResult.With<T, ImmutableValue<A>, E> : T extends Effect.Effect<unknown, unknown, unknown> | Date | RegExp | Error | PromiseLike<unknown> | Node | EventTarget | Blob | ArrayBuffer | ArrayBufferView ? T : T extends ReadonlyMap<infer K, infer V> ? ReadonlyMap<ImmutableValue<K>, ImmutableValue<V>> : T extends ReadonlySet<infer A> ? ReadonlySet<ImmutableValue<A>> : T extends object ? SnapshotRecord<T> : T;
14
+ /** Protect published plain data in every build. Opaque objects keep their own lifecycle. */
15
+ export declare function protectSnapshot<T>(value: T): T;
16
+ export {};
package/dist/snapshot.js CHANGED
@@ -1,17 +1,16 @@
1
1
  import { isAsyncResult } from 'effect/unstable/reactivity/AsyncResult';
2
- export const checkSnapshotsByDefault = typeof __EFFECTWEB_DEV__ !== 'undefined' && __EFFECTWEB_DEV__;
3
2
  const checked = new WeakSet();
4
- /** Development guard for immutable plain data. Opaque objects keep their own lifecycle. */
5
- export function protectSnapshot(value, enabled) {
6
- if (!enabled || !value || typeof value !== 'object' || checked.has(value))
3
+ /** Protect published plain data in every build. Opaque objects keep their own lifecycle. */
4
+ export function protectSnapshot(value) {
5
+ if (!value || typeof value !== 'object' || checked.has(value))
7
6
  return value;
8
7
  // AsyncResult is an immutable Effect wrapper, but its successful UI data is plain data.
9
8
  if (isAsyncResult(value)) {
10
9
  checked.add(value);
11
10
  if (value._tag === 'Success')
12
- protectSnapshot(value.value, true);
11
+ protectSnapshot(value.value);
13
12
  else if (value._tag === 'Failure' && value.previousSuccess._tag === 'Some')
14
- protectSnapshot(value.previousSuccess.value, true);
13
+ protectSnapshot(value.previousSuccess.value);
15
14
  return value;
16
15
  }
17
16
  const prototype = Object.getPrototypeOf(value);
@@ -24,7 +23,7 @@ export function protectSnapshot(value, enabled) {
24
23
  const descriptor = Reflect.get(descriptors, key);
25
24
  // Never execute getters or traverse closures, class instances, Effects or DOM objects.
26
25
  if ('value' in descriptor)
27
- protectSnapshot(descriptor.value, true);
26
+ protectSnapshot(descriptor.value);
28
27
  }
29
28
  Object.freeze(value);
30
29
  return value;
package/dist/state.d.ts CHANGED
@@ -1,2 +1 @@
1
- /** Shallow immutable update; unchanged fields preserve identity and skip publication. */
2
1
  export declare function patchModel<Model extends object>(model: Model, patch: Partial<NoInfer<Model>>): Model;
package/dist/state.js CHANGED
@@ -1,4 +1,3 @@
1
- /** Shallow immutable update; unchanged fields preserve identity and skip publication. */
2
1
  export function patchModel(model, patch) {
3
2
  return Reflect.ownKeys(patch).some((key) => Object.prototype.propertyIsEnumerable.call(patch, key) &&
4
3
  (!Object.hasOwn(model, key) || !Object.is(model[key], patch[key])))
@@ -0,0 +1,12 @@
1
+ /** SVG content and presentation attributes, including Filter Effects and SMIL.
2
+ * https://svgwg.org/svg2-draft/attindex.html
3
+ * Browser spellings are preserved: animated IDL objects are never assigned as attributes.
4
+ */
5
+ export type SvgAttributes = Partial<Record<'accumulate' | 'additive' | 'alignment-baseline' | 'amplitude' | 'attributeName' | 'attributeType' | 'azimuth' | 'baseFrequency' | 'baseline-shift' | 'begin' | 'bias' | 'by' | 'calcMode' | 'clip' | 'clip-path' | 'clip-rule' | 'clipPathUnits' | 'color' | 'color-interpolation' | 'color-interpolation-filters' | 'crossorigin' | 'cursor' | 'cx' | 'cy' | 'd' | 'diffuseConstant' | 'direction' | 'display' | 'divisor' | 'dominant-baseline' | 'dur' | 'dx' | 'dy' | 'edgeMode' | 'elevation' | 'end' | 'exponent' | 'fill' | 'fill-opacity' | 'fill-rule' | 'filter' | 'filterUnits' | 'flood-color' | 'flood-opacity' | 'font-family' | 'font-size' | 'font-size-adjust' | 'font-stretch' | 'font-style' | 'font-variant' | 'font-weight' | 'fr' | 'from' | 'fx' | 'fy' | 'glyph-orientation-horizontal' | 'glyph-orientation-vertical' | 'gradientTransform' | 'gradientUnits' | 'height' | 'href' | 'hreflang' | 'image-rendering' | 'in' | 'in2' | 'intercept' | 'k1' | 'k2' | 'k3' | 'k4' | 'kernelMatrix' | 'kernelUnitLength' | 'keyPoints' | 'keySplines' | 'keyTimes' | 'lang' | 'lengthAdjust' | 'letter-spacing' | 'lighting-color' | 'limitingConeAngle' | 'marker-end' | 'marker-mid' | 'marker-start' | 'markerHeight' | 'markerUnits' | 'markerWidth' | 'mask' | 'mask-type' | 'maskContentUnits' | 'maskUnits' | 'max' | 'media' | 'method' | 'min' | 'mode' | 'numOctaves' | 'offset' | 'opacity' | 'operator' | 'order' | 'orient' | 'origin' | 'overflow' | 'paint-order' | 'path' | 'pathLength' | 'patternContentUnits' | 'patternTransform' | 'patternUnits' | 'ping' | 'pointer-events' | 'points' | 'pointsAtX' | 'pointsAtY' | 'pointsAtZ' | 'preserveAspectRatio' | 'primitiveUnits' | 'r' | 'radius' | 'refX' | 'refY' | 'repeatCount' | 'repeatDur' | 'requiredExtensions' | 'restart' | 'result' | 'rotate' | 'rx' | 'ry' | 'scale' | 'seed' | 'shape-rendering' | 'side' | 'slope' | 'spacing' | 'specularConstant' | 'specularExponent' | 'spreadMethod' | 'startOffset' | 'stdDeviation' | 'stitchTiles' | 'stop-color' | 'stop-opacity' | 'stroke' | 'stroke-dasharray' | 'stroke-dashoffset' | 'stroke-linecap' | 'stroke-linejoin' | 'stroke-miterlimit' | 'stroke-opacity' | 'stroke-width' | 'surfaceScale' | 'systemLanguage' | 'tableValues' | 'target' | 'targetX' | 'targetY' | 'text-anchor' | 'text-decoration' | 'text-rendering' | 'textLength' | 'to' | 'transform' | 'transform-origin' | 'type' | 'unicode-bidi' | 'values' | 'vector-effect' | 'viewBox' | 'visibility' | 'width' | 'word-spacing' | 'writing-mode' | 'x' | 'x1' | 'x2' | 'xChannelSelector' | 'xmlns' | 'y' | 'y1' | 'y2' | 'yChannelSelector' | 'z', string | number | undefined>> & {
6
+ /** SVG uses an enumerated string here, not an HTML presence attribute. */
7
+ preserveAlpha?: 'true' | 'false' | undefined;
8
+ };
9
+ /** Presentation attributes shared by SVG tags whose names also exist in HTML.
10
+ * https://svgwg.org/svg2-draft/styling.html#PresentationAttributes
11
+ */
12
+ export type SvgPresentationAttributes = Pick<SvgAttributes, 'alignment-baseline' | 'baseline-shift' | 'clip' | 'clip-path' | 'clip-rule' | 'color' | 'color-interpolation' | 'color-interpolation-filters' | 'cursor' | 'direction' | 'display' | 'dominant-baseline' | 'fill' | 'fill-opacity' | 'fill-rule' | 'filter' | 'flood-color' | 'flood-opacity' | 'font-family' | 'font-size' | 'font-size-adjust' | 'font-stretch' | 'font-style' | 'font-variant' | 'font-weight' | 'glyph-orientation-horizontal' | 'glyph-orientation-vertical' | 'image-rendering' | 'letter-spacing' | 'lighting-color' | 'marker-end' | 'marker-mid' | 'marker-start' | 'mask' | 'mask-type' | 'opacity' | 'overflow' | 'paint-order' | 'pointer-events' | 'shape-rendering' | 'stop-color' | 'stop-opacity' | 'stroke' | 'stroke-dasharray' | 'stroke-dashoffset' | 'stroke-linecap' | 'stroke-linejoin' | 'stroke-miterlimit' | 'stroke-opacity' | 'stroke-width' | 'text-anchor' | 'text-decoration' | 'text-rendering' | 'transform' | 'transform-origin' | 'unicode-bidi' | 'vector-effect' | 'visibility' | 'word-spacing' | 'writing-mode'>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/task.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import type { Snapshot } from './snapshot.js';
1
2
  import { Effect } from 'effect';
2
3
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
4
  import type { View } from './dom.js';
4
- import { type Send } from './program.js';
5
+ import { type Send, type TaskPolicy } from './program.js';
5
6
  import { type UiRuntime } from './runtime.js';
6
7
  export type TaskModel<Props, State, A, E = unknown> = State & {
7
8
  readonly props: Props;
@@ -12,7 +13,7 @@ export type TaskMessage<State, Input> = {
12
13
  input: Input;
13
14
  } | {
14
15
  type: 'Fields';
15
- fields: Partial<State> & {
16
+ fields: (Partial<State> | Partial<Snapshot<State>>) & {
16
17
  props?: never;
17
18
  task?: never;
18
19
  };
@@ -25,16 +26,16 @@ export type TaskMessage<State, Input> = {
25
26
  * defineTasks(...).tasks(...).view(view(...)) to establish inference before JSX.
26
27
  */
27
28
  export declare function taskComponent<Props, State extends object, Input, A, E = unknown, R = never>(definition: {
28
- init: (props: Props) => State & {
29
+ init: (props: Snapshot<Props>) => (State | Snapshot<State>) & {
29
30
  props?: never;
30
31
  task?: never;
31
32
  };
32
- identity?: (props: Props) => unknown;
33
+ identity?: (props: Snapshot<Props>) => unknown;
33
34
  task: {
34
- policy: 'drop' | 'replace';
35
- run: (model: State & {
35
+ policy: Exclude<TaskPolicy, 'parallel'>;
36
+ run: (model: Snapshot<State & {
36
37
  readonly props: Props;
37
- }, input: Input) => Effect.Effect<A, E, R>;
38
+ }>, input: Input) => Effect.Effect<A, E, R>;
38
39
  };
39
40
  view: View<TaskModel<Props, State, A, E>, TaskMessage<State, Input>>;
40
41
  } & ([R] extends [never] ? {
@@ -45,7 +46,7 @@ export declare function taskComponent<Props, State extends object, Input, A, E =
45
46
  /** Stable methods when bound once in a compiled view's const declaration. */
46
47
  export declare function taskControls<State, Input>(send: Send<TaskMessage<State, Input>>): {
47
48
  run: (input: Input) => void;
48
- patch: (fields: Partial<State> & {
49
+ patch: (fields: (Partial<State> | Partial<Snapshot<State>>) & {
49
50
  props?: never;
50
51
  task?: never;
51
52
  }) => void;