@playfast/reform 1.1.1 → 1.2.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 (106) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +82 -33
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +733 -32
  6. package/src/calc/asyncCalc.test.ts +397 -225
  7. package/src/calc/asyncCalc.ts +115 -100
  8. package/src/calc/asyncCalcTypes.ts +147 -0
  9. package/src/calc/calc.test.ts +13 -16
  10. package/src/calc/calc.ts +44 -18
  11. package/src/calc/calcFamily.test.ts +90 -32
  12. package/src/calc/calcFamily.ts +56 -25
  13. package/src/calc/compose.ts +1 -14
  14. package/src/channel/channel.ts +25 -6
  15. package/src/channel/{procedureRegistry.ts → procedures.ts} +41 -23
  16. package/src/compose/composition.test.ts +19 -0
  17. package/src/compose/composition.ts +168 -81
  18. package/src/compose/compositionTypes.ts +249 -0
  19. package/src/compose/props.ts +13 -6
  20. package/src/compose/provide.ts +187 -46
  21. package/src/compose/slot.ts +156 -19
  22. package/src/compose/structure.test.ts +6 -3
  23. package/src/compose/structure.ts +106 -13
  24. package/src/compose/ui.test.ts +19 -3
  25. package/src/compose/ui.ts +147 -54
  26. package/src/compose/ui.typecheck.ts +40 -4
  27. package/src/definition/definition.ts +22 -9
  28. package/src/event/event.fromSource.test.ts +172 -0
  29. package/src/event/event.test.ts +10 -0
  30. package/src/event/event.ts +102 -18
  31. package/src/feature/feature.mount.test.ts +123 -56
  32. package/src/feature/feature.test.ts +67 -63
  33. package/src/feature/feature.ts +222 -262
  34. package/src/feature/feature.typecheck.ts +224 -91
  35. package/src/feature/featureBinding.ts +130 -20
  36. package/src/feature/featureClass.ts +162 -0
  37. package/src/feature/featureConfig.ts +192 -0
  38. package/src/feature/featureFactory.ts +163 -0
  39. package/src/feature/featureModule.ts +136 -0
  40. package/src/feature/featureModule.typecheck.ts +63 -0
  41. package/src/feature/featureNativeTree.ts +152 -0
  42. package/src/feature/featureRequirement.ts +82 -0
  43. package/src/feature/featureVariants.ts +234 -0
  44. package/src/feature/mountFeature.ts +54 -0
  45. package/src/graph/closure.ts +222 -0
  46. package/src/graph/services.ts +95 -0
  47. package/src/graph/summary.ts +134 -0
  48. package/src/index.ts +171 -245
  49. package/src/internal/bucketCache.ts +39 -0
  50. package/src/internal/capture.ts +9 -4
  51. package/src/internal/env.ts +1 -3
  52. package/src/internal/errors.ts +21 -10
  53. package/src/internal/queryDriver.ts +61 -25
  54. package/src/internal/queryDriverHydrate.ts +45 -0
  55. package/src/internal/queryDriverStore.ts +8 -5
  56. package/src/internal/queryDriverTypes.ts +11 -11
  57. package/src/internal/reuse.test.ts +10 -3
  58. package/src/internal/reuse.ts +5 -2
  59. package/src/internal/scheduler.ts +4 -1
  60. package/src/internal/sources.ts +25 -11
  61. package/src/internal/store.test.ts +27 -6
  62. package/src/internal/track.ts +3 -2
  63. package/src/internal.ts +222 -0
  64. package/src/namespace/namespace.ts +46 -25
  65. package/src/procedure/procedure.ts +38 -11
  66. package/src/reducer/reducer.ts +78 -34
  67. package/src/remote/pendingQueue.ts +145 -0
  68. package/src/remote/remoteState.test.ts +536 -354
  69. package/src/remote/remoteState.ts +145 -106
  70. package/src/remote/remoteState.typecheck.ts +47 -23
  71. package/src/remote/remoteStateMake.ts +97 -0
  72. package/src/remote/remoteStateSend.ts +95 -37
  73. package/src/remote/remoteStateTypes.ts +155 -0
  74. package/src/runtime/appRuntime.activation.test.ts +104 -0
  75. package/src/runtime/appRuntime.test.ts +187 -111
  76. package/src/runtime/appRuntime.ts +36 -130
  77. package/src/runtime/capturedAppRuntime.ts +274 -0
  78. package/src/runtime/eventBudget.test.ts +39 -12
  79. package/src/runtime/eventBudget.ts +9 -8
  80. package/src/runtime/featureMount.ts +94 -0
  81. package/src/runtime/hardening.test.ts +22 -13
  82. package/src/runtime/instrumentation.test.ts +213 -194
  83. package/src/runtime/instrumentation.ts +6 -11
  84. package/src/runtime/loop.test.ts +84 -7
  85. package/src/runtime/loop.ts +69 -40
  86. package/src/runtime/runtimeHandle.ts +124 -0
  87. package/src/scene/featureScene.test.ts +4 -2
  88. package/src/scene/scene.ts +234 -64
  89. package/src/scene/seedScene.test.ts +69 -86
  90. package/src/state/state.nominal.typecheck.ts +68 -0
  91. package/src/state/state.test.ts +17 -0
  92. package/src/state/state.ts +79 -26
  93. package/src/state/stateFamily.test.ts +39 -8
  94. package/src/state/stateFamily.ts +55 -22
  95. package/src/state/stateGroup.ts +53 -17
  96. package/src/state/token.ts +40 -10
  97. package/src/synced/syncedStore.ts +46 -23
  98. package/src/testkit/flight.testkit.ts +84 -0
  99. package/src/wire/tree.test.ts +8 -4
  100. package/src/wire/triggers.test.ts +6 -2
  101. package/src/wire/triggers.ts +14 -10
  102. package/src/calc/asyncCalcDefinitions.ts +0 -48
  103. package/src/internal/ctx.ts +0 -9
  104. package/src/remote/remoteStateDefinition.ts +0 -135
  105. package/src/remote/remoteStateLayers.ts +0 -118
  106. package/src/remote/remoteStateLiveTypes.ts +0 -59
