@playfast/reform 0.0.5 → 0.0.8

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 (47) hide show
  1. package/package.json +31 -10
  2. package/src/boundary/boundary.test.ts +301 -0
  3. package/src/calc/asyncCalc.test.ts +556 -0
  4. package/src/calc/asyncData.ts +9 -1
  5. package/src/calc/calc.test.ts +287 -0
  6. package/src/calc/calcFamily.test.ts +206 -0
  7. package/src/calc/compose.test.ts +68 -0
  8. package/src/channel/channel.ts +9 -5
  9. package/src/compose/host.ts +4 -1
  10. package/src/compose/props.ts +5 -1
  11. package/src/compose/ui.test.ts +62 -0
  12. package/src/compose/ui.ts +173 -44
  13. package/src/compose/ui.typecheck.ts +56 -0
  14. package/src/definition/definition.ts +2 -0
  15. package/src/event/event.test.ts +23 -0
  16. package/src/feature/feature.mount.test.ts +82 -0
  17. package/src/feature/feature.test.ts +60 -0
  18. package/src/feature/feature.typecheck.ts +108 -0
  19. package/src/index.ts +38 -2
  20. package/src/internal/capture.ts +4 -4
  21. package/src/internal/errors.test.ts +33 -0
  22. package/src/internal/errors.ts +92 -16
  23. package/src/internal/inspect.test.ts +28 -0
  24. package/src/internal/queryDriver.ts +1 -1
  25. package/src/internal/reuse.test.ts +116 -0
  26. package/src/internal/scheduler.ts +3 -1
  27. package/src/internal/sources.ts +2 -2
  28. package/src/internal/stateRegistry.ts +32 -0
  29. package/src/internal/store.test.ts +80 -0
  30. package/src/internal/track.ts +3 -1
  31. package/src/remote/remoteState.test.ts +695 -0
  32. package/src/remote/remoteState.typecheck.ts +195 -0
  33. package/src/runtime/bus.ts +6 -2
  34. package/src/runtime/hardening.test.ts +69 -0
  35. package/src/runtime/loop.test.ts +178 -0
  36. package/src/runtime/loop.ts +4 -2
  37. package/src/scene/seedScene.test.ts +169 -0
  38. package/src/state/state.ts +4 -1
  39. package/src/state/stateFamily.test.ts +138 -0
  40. package/src/state/stateFamily.ts +4 -1
  41. package/src/state/stateGroup.ts +31 -4
  42. package/src/state/token.ts +5 -4
  43. package/src/synced/syncedStore.ts +99 -0
  44. package/src/wire/tree.test.ts +81 -0
  45. package/src/wire/tree.ts +129 -0
  46. package/src/wire/triggers.test.ts +76 -0
  47. package/src/wire/triggers.ts +98 -0
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export { composeCalcs } from './calc/compose'
25
25
  // query-swapping helpers and layer types without re-spelling them.
26
26
  export type { InputsObject, InputStores, InvalidateBy } from './internal/sources'
