effectweb 0.2.3 → 0.3.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.
Files changed (77) hide show
  1. package/README.md +93 -1
  2. package/dist/AsyncContent.d.ts +2 -2
  3. package/dist/AsyncContent.js +7 -4
  4. package/dist/actions.d.ts +4 -3
  5. package/dist/actions.js +1 -0
  6. package/dist/boundary.d.ts +11 -0
  7. package/dist/boundary.js +66 -0
  8. package/dist/cache-internals.d.ts +18 -0
  9. package/dist/cache-internals.js +11 -0
  10. package/dist/cache.d.ts +35 -16
  11. package/dist/cache.js +258 -37
  12. package/dist/collection.d.ts +10 -9
  13. package/dist/collection.js +32 -6
  14. package/dist/component.d.ts +11 -7
  15. package/dist/component.js +70 -10
  16. package/dist/diagnostics.d.ts +25 -0
  17. package/dist/diagnostics.js +167 -1
  18. package/dist/dom-attributes.json +71 -0
  19. package/dist/dom.d.ts +44 -15
  20. package/dist/dom.js +493 -89
  21. package/dist/effectEvent.d.ts +2 -1
  22. package/dist/effectEvent.js +4 -1
  23. package/dist/form.d.ts +61 -0
  24. package/dist/form.js +98 -0
  25. package/dist/html-attributes.d.ts +602 -0
  26. package/dist/html-attributes.js +1 -0
  27. package/dist/http.d.ts +5 -0
  28. package/dist/http.js +7 -0
  29. package/dist/index.d.ts +16 -11
  30. package/dist/index.js +11 -6
  31. package/dist/infinite-query.d.ts +42 -0
  32. package/dist/infinite-query.js +150 -0
  33. package/dist/intrinsic.d.ts +2 -0
  34. package/dist/intrinsic.js +120 -0
  35. package/dist/jsx.d.ts +165 -33
  36. package/dist/jsx.js +2 -1
  37. package/dist/keyed-tasks.d.ts +29 -0
  38. package/dist/keyed-tasks.js +97 -0
  39. package/dist/lazy.d.ts +23 -0
  40. package/dist/lazy.js +66 -0
  41. package/dist/load.d.ts +3 -3
  42. package/dist/mount.d.ts +11 -12
  43. package/dist/mount.js +30 -14
  44. package/dist/owner.d.ts +14 -10
  45. package/dist/owner.js +66 -39
  46. package/dist/pages.d.ts +28 -26
  47. package/dist/pages.js +19 -11
  48. package/dist/program.d.ts +29 -9
  49. package/dist/program.js +204 -61
  50. package/dist/query-internals.d.ts +13 -0
  51. package/dist/query-internals.js +10 -0
  52. package/dist/query.d.ts +30 -19
  53. package/dist/query.js +64 -5
  54. package/dist/resource.d.ts +4 -10
  55. package/dist/resource.js +14 -5
  56. package/dist/runtime.d.ts +1 -2
  57. package/dist/runtime.js +3 -3
  58. package/dist/session.d.ts +12 -15
  59. package/dist/session.js +25 -13
  60. package/dist/settlement.d.ts +7 -0
  61. package/dist/settlement.js +27 -0
  62. package/dist/share.d.ts +5 -4
  63. package/dist/share.js +5 -44
  64. package/dist/sharing.d.ts +6 -0
  65. package/dist/sharing.js +96 -0
  66. package/dist/slotIdentity.d.ts +1 -0
  67. package/dist/slotIdentity.js +2 -0
  68. package/dist/snapshot.d.ts +16 -5
  69. package/dist/snapshot.js +34 -27
  70. package/dist/svg-attributes.d.ts +12 -0
  71. package/dist/svg-attributes.js +1 -0
  72. package/dist/task.d.ts +9 -8
  73. package/dist/task.js +75 -47
  74. package/dist/tasks.d.ts +20 -15
  75. package/dist/tasks.js +40 -25
  76. package/dist/testing.d.ts +10 -14
  77. package/package.json +13 -1
@@ -1,4 +1,4 @@
1
- import { Cause } from 'effect';
1
+ import type { Snapshot } from './snapshot.js';
2
2
  import * as AsyncResult from 'effect/unstable/reactivity/AsyncResult';