@@ -2,24 +2,80 @@ import { Context, Effect, Either, Layer } from 'effect'
2
2
  import * as Channel from '../channel/channel'
3
3
  import * as Event from '../event/event'
4
4
  import { Bus, narrowHandled } from '../runtime/bus'
5
- import type { SettleLink } from './remoteStateLiveTypes'
5
+ import { type ReducerEntry, Reducers } from '../runtime/loop'
6
+ import type { PendingQueue } from './pendingQueue'
7
+ import type { QueueFoldEventExternalApi, SettleLink } from './remoteStateTypes'
6
8
 
7
- interface SendProcedureOptions<I, R> {
9
+ export interface IntentEvents<I> {
10
+ readonly queued: Event.EventClass<string, { readonly opId: string; readonly intent: I }>
8
11
  readonly acked: Event.EventClass<string, { readonly opId: string }>
12
+ readonly settled: Event.EventClass<string, { readonly opId: string }>
13
+ readonly invalidated: Event.EventClass<string, {}>
14
+ }
15
+
16
+ interface QueueReducerOptions<I> {
17
+ readonly name: string
18
+ readonly queueTag: Context.Tag<PendingQueue<I>, PendingQueue<I>>
19
+ readonly events: IntentEvents<I>
20
+ }
21
+
22
+ // Direct registry assembly avoids deferred conditional parameters while Intents is generic.
23
+ export const queueReducerLayer = <I>(
24
+ options: QueueReducerOptions<I>,
25
+ ): Layer.Layer<never, never, Reducers | PendingQueue<I>> =>
26
+ Layer.scopedDiscard(
27
+ Effect.gen(function* () {
28
+ const reducers = yield* Reducers
29
+ if (reducers.entries.some((entry) => entry.name === options.name)) {
30
+ yield* Effect.logWarning(`reform: duplicate reducer name '${options.name}' registered`)
31
+ }
32
+ const queue = yield* options.queueTag
33
+ const entry: ReducerEntry = {
34
+ name: options.name,
35
+ handles: new Set([
36
+ options.events.queued.tag,
37
+ options.events.acked.tag,
38
+ options.events.settled.tag,
39
+ ]),
40
+ apply: (event) => {
41
+ const handled = narrowHandled<QueueFoldEventExternalApi<I>>(event)
42
+ if (handled._tag === options.events.queued.tag) {
43
+ if (handled.intent !== undefined) {
44
+ queue.append({
45
+ opId: handled.opId,
46
+ intent: handled.intent,
47
+ status: 'sending',
48
+ })
49
+ }
50
+ return
51
+ }
52
+ if (handled._tag === options.events.acked.tag) {
53
+ queue.ack(handled.opId)
54
+ return
55
+ }
56
+ queue.settle(handled.opId)
57
+ },
58
+ }
59
+ yield* Effect.acquireRelease(
60
+ Effect.sync(() => reducers.register(entry)),
61
+ () => Effect.sync(() => reducers.unregister(entry)),
62
+ )
63
+ }),
64
+ )
65
+
66
+ interface SendProcedureOptions<I, R2> {
67
+ readonly name: string
9
68
  readonly channel: Channel.ChannelClass
10
- readonly failed: Event.EventClass<string, { readonly intent: I; readonly error: unknown }>
11
69
  readonly handles: ReadonlySet<string>
12
- readonly invalidated: Event.EventClass<string, {}>
13
70
  readonly linkTag: Context.Tag<SettleLink, SettleLink>
14
- readonly name: string
15
- readonly queued: Event.EventClass<string, { readonly opId: string; readonly intent: I }>
16
- readonly send: (intent: I) => Effect.Effect<unknown, unknown, R>
17
- readonly settled: Event.EventClass<string, { readonly opId: string }>
71
+ readonly failed: Event.EventClass<string, { readonly intent: I; readonly error: unknown }>
72
+ readonly send: (intent: I) => Effect.Effect<unknown, unknown, R2>
73
+ readonly events: IntentEvents<I>
18
74
  }
19
75
 
20
- export const makeSendProcedureLayer = <I, R>(
21
- options: SendProcedureOptions<I, R>,
22
- ): Layer.Layer<never, never, Channel.Procedures | SettleLink | Bus | R> =>
76
+ export const sendProcedureLayer = <I, R2>(
77
+ options: SendProcedureOptions<I, R2>,
78
+ ): Layer.Layer<never, never, Channel.Procedures | Bus | R2 | SettleLink> =>
23
79
  Layer.scopedDiscard(
24
80
  Effect.gen(function* () {
25
81
  const procedures = yield* Channel.Procedures
@@ -27,38 +83,40 @@ export const makeSendProcedureLayer = <I, R>(
27
83
  yield* Effect.logWarning(`reform: duplicate procedure name '${options.name}' registered`)
28
84
  }
29
85
  const link = yield* options.linkTag
30
- const runtime = yield* Effect.runtime<Bus | R>()
31
- const deliver = Effect.fn('deliver')(
32
- function* (intent: I): Effect.fn.Return<void, never, Bus | R> {
33
- const opId = yield* Effect.sync(() => crypto.randomUUID())
34
- yield* Event.dispatch(options.queued, { opId, intent })
35
- const outcome = yield* options.send(intent).pipe(
36
- Effect.catchAllDefect((defect) => Effect.fail(defect)),
37
- Effect.either,
38
- Effect.onInterrupt(() => Event.dispatch(options.settled, { opId })),
39
- )
40
- yield* Either.match(outcome, {
41
- onLeft: (error) =>
42
- Effect.zipRight(
43
- Event.dispatch(options.failed, { intent, error }),
44
- Event.dispatch(options.settled, { opId }),
45
- ),
46
- onRight: () =>
47
- Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
48
- Effect.zipRight(Event.dispatch(options.acked, { opId })),
49
- Effect.zipRight(Event.dispatch(options.invalidated, {})),
50
- ),
51
- })
52
- },
53
- )
86
+ const runtime = yield* Effect.runtime<Bus | R2>()
87
+ const deliver = Effect.fn('deliver')(function* (
88
+ intent: I,
89
+ ): Effect.fn.Return<void, never, Bus | R2> {
90
+ const opId = yield* Effect.sync(() => crypto.randomUUID())
91
+ yield* Event.dispatch(options.events.queued, { opId, intent })
92
+ const outcome = yield* options.send(intent).pipe(
93
+ Effect.catchAllDefect((defect) => Effect.fail(defect)),
94
+ Effect.either,
95
+ Effect.onInterrupt(() => Event.dispatch(options.events.settled, { opId })),
96
+ )
97
+ yield* Either.match(outcome, {
98
+ onLeft: (error) =>
99
+ Effect.zipRight(
100
+ Event.dispatch(options.failed, { intent, error }),
101
+ Event.dispatch(options.events.settled, { opId }),
102
+ ),
103
+ onRight: () =>
104
+ // Register before invalidation so a pre-ack refetch cannot settle this intent.
105
+ Effect.sync(() => link.register(opId, link.requested() + 1)).pipe(
106
+ Effect.zipRight(Event.dispatch(options.events.acked, { opId })),
107
+ Effect.zipRight(Event.dispatch(options.events.invalidated, {})),
108
+ ),
109
+ })
110
+ })
54
111
  const entry: Channel.ProcedureEntry = {
55
112
  name: options.name,
56
113
  channelName: options.channel.manifest.name,
57
114
  handles: options.handles,
58
115
  run: (event) => Effect.provide(deliver(narrowHandled<I>(event)), runtime),
59
116
  }
60
- yield* Effect.acquireRelease(Effect.sync(() => procedures.register(entry)), () =>
61
- Effect.sync(() => procedures.unregister(entry)),
117
+ yield* Effect.acquireRelease(
118
+ Effect.sync(() => procedures.register(entry)),
119
+ () => Effect.sync(() => procedures.unregister(entry)),
62
120
  )
63
121
  }),
64
122
  )
@@ -0,0 +1,155 @@
1
+ import type { Context, Effect, Schema } from 'effect'
2
+ import type { Effect as EffectType } from 'effect/Effect'
3
+ import type { AsyncData, AsyncError, AsyncSuccess } from '../calc/asyncData'
4
+ import type * as Channel from '../channel/channel'
5
+ import type { Manifest } from '../definition/definition'
6
+ import type * as Event from '../event/event'
7
+ import type { QueryCodec } from '../internal/queryDriver'
8
+ import type { InputsObject, InvalidateBy } from '../internal/sources'
9
+ import type { Store } from '../internal/store'
10
+ import type { AnySource, SourceCapture, StateToken } from '../state/token'
11
+
12
+ // An intent acked at generation g settles only on Success with generation > g.
13
+ // `apply` must be idempotent while refetched truth and the pending intent overlap.
14
+
15
+ export interface PendingIntent<I> {
16
+ readonly opId: string
17
+ readonly intent: I
18
+ readonly status: 'sending' | 'confirmed'
19
+ }
20
+
21
+ export interface FailedIntent<I> {
22
+ readonly intent: I
23
+ readonly error: unknown
24
+ }
25
+
26
+ export type RemoteStateSettled<A, E> = AsyncSuccess<A> | AsyncError<E>
27
+
28
+ export const RemoteStateVisibleStoreTypeId: unique symbol = Symbol.for(
29
+ 'reform/RemoteStateVisibleStore',
30
+ )
31
+ export type RemoteStateVisibleStoreTypeId = typeof RemoteStateVisibleStoreTypeId
32
+
33
+ export interface RemoteStateVisibleStore<N extends string> {
34
+ readonly [RemoteStateVisibleStoreTypeId]: N
35
+ }
36
+
37
+ export const RemoteStateTruthStoreTypeId: unique symbol = Symbol.for('reform/RemoteStateTruthStore')
38
+ export type RemoteStateTruthStoreTypeId = typeof RemoteStateTruthStoreTypeId
39
+
40
+ export interface RemoteStateTruthStore<N extends string> {
41
+ readonly [RemoteStateTruthStoreTypeId]: N
42
+ }
43
+
44
+ export const RemoteStatePendingStoreTypeId: unique symbol = Symbol.for(
45
+ 'reform/RemoteStatePendingStore',
46
+ )
47
+ export type RemoteStatePendingStoreTypeId = typeof RemoteStatePendingStoreTypeId
48
+
49
+ export interface RemoteStatePendingStore<N extends string> {
50
+ readonly [RemoteStatePendingStoreTypeId]: N
51
+ }
52
+
53
+ export type EventPayload<Ev extends Event.AnyEvent> =
54
+ Ev extends Event.EventClass<string, infer P> ? P : never
55
+
56
+ // ExternalApi: optional `intent` only on Queued envelopes.
57
+ export interface QueueFoldEventExternalApi<I> {
58
+ readonly _tag: string
59
+ readonly opId: string
60
+ readonly intent?: I
61
+ }
62
+
63
+ export interface RemoteStateSchemaReflection {
64
+ readonly ast: Schema.Schema<unknown, unknown, unknown>['ast']
65
+ }
66
+
67
+ export interface RemoteStateManifestExternalApi<N extends string> extends Manifest {
68
+ readonly kind: 'RemoteState'
69
+ readonly name: N
70
+ readonly output: RemoteStateSchemaReflection
71
+ readonly error?: RemoteStateSchemaReflection
72
+ readonly gated: boolean
73
+ readonly intents: ReadonlyArray<string>
74
+ }
75
+
76
+ export interface RemoteStateClass<
77
+ N extends string,
78
+ out Inputs extends ReadonlyArray<AnySource>,
79
+ in out Intents extends ReadonlyArray<Event.AnyEvent>,
80
+ in out A,
81
+ in out E,
82
+ in out Gated extends boolean,
83
+ > extends EffectType<AsyncData<A, E, Gated>, never, RemoteStateVisibleStore<N>> {
84
+ new (): {}
85
+ readonly manifest: RemoteStateManifestExternalApi<N>
86
+ readonly codec: QueryCodec<A>
87
+ readonly store: Context.Tag<RemoteStateVisibleStore<N>, Store<AsyncData<A, E, Gated>>>
88
+ readonly name: N
89
+ readonly inputs: Inputs
90
+ readonly intents: Intents
91
+ readonly gated: Gated
92
+ readonly capture: <Result>(visit: SourceCapture<Result>) => Result
93
+ readonly truth: StateToken<`${N}/truth`, AsyncData<A, E, Gated>, RemoteStateTruthStore<N>>
94
+ // pending is a Source, not a StateClass, so user reducers cannot target it.
95
+ readonly pending: StateToken<
96
+ `${N}/pending`,
97
+ ReadonlyArray<PendingIntent<Event.EventType<Intents[number]>>>,
98
+ RemoteStatePendingStore<N>
99
+ >
100
+ readonly Failed: Event.EventClass<`${N}/Failed`, FailedIntent<Event.EventType<Intents[number]>>>
101
+ }
102
+
103
+ export interface RemoteStateConfigExternalApi<
104
+ Inputs extends ReadonlyArray<AnySource>,
105
+ Intents extends ReadonlyArray<Event.AnyEvent>,
106
+ OutputSchema extends Schema.Schema.AnyNoContext,
107
+ ErrorSchema extends Schema.Schema.AnyNoContext | undefined,
108
+ AlwaysOn extends boolean,
109
+ > {
110
+ readonly inputs: Inputs
111
+ readonly output: OutputSchema
112
+ readonly error?: ErrorSchema
113
+ readonly alwaysOn?: AlwaysOn
114
+ readonly intents: Intents
115
+ }
116
+ export type RemoteStateLiveExternalApi<
117
+ Inputs extends ReadonlyArray<AnySource>,
118
+ Intents extends ReadonlyArray<Event.AnyEvent>,
119
+ A,
120
+ E,
121
+ Gated extends boolean,
122
+ R,
123
+ R2,
124
+ SettledEvent extends Event.AnyEvent,
125
+ > = {
126
+ readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
127
+ // Any failure settles and emits Failed; truth comes only from the invalidated query.
128
+ readonly send: (intent: Event.EventType<Intents[number]>) => Effect.Effect<unknown, unknown, R2>
129
+ readonly apply: (value: A, intent: Event.EventType<Intents[number]>) => A
130
+ readonly channel?: Channel.ChannelClass
131
+ readonly invalidateBy?: InvalidateBy<Inputs>
132
+ readonly invalidateOn?: ReadonlyArray<Event.AnyEvent>
133
+ readonly coalesce?: 'switch' | 'trailing'
134
+ readonly reuse?: boolean
135
+ readonly settled?: {
136
+ readonly event: SettledEvent
137
+ readonly payload: (result: RemoteStateSettled<A, E>) => EventPayload<SettledEvent>
138
+ }
139
+ // Only settled server truth is persisted; the optimistic overlay is a separate store.
140
+ readonly persist?:
141
+ | boolean
142
+ | { readonly key?: string | ((inputs: InputsObject<Inputs>) => string) }
143
+ } & (Gated extends true
144
+ ? { readonly disabled?: (inputs: InputsObject<Inputs>) => boolean }
145
+ : { readonly disabled?: never })
146
+
147
+ export interface SettleLink {
148
+ readonly requested: () => number
149
+ readonly register: (opId: string, waitFor: number) => void
150
+ }
151
+
152
+ export interface WaiterRegistration {
153
+ readonly opId: string
154
+ readonly waitFor: number
155
+ }
@@ -0,0 +1,104 @@
1
+ import { Context, Effect, Layer, Option, Schema } from 'effect'
2
+ import { expect, it } from '@effect/vitest'
3
+ import * as Composition from '../compose/composition'
4
+ import { mount } from '../compose/structure'
5
+ import { ui } from '../compose/ui'
6
+ import { scene } from '../scene/scene'
7
+ import { flush, until } from '../testkit/flight.testkit'
8
+ import { Engine } from './loop'
9
+ import { makeAppRuntime } from './appRuntime'
10
+
11
+ interface ActivationProbeService {
12
+ readonly acquired: Array<string>
13
+ readonly released: Array<string>
14
+ }
15
+
16
+ class ActivationProbe extends Context.Tag('appRuntime.ActivationProbe')<
17
+ ActivationProbe,
18
+ ActivationProbeService
19
+ >() {}
20
+
21
+ class ScopedUi extends ui('appRuntime.ScopedUi')<{
22
+ props: { readonly id: string }
23
+ }>() {}
24
+
25
+ class ScopedComposition extends Composition.make('appRuntime.Scoped', {
26
+ title: 'Scoped',
27
+ props: Schema.Struct({ id: Schema.String }),
28
+ ui: ScopedUi,
29
+ })<ScopedComposition>() {}
30
+
31
+ const ScopedLive = Composition.liveScoped(ScopedComposition, {
32
+ render: function* () {
33
+ const { id } = yield* ScopedComposition.props
34
+ return mount({ props: { id }, slots: {} })
35
+ },
36
+ activate: function* () {
37
+ const { id } = yield* ScopedComposition.props
38
+ const probe = yield* ActivationProbe
39
+ yield* Effect.acquireRelease(
40
+ Effect.sync(() => probe.acquired.push(id)),
41
+ () => Effect.sync(() => probe.released.push(id)),
42
+ )
43
+ },
44
+ })
45
+
46
+ class PlainUi extends ui('appRuntime.PlainUi')<{ props: {} }>() {}
47
+ class PlainComposition extends Composition.make('appRuntime.Plain', {
48
+ title: 'Plain',
49
+ ui: PlainUi,
50
+ })<PlainComposition>() {}
51
+
52
+ const PlainLive = Composition.live(PlainComposition, function* () {
53
+ return mount({ props: {}, slots: {} })
54
+ })
55
+
56
+ const makeScene = (probe: ActivationProbeService) =>
57
+ scene(ScopedComposition, {
58
+ provide: [
59
+ Layer.mergeAll(ScopedLive, PlainLive).pipe(
60
+ Layer.provideMerge(Layer.succeed(ActivationProbe, probe)),
61
+ Layer.provideMerge(Engine),
62
+ ),
63
+ ],
64
+ })
65
+
66
+ // Wait for the count, then quiesce: the exact `toEqual` is what proves no extra
67
+ // acquire/release slipped in behind it.
68
+ const settledAt = (read: () => ReadonlyArray<string>, count: number): Effect.Effect<void> =>
69
+ until(read, (entries) => entries.length >= count).pipe(Effect.zipRight(flush()))
70
+
71
+ it.scopedLive(
72
+ 'keeps activation lazy, scopes sibling mounts independently, and closes them at teardown',
73
+ () =>
74
+ Effect.gen(function* () {
75
+ const probe: ActivationProbeService = { acquired: [], released: [] }
76
+ const app = makeAppRuntime(makeScene(probe))
77
+ yield* Effect.addFinalizer(() => Effect.sync(() => app.dispose()))
78
+ const service = app.read(ScopedComposition.tag)
79
+ const plain = app.read(PlainComposition.tag)
80
+
81
+ yield* Composition.render(service, {
82
+ props: { id: 'first' },
83
+ tracker: { add: () => undefined },
84
+ })
85
+ expect(probe.acquired).toEqual([])
86
+ expect(Option.isNone(Composition.activation(plain, {}))).toBe(true)
87
+
88
+ const first = app.mountScoped(
89
+ Option.getOrThrow(Composition.activation(service, { id: 'first' })),
90
+ )
91
+ app.mountScoped(Option.getOrThrow(Composition.activation(service, { id: 'second' })))
92
+ yield* settledAt(() => probe.acquired, 2)
93
+ expect(probe.acquired).toEqual(['first', 'second'])
94
+
95
+ first()
96
+ first()
97
+ yield* settledAt(() => probe.released, 1)
98
+ expect(probe.released).toEqual(['first'])
99
+
100
+ app.dispose()
101
+ yield* settledAt(() => probe.released, 2)
102
+ expect(probe.released).toEqual(['first', 'second'])
103
+ }),
104
+ )