27
27
  export {
28
- type AsyncData,
28
+ AsyncData,
29
29
  type AsyncError,
30
30
  type AsyncIdle,
31
31
  type AsyncLoading,
@@ -36,6 +36,10 @@ export * as RemoteState from './remote/remoteState'
36
36
  // layer's inferred type references these (`Store<ReadonlyArray<PendingIntent<…>>>`),
37
37
  // and the declaration emitter can only name them through a plain export.
38
38
  export type { FailedIntent, PendingIntent, RemoteStateClass } from './remote/remoteState'
39
+ export * as SyncedStore from './synced/syncedStore'
40
+ // Direct type re-export alongside the namespace (TS2742 nameability): adapters
41
+ // (e.g. `@playfast/reform-db`) annotate the source handed to `SyncedStore.live`.
42
+ export type { SyncedSource, SyncedStoreClass } from './synced/syncedStore'
39
43
  export * as Boundary from './boundary/boundary'
40
44
  // Direct type re-exports alongside the namespace (same TS2742 nameability
41
45
  // reason as the RemoteState types above): app code annotates props with these.
@@ -67,17 +71,39 @@ export {
67
71
  export { CurrentSlots, type SlotHost } from './compose/host'
68
72
  export * as Ui from './compose/ui'
69
73
  export {
74
+ type DerivedContract,
70
75
  isUi,
71
76
  type LogicView,
77
+ type MadeView,
72
78
  ui,
73
79
  type UiClass,
74
80
  type UiContract,
81
+ type UiManifest,
75
82
  UiTypeId,
83
+ UiViewContract,
76
84
  type ViewImpl,
85
+ type WireSchemas,
86
+ type WiredUi,
87
+ type WiredUiManifest,
77
88
  } from './compose/ui'
78
89
  export { provide } from './compose/provide'
79
90
  export type { Trigger } from './ui/trigger'
80
91
 
92
+ // Wire: the serializable rendered-tree model + pure diff/apply the remote
93
+ // transport sends over the wire and the client folds back. See REMOTE_UI.md §3.
94
+ export * as Wire from './wire/tree'
95
+ export type { WireNode, WirePatch, WireProp, WireTree } from './wire/tree'
96
+
97
+ // Triggers: the server-side handle registry bridging client trigger invocations
98
+ // back to live triggers, decoding payloads via the event schema. See REMOTE_UI.md §4.
99
+ export * as Triggers from './wire/triggers'
100
+ export {
101
+ TriggerRegistry,
102
+ type TriggerHandle,
103
+ type TriggerRegistryApi,
104
+ UnknownTrigger,
105
+ } from './wire/triggers'
106
+
81
107
  // Feature: the lazy/eager code-split unit. Definitions stay eager and reflectable;
82
108
  // the heavy `.live` layer loads on demand (an Effect, never a Promise). See LAZY.md.
83
109
  export * as Feature from './feature/feature'
@@ -120,8 +146,10 @@ export { CurrentTracker, type Subscribable, type Tracker } from './internal/trac
120
146
  export { CaptureSink, type CaptureSinkApi, type UiCapture } from './internal/capture'
121
147
  export {
122
148
  AsyncReducer,
149
+ AsyncSceneLayer,
123
150
  DuplicateRegistration,
124
151
  FeatureLoadFailed,
152
+ forceSync,
125
153
  InvalidProvideTarget,
126
154
  SlotRenderingUnavailable,
127
155
  UnknownGroupState,
@@ -134,7 +162,15 @@ export {
134
162
  makeScheduler,
135
163
  Notifications,
136
164
  notificationsLayer,
165
+ resolveScheduler,
137
166
  type Scheduler,
138
167
  } from './internal/scheduler'
139
- export type { Store } from './internal/store'
168
+ export { makeStore, type Store } from './internal/store'
140
169
  export type { FamilyStore } from './state/stateFamily'
170
+ // Primitive-authoring toolkit — exported for first-party companion packages
171
+ // (e.g. `@playfast/reform-db`, `@playfast/reform-resource`) that build their own
172
+ // first-class `Source`s which must participate in dependency tracking and
173
+ // reflection exactly like a `Calc`.
174
+ export { type Kind, type Manifest, yieldableClass } from './definition/definition'
175
+ export { readTracked } from './internal/track'
176
+ export { sameKey, wireSources, type WiredSources } from './internal/sources'
@@ -24,7 +24,7 @@ export interface CaptureSinkApi {
24
24
  * replacing any presentation. Production never provides it, so nothing is
25
25
  * recorded and there is no cost beyond one `serviceOption` lookup per render.
26
26
  */
27
- export class CaptureSink extends Context.Tag('reform/internal/CaptureSink')<
28
- CaptureSink,
29
- CaptureSinkApi
30
- >() {}
27
+ const CaptureSinkBase: Context.TagClass<CaptureSink, 'reform/internal/CaptureSink', CaptureSinkApi> =
28
+ Context.Tag('reform/internal/CaptureSink')<CaptureSink, CaptureSinkApi>()
29
+
30
+ export class CaptureSink extends CaptureSinkBase {}
@@ -0,0 +1,33 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Context, Effect, Layer, ManagedRuntime } from 'effect'
3
+ import { AsyncSceneLayer, forceSync } from '../index'
4
+
5
+ // `forceSync` is the sync-build boundary: reform materialises a scene's layer graph
6
+ // with `runSync` so the first frame renders synchronously (D2). A layer whose acquire
7
+ // suspends makes that `runSync` throw Effect's opaque `AsyncFiberException`; the
8
+ // boundary translates it into `AsyncSceneLayer`, which names the cause and the fix.
9
+
10
+ class Thing extends Context.Tag('test/Thing')<Thing, number>() {}
11
+
12
+ it('translates an async layer build into AsyncSceneLayer', () => {
13
+ // A layer whose acquire suspends — exactly what a consumer trips on.
14
+ const asyncLayer = Layer.effect(Thing, Effect.as(Effect.sleep('1 millis'), 1))
15
+ const runtime = ManagedRuntime.make(asyncLayer)
16
+ expect(() => forceSync(() => runtime.runSync(Effect.context<Thing>()))).toThrow(AsyncSceneLayer)
17
+ })
18
+
19
+ it('passes a synchronous layer build through unchanged', () => {
20
+ const syncLayer = Layer.succeed(Thing, 1)
21
+ const runtime = ManagedRuntime.make(syncLayer)
22
+ const context = forceSync(() => runtime.runSync(Effect.context<Thing>()))
23
+ expect(Context.get(context, Thing)).toBe(1)
24
+ })
25
+
26
+ it('re-throws a non-async error untouched', () => {
27
+ class Boom extends Error {}
28
+ expect(() =>
29
+ forceSync(() => {
30
+ throw new Boom('nope')
31
+ }),
32
+ ).toThrow(Boom)
33
+ })
@@ -1,4 +1,4 @@
1
- import { Data } from 'effect'
1
+ import { Cause, Data, Option, Runtime } from 'effect'
2
2
 
3
3
  /**
4
4
  * Tagged errors for the framework's synchronous boundaries. Even when thrown
@@ -7,62 +7,138 @@ import { Data } from 'effect'
7
7
  * it. `Data.TaggedError` subclasses are real `Error`s, so `throw` still works.
8
8
  */
9
9
 
10
+ /**
11
+ * The constructor shape `Data.TaggedError(tag)<A>` produces, named so the
12
+ * generated `.d.ts` can describe the `extends` base under `isolatedDeclarations`
13
+ * (which forbids an inferred expression in an extends clause).
14
+ */
15
+ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
16
+ args: A,
17
+ ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
18
+
19
+ /** The fieldless variant — a `Data.TaggedError(tag)<{}>` constructor. */
20
+ type EmptyTaggedErrorClass<Tag extends string> = new () => Cause.YieldableError & { readonly _tag: Tag }
21
+
22
+ const SlotRenderingUnavailableBase: TaggedErrorClass<
23
+ 'reform/SlotRenderingUnavailable',
24
+ { readonly slot: string }
25
+ > = Data.TaggedError('reform/SlotRenderingUnavailable')<{ readonly slot: string }>
26
+
10
27
  /** A `<slots.X/>` was rendered without a render-target host to realize it. */
11
- export class SlotRenderingUnavailable extends Data.TaggedError('reform/SlotRenderingUnavailable')<{
12
- readonly slot: string
13
- }> {
28
+ export class SlotRenderingUnavailable extends SlotRenderingUnavailableBase {
14
29
  override get message(): string {
15
30
  return `reform: rendering slot '${this.slot}' requires @reform/react`
16
31
  }
17
32
  }
18
33
 
34
+ const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
35
+ Data.TaggedError('reform/InvalidProvideTarget')<{}>
36
+
19
37
  /** `provide(target, …)` was handed something that is neither a ui contract nor a slot. */
20
- export class InvalidProvideTarget extends Data.TaggedError('reform/InvalidProvideTarget')<{}> {
38
+ export class InvalidProvideTarget extends InvalidProvideTargetBase {
21
39
  override get message(): string {
22
40
  return 'reform: provide target is neither a ui contract nor a slot'
23
41
  }
24
42
  }
25
43
 
44
+ const UnknownGroupStateBase: TaggedErrorClass<'reform/UnknownGroupState', { readonly name: string }> =
45
+ Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
46
+
26
47
  /** `StateGroup.select(group, name)` named a state the group does not contain. */
27
- export class UnknownGroupState extends Data.TaggedError('reform/UnknownGroupState')<{
28
- readonly name: string
29
- }> {
48
+ export class UnknownGroupState extends UnknownGroupStateBase {
30
49
  override get message(): string {
31
50
  return `reform: unknown state '${this.name}' in group`
32
51
  }
33
52
  }
34
53
 
54
+ const FeatureLoadFailedBase: TaggedErrorClass<'reform/FeatureLoadFailed', { readonly cause: unknown }> =
55
+ Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
56
+
35
57
  /**
36
58
  * A lazy `Feature`'s `load` Effect failed — the dynamic `import()` rejected (a
37
59
  * missing chunk, a network failure, a module-eval throw). Surfaced as a typed
38
60
  * failure the host hands to the feature's `placeholder.failed`, never a bare
39
61
  * rejection. `cause` is the original rejection value.
40
62
  */
41
- export class FeatureLoadFailed extends Data.TaggedError('reform/FeatureLoadFailed')<{
42
- readonly cause: unknown
43
- }> {
63
+ export class FeatureLoadFailed extends FeatureLoadFailedBase {
44
64
  override get message(): string {
45
65
  return `reform: a feature failed to load (${String(this.cause)})`
46
66
  }
47
67
  }
48
68
 
69
+ const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
70
+ Data.TaggedError('reform/AsyncReducer')<{}>
71
+
49
72
  /** A reducer fold returned a thenable — folds must be pure synchronous writes. */
50
- export class AsyncReducer extends Data.TaggedError('reform/AsyncReducer')<{}> {
73
+ export class AsyncReducer extends AsyncReducerBase {
51
74
  override get message(): string {
52
75
  return 'reform: a reducer fold must be synchronous (it returned a Promise)'
53
76
  }
54
77
  }
55
78
 
79
+ const AsyncSceneLayerBase: EmptyTaggedErrorClass<'reform/AsyncSceneLayer'> =
80
+ Data.TaggedError('reform/AsyncSceneLayer')<{}>
81
+
82
+ /**
83
+ * A scene layer performed async work during reform's synchronous layer build.
84
+ * Reform materialises a scene's layer graph with `runSync` so the first frame
85
+ * renders synchronously (D2); a `Layer.effect`/`scoped` whose acquire suspends
86
+ * (HTTP, opening a DB, `Effect.sleep`) makes that `runSync` throw Effect's opaque
87
+ * `AsyncFiberException`. `forceSync` catches it and rethrows this, which names the
88
+ * cause and the sanctioned fix instead of pointing at Effect internals.
89
+ */
90
+ export class AsyncSceneLayer extends AsyncSceneLayerBase {
91
+ override get message(): string {
92
+ return (
93
+ 'reform: a scene layer performed async work while building. Reform builds the ' +
94
+ 'scene synchronously so the first frame can render — a layer acquire must be ' +
95
+ 'synchronous (Layer.sync/Layer.succeed). For async setup (open a connection, ' +
96
+ 'fetch config), use Resource.live from @playfast/reform-resource, which builds ' +
97
+ 'synchronously and resolves in the background.'
98
+ )
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Whether a thrown value is Effect's "ran async under runSync" defect. `runSync`
104
+ * throws the bare `AsyncFiberException`; a `ManagedRuntime.runSync` whose layer
105
+ * build suspends instead throws a `FiberFailure` carrying it as a `Die`, so both
106
+ * shapes are checked.
107
+ */
108
+ const isAsyncBuildDefect = (error: unknown): boolean => {
109
+ if (Runtime.isAsyncFiberException(error)) return true
110
+ if (!Runtime.isFiberFailure(error)) return false
111
+ const die = Cause.dieOption(error[Runtime.FiberFailureCauseId])
112
+ return Option.isSome(die) && Runtime.isAsyncFiberException(die.value)
113
+ }
114
+
115
+ /**
116
+ * Run a synchronous runtime read (the scene layer build / context capture) and
117
+ * translate Effect's async-fiber defect into `AsyncSceneLayer`; any other throw
118
+ * passes through unchanged. The single home for the sync-build boundary, shared by
119
+ * the React host and the remote server.
120
+ */
121
+ export const forceSync = <A>(thunk: () => A): A => {
122
+ try {
123
+ return thunk()
124
+ } catch (error) {
125
+ if (isAsyncBuildDefect(error)) throw new AsyncSceneLayer()
126
+ throw error
127
+ }
128
+ }
129
+
130
+ const DuplicateRegistrationBase: TaggedErrorClass<
131
+ 'reform/DuplicateRegistration',
132
+ { readonly kind: string; readonly name: string }
133
+ > = Data.TaggedError('reform/DuplicateRegistration')<{ readonly kind: string; readonly name: string }>
134
+
56
135
  /**
57
136
  * Two distinct primitives were registered under one name into the same runtime —
58
137
  * a silent footgun, since names key DI tags and channel lanes. Thrown for a
59
138
  * channel name reused with a different policy (the second would otherwise be
60
139
  * dropped). Reducer/procedure name clashes are warned (logged), not thrown.
61
140
  */
62
- export class DuplicateRegistration extends Data.TaggedError('reform/DuplicateRegistration')<{
63
- readonly kind: string
64
- readonly name: string
65
- }> {
141
+ export class DuplicateRegistration extends DuplicateRegistrationBase {
66
142
  override get message(): string {
67
143
  return `reform: duplicate ${this.kind} registered under name '${this.name}'`
68
144
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from '@effect/vitest'
2
+ import { Schema as S } from 'effect'
3
+ import { State } from '../index'
4
+ import { makeStore } from './store'
5
+
6
+ // Every reform value should print like an Effect value (an `_id`-tagged JSON),
7
+ // not `[object Object]` — via `toJSON`/`toString`/the Node inspect symbol.
8
+
9
+ describe('Inspectable', () => {
10
+ it('a Store reports its current value as tagged JSON', () => {
11
+ const store = makeStore(41)
12
+ expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 41 })
13
+ store.set(42)
14
+ expect(store.toJSON()).toEqual({ _id: 'reform/Store', value: 42 })
15
+ // toString and JSON.stringify route through the same toJSON.
16
+ expect(JSON.parse(JSON.stringify(store))).toEqual({ _id: 'reform/Store', value: 42 })
17
+ expect(store.toString()).toContain('reform/Store')
18
+ })
19
+
20
+ it('a definition reports its manifest', () => {
21
+ class Count extends State.make('count', S.Number) {}
22
+ // Inspectable is attached at runtime (definitions are reflected via their
23
+ // manifest); reach the method through a typed view.
24
+ const inspectable = Count as unknown as { toJSON(): unknown }
25
+ expect(inspectable.toJSON()).toBe(Count.manifest)
26
+ expect(String(Count)).toContain('"kind": "State"')
27
+ })
28
+ })
@@ -44,7 +44,7 @@ export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefin
44
44
  // package index, so the editor catalog (which only indexes module exports)
45
45
  // never sees it.
46
46
  export type Revision = number & Brand.Brand<'reform/Revision'>
47
- export const Revision = Brand.nominal<Revision>()
47
+ export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
48
48
  export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
49
49
  Schema.brand('reform/Revision'),
50
50
  )
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it } from '@effect/vitest'
2
+ import { Data, Schema as S } from 'effect'
3
+ import { reuse } from './reuse'
4
+
5
+ // The structural-sharing pass behind `reuse: true`: identities move only where
6
+ // values moved, and the result is always value-equal to the fresh output.
7
+
8
+ describe('reuse', () => {
9
+ it('returns the previous value when the next is deeply value-equal', () => {
10
+ const prev = { a: [1, 2, { b: 'x' }], c: { d: true } }
11
+ const next = { a: [1, 2, { b: 'x' }], c: { d: true } }
12
+ expect(reuse(prev, next)).toBe(prev)
13
+ })
14
+
15
+ it('keeps identity of unchanged array elements while replacing changed ones', () => {
16
+ const e0 = { id: 1, name: 'a' }
17
+ const e1 = { id: 2, name: 'b' }
18
+ const prev = [e0, e1]
19
+ const next = [{ id: 1, name: 'a' }, { id: 2, name: 'B' }]
20
+ const out = reuse(prev, next)
21
+ expect(out).not.toBe(prev)
22
+ expect(out[0]).toBe(e0)
23
+ expect(out[1]).not.toBe(e1)
24
+ expect(out[1]).toEqual({ id: 2, name: 'B' })
25
+ })
26
+
27
+ it('keeps identity of unchanged record subtrees', () => {
28
+ const left = { items: [1, 2, 3] }
29
+ const prev = { left, right: { items: [4] } }
30
+ const next = { left: { items: [1, 2, 3] }, right: { items: [4, 5] } }
31
+ const out = reuse(prev, next)
32
+ expect(out.left).toBe(left)
33
+ expect(out.right).not.toBe(prev.right)
34
+ expect(out).toEqual(next)
35
+ })
36
+
37
+ it('substitutes Equal-implementing instances by value equality', () => {
38
+ class Row extends S.Class<Row>('Row')({ id: S.Number, name: S.String }) {}
39
+ const prevRow = new Row({ id: 1, name: 'a' })
40
+ const prev = { rows: [prevRow] }
41
+ const next = { rows: [new Row({ id: 1, name: 'a' })] }
42
+ const out = reuse(prev, next)
43
+ expect(out).toBe(prev)
44
+
45
+ const tagged = Data.struct({ k: 'v' })
46
+ const out2 = reuse({ t: tagged }, { t: Data.struct({ k: 'v' }) })
47
+ expect(out2.t).toBe(tagged)
48
+ })
49
+
50
+ it('walks INTO a differing Schema class, keeping its unchanged children', () => {
51
+ class Row extends S.Class<Row>('Row')({ id: S.Number, name: S.String }) {}
52
+ class View extends S.Class<View>('View')({ title: S.String, rows: S.Array(Row) }) {}
53
+ const keep = new Row({ id: 1, name: 'a' })
54
+ const prev = new View({ title: 't', rows: [keep, new Row({ id: 2, name: 'b' })] })
55
+ const next = new View({ title: 't', rows: [new Row({ id: 1, name: 'a' }), new Row({ id: 2, name: 'B' })] })
56
+ const out = reuse(prev, next)
57
+ // The container changed (row 2 renamed) so a fresh View comes back — but it
58
+ // is still a View (prototype preserved) and the untouched row keeps its
59
+ // exact previous identity.
60
+ expect(out).not.toBe(prev)
61
+ expect(out).toBeInstanceOf(View)
62
+ expect(out.rows[0]).toBe(keep)
63
+ expect(out.rows[1]).not.toBe(prev.rows[1])
64
+ expect(out.rows[1]?.name).toBe('B')
65
+ expect(out).toStrictEqual(next)
66
+ })
67
+
68
+ it('compares Dates by instant (Effect Equal) and treats other class instances as opaque leaves', () => {
69
+ // Effect's `Equal.equals` compares Dates structurally: same instant ⇒ the
70
+ // previous identity is kept.
71
+ const prev = { at: new Date(0) }
72
+ const out = reuse(prev, { at: new Date(0) })
73
+ expect(out.at).toBe(prev.at)
74
+
75
+ // A class without Equal: never recursed into, the fresh leaf wins even
76
+ // when its fields look identical.
77
+ class Opaque {
78
+ readonly v: number
79
+ constructor(v: number) {
80
+ this.v = v
81
+ }
82
+ }
83
+ const nextOpaque = { o: new Opaque(1) }
84
+ const out2 = reuse({ o: new Opaque(1) }, nextOpaque)
85
+ expect(out2.o).toBe(nextOpaque.o)
86
+ })
87
+
88
+ it('returns next below the depth bound', () => {
89
+ type Nest = { readonly n?: Nest; readonly v: number }
90
+ const deep = (depth: number): Nest => (depth === 0 ? { v: 0 } : { v: depth, n: deep(depth - 1) })
91
+ const prev = deep(20)
92
+ const next = deep(20)
93
+ const out = reuse(prev, next)
94
+ // Value-equal throughout — shared down to the bound, fresh below it.
95
+ expect(out).toEqual(next)
96
+ })
97
+
98
+ it('is value-equal to next and mutates neither argument', () => {
99
+ const prev = { a: [{ x: 1 }], b: 'keep' }
100
+ const next = { a: [{ x: 2 }], b: 'keep' }
101
+ const prevCopy = structuredClone(prev)
102
+ const nextCopy = structuredClone(next)
103
+ const out = reuse(prev, next)
104
+ expect(out).toEqual(nextCopy)
105
+ expect(prev).toEqual(prevCopy)
106
+ expect(next).toEqual(nextCopy)
107
+ })
108
+
109
+ it('shares nothing when shapes diverge (added/removed keys)', () => {
110
+ const prev = { a: 1, b: 2 }
111
+ const next = { a: 1 }
112
+ const out = reuse<Record<string, number>>(prev, next)
113
+ expect(out).toEqual({ a: 1 })
114
+ expect(out).not.toBe(prev)
115
+ })
116
+ })
@@ -69,7 +69,9 @@ export const defaultScheduler: Scheduler = makeScheduler()
69
69
  * state/calc layers, those stores coalesce on it instead of the global default,
70
70
  * isolating their notification timing from other runtimes in the same process.
71
71
  */
72
- export class Notifications extends Context.Tag('reform/Notifications')<Notifications, Scheduler>() {}
72
+ const NotificationsBase: Context.TagClass<Notifications, 'reform/Notifications', Scheduler> =
73
+ Context.Tag('reform/Notifications')<Notifications, Scheduler>()
74
+ export class Notifications extends NotificationsBase {}
73
75
 
74
76
  /**
75
77
  * A fresh per-runtime scheduler. Merged into `Engine`, so a runtime that wires
@@ -47,8 +47,8 @@ export type InvalidateBy<Inputs extends ReadonlyArray<AnySource>> = (
47
47
  const inputKey = (input: AnySource): string =>
48
48
  Predicate.hasProperty(input, 'manifest') &&
49
49
  Predicate.isRecord(input.manifest) &&
50
- Predicate.isString(input.manifest.name)
51
- ? input.manifest.name
50
+ Predicate.isString(input.manifest['name'])
51
+ ? input.manifest['name']
52
52
  : input.name
53
53
 
54
54
  /** Element-wise value equality of two invalidation keys. */
@@ -0,0 +1,32 @@
1
+ import { Effect } from 'effect'
2
+
3
+ /**
4
+ * Module-load registry of the DI tag identifiers minted by `State.make` /
5
+ * `StateFamily.make`. Two distinct definitions that claim one identifier (e.g.
6
+ * `State.make('open')` in two different features) otherwise collapse silently —
7
+ * `Layer.merge` resolves identical tags to the rightmost, with no error. Here we
8
+ * surface the clash.
9
+ *
10
+ * Warned, not thrown — mirroring duplicate reducer/procedure names: a cross-
11
+ * feature clash is a footgun, but a module may legitimately be re-evaluated under
12
+ * the same identifier (HMR), so a hard throw would punish dev reloads. The
13
+ * `warned` set keeps that to one warning per identifier regardless of reloads.
14
+ * In-group clashes (where every member is addressable by name) are a harder error
15
+ * — see `StateGroup.make`, which throws.
16
+ */
17
+ const claimed = new Set<string>()
18
+ const warned = new Set<string>()
19
+
20
+ export const claimStateTag = (identifier: string): void => {
21
+ if (!claimed.has(identifier)) {
22
+ claimed.add(identifier)
23
+ return
24
+ }
25
+ if (warned.has(identifier)) return
26
+ warned.add(identifier)
27
+ Effect.runSync(
28
+ Effect.logWarning(
29
+ `reform: duplicate state tag '${identifier}' — two definitions collide and the last merged wins; namespace the name per feature`,
30
+ ),
31
+ )
32
+ }
@@ -0,0 +1,80 @@
1
+ import { expect, test } from 'vitest'
2
+ import { makeStore } from './store'
3
+
4
+ const tick = () => new Promise<void>((resolve) => queueMicrotask(resolve))
5
+
6
+ // A const counter we increment in place — subscriber callbacks need somewhere to
7
+ // tally without a reassigned `let`.
8
+ const counter = () => ({ n: 0 })
9
+
10
+ test('subscribe observes changes; getSnapshot is always current', async () => {
11
+ const store = makeStore(0)
12
+ const notifications = counter()
13
+ store.subscribe(() => {
14
+ notifications.n++
15
+ })
16
+
17
+ store.set(1)
18
+ expect(store.getSnapshot()).toBe(1) // snapshot is synchronous
19
+ await tick()
20
+ expect(notifications.n).toBe(1)
21
+ })
22
+
23
+ test('a no-op write notifies no one', async () => {
24
+ const store = makeStore('same')
25
+ const notifications = counter()
26
+ store.subscribe(() => {
27
+ notifications.n++
28
+ })
29
+
30
+ store.set('same')
31
+ await tick()
32
+ expect(notifications.n).toBe(0)
33
+ expect(store.getSnapshot()).toBe('same')
34
+ })
35
+
36
+ test('a batch of writes coalesces into a single notification per microtask', async () => {
37
+ const store = makeStore(0)
38
+ const notifications = counter()
39
+ store.subscribe(() => {
40
+ notifications.n++
41
+ })
42
+
43
+ store.set(1)
44
+ store.set(2)
45
+ store.set(3)
46
+ expect(store.getSnapshot()).toBe(3)
47
+ await tick()
48
+ expect(notifications.n).toBe(1)
49
+ })
50
+
51
+ test('stores are independent — one slice does not wake another', async () => {
52
+ const a = makeStore(0)
53
+ const b = makeStore(0)
54
+ const aWakes = counter()
55
+ const bWakes = counter()
56
+ a.subscribe(() => {
57
+ aWakes.n++
58
+ })
59
+ b.subscribe(() => {
60
+ bWakes.n++
61
+ })
62
+
63
+ a.set(1)
64
+ await tick()
65
+ expect(aWakes.n).toBe(1)
66
+ expect(bWakes.n).toBe(0)
67
+ })
68
+
69
+ test('unsubscribe stops notifications', async () => {
70
+ const store = makeStore(0)
71
+ const notifications = counter()
72
+ const unsubscribe = store.subscribe(() => {
73
+ notifications.n++
74
+ })
75
+
76
+ unsubscribe()
77
+ store.set(1)
78
+ await tick()
79
+ expect(notifications.n).toBe(0)
80
+ })
@@ -23,7 +23,9 @@ export interface Tracker {
23
23
  * The active render's dependency tracker (D5). Present only while a composition
24
24
  * is rendering under `@reform/react`; absent in procedures and headless reads.
25
25
  */
26
- export class CurrentTracker extends Context.Tag('reform/Tracker')<CurrentTracker, Tracker>() {}
26
+ const CurrentTrackerBase: Context.TagClass<CurrentTracker, 'reform/Tracker', Tracker> =
27
+ Context.Tag('reform/Tracker')<CurrentTracker, Tracker>()
28
+ export class CurrentTracker extends CurrentTrackerBase {}
27
29
 
28
30
  /**
29
31
  * Read a store's current snapshot and, if a tracker is active, record the store