3
3
  import type { UiLoad } from './load.js';
4
4
  import { type UiRuntime } from './runtime.js';
@@ -8,25 +8,19 @@ export interface ResourceModel<Props, A, E = unknown> {
8
8
  readonly key: string | undefined;
9
9
  readonly result: AsyncResult.AsyncResult<A, E>;
10
10
  }
11
- export type ResourceMessage<A, E = unknown> = {
11
+ export type ResourceMessage = {
12
12
  type: 'Retry';
13
- } | {
14
- type: 'Loaded';
15
- value: A;
16
- } | {
17
- type: 'Failed';
18
- cause: Cause.Cause<E>;
19
13
  };
20
14
  export declare const available: <A>(result: AsyncResult.AsyncResult<A, unknown>) => A | undefined;
21
15
  export declare const resourceError: (result: AsyncResult.AsyncResult<unknown, unknown>) => string;
22
16
  /** A replaceable request owns one result. Key changes clear it; refreshes preserve the last success. */
23
17
  export declare function resourceComponent<Props, A, E = unknown, R = never>(definition: {
24
- request: (props: Props) => {
18
+ request: (props: Snapshot<Props>) => {
25
19
  key: string;
26
20
  load: () => UiLoad<A, E, R>;
27
21
  delay?: number;
28
22
  } | undefined;
29
- view: View<ResourceModel<Props, A, E>, ResourceMessage<A, E>>;
23
+ view: View<ResourceModel<Props, A, E>, ResourceMessage>;
30
24
  } & ([R] extends [never] ? {
31
25
  runtime?: UiRuntime<R>;
32
26
  } : {
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,4 +1,5 @@
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';
@@ -12,6 +13,14 @@ export interface SessionContext<R = never> {
12
13
  readonly cache: QueryCache<R>;
13
14
  readonly changed: () => void;
14
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
+ }
15
24
  /**
16
25
  * Idempotent query reconciliation for snapshot ticks. Freshness is checked when entering a
17
26
  * selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
@@ -19,20 +28,8 @@ export interface SessionContext<R = never> {
19
28
  export declare function queryResource<Args, A, E, R>(context: {
20
29
  cache: QueryCache<R>;
21
30
  changed?: () => void;
22
- }, definition: Query<Args, A, E, NoInfer<R>>): {
23
- select(args: Args | undefined): void;
24
- read: () => AsyncResult.AsyncResult<A, E>;
25
- subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
26
- refresh: () => void;
27
- dispose: () => void;
28
- };
29
- 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): {
30
- select(args: Args | undefined): void;
31
- read: () => AsyncResult.AsyncResult<A, E>;
32
- subscribe(listener: (result: AsyncResult.AsyncResult<A, E>) => void): () => void;
33
- refresh: () => void;
34
- dispose: () => void;
35
- };
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>;
36
33
  /** Application selectors run once per explicit input version, with optional prior output sharing. */
37
34
  export declare function projectionCache(): {
38
35
  invalidate: () => void;
package/dist/session.js CHANGED
@@ -1,6 +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';
5
+ import { cacheInternals } from './cache-internals.js';
4
6
  export function lifetime() {
5
7
  const cleanups = [];
6
8
  let disposed = false;
@@ -27,6 +29,7 @@ export function lifetime() {
27
29
  * selection (including remount), not on every same-key tick; refresh revalidates unless a request is already in flight.
28
30
  */
29
31
  export function queryResource(context, definition) {
32
+ const internal = cacheInternals(context.cache);
30
33
  let key;
31
34
  let generation = -1;
32
35
  let atom;
@@ -35,8 +38,11 @@ export function queryResource(context, definition) {
35
38
  let revision = 0;
36
39
  const initial = AsyncResult.initial();
37
40
  const listeners = new Set();
38
- const read = () => atom && !disposed && generation === context.cache.registry.get(context.cache.generation)
39
- ? context.cache.registry.get(atom)
41
+ const read = () => atom &&
42
+ !disposed &&
43
+ !internal.disposed() &&
44
+ generation === internal.registry.get(internal.generation)
45
+ ? internal.registry.get(atom)
40
46
  : initial;
41
47
  let published;
42
48
  let notifying = false;
@@ -69,7 +75,7 @@ export function queryResource(context, definition) {
69
75
  published = result;
70
76
  const selected = revision;
71
77
  // A callback may select, reset, refresh or dispose. Finish only the current publication.
72
- // oxlint-disable-next-line unicorn/no-useless-spread
78
+ // oxlint-disable-next-line unicorn/no-useless-spread -- Iterate a snapshot because listeners can add or remove subscriptions.
73
79
  for (const listener of [...listeners]) {
74
80
  if (disposed || selected !== revision || result !== read())
75
81
  break;
@@ -88,8 +94,8 @@ export function queryResource(context, definition) {
88
94
  release?.();
89
95
  };
90
96
  // Initialize before subscribing; construction must not invoke application callbacks.
91
- context.cache.registry.get(context.cache.generation);
92
- const stopGeneration = context.cache.registry.subscribe(context.cache.generation, () => {
97
+ internal.registry.get(internal.generation);
98
+ const stopGeneration = internal.registry.subscribe(internal.generation, () => {
93
99
  revision++;
94
100
  disconnect();
95
101
  atom = undefined;
@@ -99,23 +105,28 @@ export function queryResource(context, definition) {
99
105
  });
100
106
  return {
101
107
  select(args) {
102
- if (disposed)
108
+ if (disposed || internal.disposed())
103
109
  return;
104
- const nextGeneration = context.cache.registry.get(context.cache.generation);
105
- const nextKey = args === undefined ? undefined : definition.key(args);
110
+ const nextGeneration = internal.registry.get(internal.generation);
111
+ const nextKey = args === undefined ? undefined : encodeQueryKey(args);
106
112
  if (nextKey === key && nextGeneration === generation)
107
113
  return;
108
114
  const selected = ++revision;
109
115
  disconnect();
110
116
  key = nextKey;
111
117
  generation = nextGeneration;
112
- const nextAtom = args === undefined ? undefined : context.cache.query(definition, args);
118
+ const nextAtom = args === undefined ? undefined : internal.query(definition, args);
113
119
  if (disposed || selected !== revision)
114
120
  return;
115
121
  atom = nextAtom;
116
122
  if (nextAtom) {
117
123
  // Subscription setup itself can execute a synchronous Effect and reenter selection.
118
- const release = context.cache.registry.subscribe(nextAtom, notify);
124
+ const releaseUse = internal.retain(nextAtom);
125
+ const unsubscribe = internal.registry.subscribe(nextAtom, notify);
126
+ const release = () => {
127
+ unsubscribe();
128
+ releaseUse();
129
+ };
119
130
  if (disposed || selected !== revision) {
120
131
  release();
121
132
  return;
@@ -136,9 +147,10 @@ export function queryResource(context, definition) {
136
147
  refresh: () => {
137
148
  if (atom &&
138
149
  !disposed &&
139
- generation === context.cache.registry.get(context.cache.generation) &&
140
- !context.cache.registry.get(atom).waiting)
141
- context.cache.registry.refresh(atom);
150
+ !internal.disposed() &&
151
+ generation === internal.registry.get(internal.generation) &&
152
+ !internal.registry.get(atom).waiting)
153
+ internal.refresh(atom);
142
154
  },
143
155
  dispose: () => {
144
156
  if (disposed)
@@ -0,0 +1,7 @@
1
+ /** Internal completion accounting, including reentrant acquisition before a fiber is available. */
2
+ export declare class Settlement {
3
+ private pending;
4
+ private waiters;
5
+ begin(): () => void;
6
+ wait(): Promise<void>;
7
+ }
@@ -0,0 +1,27 @@
1
+ /** Internal completion accounting, including reentrant acquisition before a fiber is available. */
2
+ export class Settlement {
3
+ pending = 0;
4
+ waiters = [];
5
+ begin() {
6
+ this.pending++;
7
+ let finished = false;
8
+ return () => {
9
+ if (finished)
10
+ return;
11
+ finished = true;
12
+ if (--this.pending)
13
+ return;
14
+ for (const done of this.waiters)
15
+ done();
16
+ this.waiters.length = 0;
17
+ };
18
+ }
19
+ wait() {
20
+ return new Promise((done) => {
21
+ if (this.pending)
22
+ this.waiters.push(done);
23
+ else
24
+ done();
25
+ });
26
+ }
27
+ }
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,96 @@
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 changed;
64
+ for (const key of keys) {
65
+ const custom = fields && Object.hasOwn(fields, key) ? fields[key] : undefined;
66
+ const item = custom && Object.hasOwn(old, key)
67
+ ? custom(old[key], value[key])
68
+ : share(Object.hasOwn(old, key) ? old[key] : undefined, value[key], undefined);
69
+ if (!Object.hasOwn(old, key) || !Object.is(item, old[key]))
70
+ equal = false;
71
+ if (!Object.is(item, value[key]))
72
+ (changed ??= new Map()).set(key, item);
73
+ }
74
+ let result = equal ? previous : next;
75
+ if (!equal && changed) {
76
+ const shared = array ? [] : {};
77
+ if (array)
78
+ shared.length = value.length;
79
+ for (const key of keys)
80
+ Object.defineProperty(shared, key, {
81
+ value: changed.has(key) ? changed.get(key) : value[key],
82
+ writable: true,
83
+ enumerable: true,
84
+ configurable: true,
85
+ });
86
+ result = shared;
87
+ }
88
+ if (fields)
89
+ return result;
90
+ if (!pairs) {
91
+ pairs = new WeakMap();
92
+ sharedPairs.set(previous, pairs);
93
+ }
94
+ pairs.set(next, result);
95
+ return result;
96
+ }
@@ -0,0 +1 @@
1
+ export declare const compiledSlots: WeakSet<object>;
@@ -0,0 +1,2 @@
1
+ // Shared only by compiler runtime helpers; user functions cannot acquire this proof.
2
+ export const compiledSlots = new WeakSet();
@@ -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,31 +1,38 @@
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))
7
- return value;
8
- // AsyncResult is an immutable Effect wrapper, but its successful UI data is plain data.
9
- if (isAsyncResult(value)) {
10
- checked.add(value);
11
- if (value._tag === 'Success')
12
- protectSnapshot(value.value, true);
13
- else if (value._tag === 'Failure' && value.previousSuccess._tag === 'Some')
14
- protectSnapshot(value.previousSuccess.value, true);
15
- return value;
16
- }
17
- const prototype = Object.getPrototypeOf(value);
18
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
19
- return value;
20
- // Mark before walking to allow cyclic data and reuse already checked shared branches.
21
- checked.add(value);
22
- const descriptors = Object.getOwnPropertyDescriptors(value);
23
- for (const key of Reflect.ownKeys(descriptors)) {
24
- const descriptor = Reflect.get(descriptors, key);
25
- // Never execute getters or traverse closures, class instances, Effects or DOM objects.
26
- if ('value' in descriptor)
27
- protectSnapshot(descriptor.value, true);
28
- }
29
- Object.freeze(value);
3
+ /** Protect published plain data in every build. Opaque objects keep their own lifecycle. */
4
+ export function protectSnapshot(value) {
5
+ const seen = new Set();
6
+ const plain = [];
7
+ const validate = (item) => {
8
+ if (!item || typeof item !== 'object' || checked.has(item) || seen.has(item))
9
+ return;
10
+ seen.add(item);
11
+ // Effect wrappers remain opaque, but their successful UI data is plain data.
12
+ if (isAsyncResult(item)) {
13
+ if (item._tag === 'Success')
14
+ validate(item.value);
15
+ else if (item._tag === 'Failure' && item.previousSuccess._tag === 'Some')
16
+ validate(item.previousSuccess.value);
17
+ return;
18
+ }
19
+ const prototype = Object.getPrototypeOf(item);
20
+ if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
21
+ return;
22
+ for (const key of Reflect.ownKeys(item)) {
23
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
24
+ if (!Object.hasOwn(descriptor, 'value'))
25
+ throw new Error(`Snapshot data cannot contain an accessor (${String(key)}). Materialize its value before publication; keep services in opaque instances.`);
26
+ validate(descriptor.value);
27
+ }
28
+ plain.push(item);
29
+ };
30
+ // Validate the complete graph before caching any proof. A rejected cyclic graph
31
+ // must not make a later publication skip validation through a previously seen child.
32
+ validate(value);
33
+ for (const item of plain)
34
+ Object.freeze(item);
35
+ for (const item of seen)
36
+ checked.add(item);
30
37
  return value;
31
38
  }
@@ -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;