@playfast/reform 0.0.7 → 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.
- package/package.json +2 -2
- package/src/boundary/boundary.test.ts +301 -0
- package/src/boundary/boundary.ts +192 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncCalc.ts +267 -0
- package/src/calc/asyncData.ts +96 -0
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calc.ts +120 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/calcFamily.ts +194 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/calc/compose.ts +69 -0
- package/src/channel/channel.ts +269 -0
- package/src/compose/composition.ts +112 -0
- package/{dist/compose/host.d.ts → src/compose/host.ts} +9 -8
- package/{dist/compose/props.js → src/compose/props.ts} +7 -5
- package/src/compose/provide.ts +70 -0
- package/{dist/compose/slot.d.ts → src/compose/slot.ts} +36 -19
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +226 -0
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +76 -0
- package/src/event/event.test.ts +23 -0
- package/src/event/event.ts +65 -0
- package/src/event/eventGroup.ts +14 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/{dist/feature/feature.d.ts → src/feature/feature.ts} +178 -92
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +176 -0
- package/{dist/internal/capture.d.ts → src/internal/capture.ts} +13 -11
- package/{dist/internal/ctx.d.ts → src/internal/ctx.ts} +8 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +145 -0
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/inspect.ts +34 -0
- package/src/internal/queryDriver.ts +247 -0
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/reuse.ts +73 -0
- package/src/internal/scheduler.ts +93 -0
- package/{dist/internal/seeds.d.ts → src/internal/seeds.ts} +6 -3
- package/src/internal/sources.ts +104 -0
- package/{dist/internal/stateRegistry.js → src/internal/stateRegistry.ts} +18 -14
- package/src/internal/store.test.ts +80 -0
- package/src/internal/store.ts +105 -0
- package/{dist/internal/track.d.ts → src/internal/track.ts} +17 -11
- package/src/procedure/procedure.ts +89 -0
- package/src/reducer/reducer.ts +137 -0
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.ts +571 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +40 -0
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +171 -0
- package/src/scene/scene.ts +76 -0
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +94 -0
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +174 -0
- package/src/state/stateGroup.ts +105 -0
- package/{dist/state/token.d.ts → src/state/token.ts} +22 -13
- package/src/synced/syncedStore.ts +99 -0
- package/{dist/ui/node.d.ts → src/ui/node.ts} +3 -3
- package/{dist/ui/trigger.d.ts → src/ui/trigger.ts} +1 -2
- package/src/wire/tree.test.ts +81 -0
- package/src/wire/tree.ts +129 -0
- package/src/wire/triggers.test.ts +76 -0
- package/src/wire/triggers.ts +98 -0
- package/dist/boundary/boundary.d.ts +0 -72
- package/dist/boundary/boundary.d.ts.map +0 -1
- package/dist/boundary/boundary.js +0 -83
- package/dist/boundary/boundary.js.map +0 -1
- package/dist/calc/asyncCalc.d.ts +0 -91
- package/dist/calc/asyncCalc.d.ts.map +0 -1
- package/dist/calc/asyncCalc.js +0 -95
- package/dist/calc/asyncCalc.js.map +0 -1
- package/dist/calc/asyncData.d.ts +0 -57
- package/dist/calc/asyncData.d.ts.map +0 -1
- package/dist/calc/asyncData.js +0 -34
- package/dist/calc/asyncData.js.map +0 -1
- package/dist/calc/calc.d.ts +0 -57
- package/dist/calc/calc.d.ts.map +0 -1
- package/dist/calc/calc.js +0 -58
- package/dist/calc/calc.js.map +0 -1
- package/dist/calc/calcFamily.d.ts +0 -57
- package/dist/calc/calcFamily.d.ts.map +0 -1
- package/dist/calc/calcFamily.js +0 -124
- package/dist/calc/calcFamily.js.map +0 -1
- package/dist/calc/compose.d.ts +0 -16
- package/dist/calc/compose.d.ts.map +0 -1
- package/dist/calc/compose.js +0 -7
- package/dist/calc/compose.js.map +0 -1
- package/dist/channel/channel.d.ts +0 -115
- package/dist/channel/channel.d.ts.map +0 -1
- package/dist/channel/channel.js +0 -138
- package/dist/channel/channel.js.map +0 -1
- package/dist/compose/composition.d.ts +0 -72
- package/dist/compose/composition.d.ts.map +0 -1
- package/dist/compose/composition.js +0 -46
- package/dist/compose/composition.js.map +0 -1
- package/dist/compose/host.d.ts.map +0 -1
- package/dist/compose/host.js +0 -6
- package/dist/compose/host.js.map +0 -1
- package/dist/compose/props.d.ts +0 -13
- package/dist/compose/props.d.ts.map +0 -1
- package/dist/compose/props.js.map +0 -1
- package/dist/compose/provide.d.ts +0 -22
- package/dist/compose/provide.d.ts.map +0 -1
- package/dist/compose/provide.js +0 -28
- package/dist/compose/provide.js.map +0 -1
- package/dist/compose/slot.d.ts.map +0 -1
- package/dist/compose/slot.js +0 -23
- package/dist/compose/slot.js.map +0 -1
- package/dist/compose/ui.d.ts +0 -135
- package/dist/compose/ui.d.ts.map +0 -1
- package/dist/compose/ui.js +0 -63
- package/dist/compose/ui.js.map +0 -1
- package/dist/definition/definition.d.ts +0 -33
- package/dist/definition/definition.d.ts.map +0 -1
- package/dist/definition/definition.js +0 -42
- package/dist/definition/definition.js.map +0 -1
- package/dist/event/event.d.ts +0 -33
- package/dist/event/event.d.ts.map +0 -1
- package/dist/event/event.js +0 -30
- package/dist/event/event.js.map +0 -1
- package/dist/event/eventGroup.d.ts +0 -9
- package/dist/event/eventGroup.d.ts.map +0 -1
- package/dist/event/eventGroup.js +0 -4
- package/dist/event/eventGroup.js.map +0 -1
- package/dist/feature/feature.d.ts.map +0 -1
- package/dist/feature/feature.js +0 -98
- package/dist/feature/feature.js.map +0 -1
- package/dist/index.d.ts +0 -53
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -62
- package/dist/index.js.map +0 -1
- package/dist/internal/capture.d.ts.map +0 -1
- package/dist/internal/capture.js +0 -12
- package/dist/internal/capture.js.map +0 -1
- package/dist/internal/ctx.d.ts.map +0 -1
- package/dist/internal/ctx.js +0 -2
- package/dist/internal/ctx.js.map +0 -1
- package/dist/internal/errors.d.ts +0 -89
- package/dist/internal/errors.d.ts.map +0 -1
- package/dist/internal/errors.js +0 -102
- package/dist/internal/errors.js.map +0 -1
- package/dist/internal/inspect.d.ts +0 -17
- package/dist/internal/inspect.d.ts.map +0 -1
- package/dist/internal/inspect.js +0 -32
- package/dist/internal/inspect.js.map +0 -1
- package/dist/internal/queryDriver.d.ts +0 -65
- package/dist/internal/queryDriver.d.ts.map +0 -1
- package/dist/internal/queryDriver.js +0 -134
- package/dist/internal/queryDriver.js.map +0 -1
- package/dist/internal/reuse.d.ts +0 -10
- package/dist/internal/reuse.d.ts.map +0 -1
- package/dist/internal/reuse.js +0 -68
- package/dist/internal/reuse.js.map +0 -1
- package/dist/internal/scheduler.d.ts +0 -47
- package/dist/internal/scheduler.d.ts.map +0 -1
- package/dist/internal/scheduler.js +0 -70
- package/dist/internal/scheduler.js.map +0 -1
- package/dist/internal/seeds.d.ts.map +0 -1
- package/dist/internal/seeds.js +0 -17
- package/dist/internal/seeds.js.map +0 -1
- package/dist/internal/sources.d.ts +0 -39
- package/dist/internal/sources.d.ts.map +0 -1
- package/dist/internal/sources.js +0 -59
- package/dist/internal/sources.js.map +0 -1
- package/dist/internal/stateRegistry.d.ts +0 -2
- package/dist/internal/stateRegistry.d.ts.map +0 -1
- package/dist/internal/stateRegistry.js.map +0 -1
- package/dist/internal/store.d.ts +0 -47
- package/dist/internal/store.d.ts.map +0 -1
- package/dist/internal/store.js +0 -73
- package/dist/internal/store.js.map +0 -1
- package/dist/internal/track.d.ts.map +0 -1
- package/dist/internal/track.js +0 -19
- package/dist/internal/track.js.map +0 -1
- package/dist/procedure/procedure.d.ts +0 -40
- package/dist/procedure/procedure.d.ts.map +0 -1
- package/dist/procedure/procedure.js +0 -50
- package/dist/procedure/procedure.js.map +0 -1
- package/dist/reducer/reducer.d.ts +0 -44
- package/dist/reducer/reducer.d.ts.map +0 -1
- package/dist/reducer/reducer.js +0 -63
- package/dist/reducer/reducer.js.map +0 -1
- package/dist/remote/remoteState.d.ts +0 -119
- package/dist/remote/remoteState.d.ts.map +0 -1
- package/dist/remote/remoteState.js +0 -270
- package/dist/remote/remoteState.js.map +0 -1
- package/dist/runtime/bus.d.ts +0 -27
- package/dist/runtime/bus.d.ts.map +0 -1
- package/dist/runtime/bus.js +0 -21
- package/dist/runtime/bus.js.map +0 -1
- package/dist/runtime/loop.d.ts +0 -45
- package/dist/runtime/loop.d.ts.map +0 -1
- package/dist/runtime/loop.js +0 -117
- package/dist/runtime/loop.js.map +0 -1
- package/dist/scene/scene.d.ts +0 -44
- package/dist/scene/scene.d.ts.map +0 -1
- package/dist/scene/scene.js +0 -31
- package/dist/scene/scene.js.map +0 -1
- package/dist/state/state.d.ts +0 -37
- package/dist/state/state.d.ts.map +0 -1
- package/dist/state/state.js +0 -46
- package/dist/state/state.js.map +0 -1
- package/dist/state/stateFamily.d.ts +0 -79
- package/dist/state/stateFamily.d.ts.map +0 -1
- package/dist/state/stateFamily.js +0 -99
- package/dist/state/stateFamily.js.map +0 -1
- package/dist/state/stateGroup.d.ts +0 -50
- package/dist/state/stateGroup.d.ts.map +0 -1
- package/dist/state/stateGroup.js +0 -54
- package/dist/state/stateGroup.js.map +0 -1
- package/dist/state/token.d.ts.map +0 -1
- package/dist/state/token.js +0 -20
- package/dist/state/token.js.map +0 -1
- package/dist/synced/syncedStore.d.ts +0 -51
- package/dist/synced/syncedStore.d.ts.map +0 -1
- package/dist/synced/syncedStore.js +0 -43
- package/dist/synced/syncedStore.js.map +0 -1
- package/dist/ui/node.d.ts.map +0 -1
- package/dist/ui/node.js +0 -2
- package/dist/ui/node.js.map +0 -1
- package/dist/ui/trigger.d.ts.map +0 -1
- package/dist/ui/trigger.js +0 -2
- package/dist/ui/trigger.js.map +0 -1
- package/dist/wire/tree.d.ts +0 -66
- package/dist/wire/tree.d.ts.map +0 -1
- package/dist/wire/tree.js +0 -60
- package/dist/wire/tree.js.map +0 -1
- package/dist/wire/triggers.d.ts +0 -44
- package/dist/wire/triggers.d.ts.map +0 -1
- package/dist/wire/triggers.js +0 -31
- package/dist/wire/triggers.js.map +0 -1
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import { Context } from 'effect'
|
|
2
|
-
import type { Trigger } from '../ui/trigger'
|
|
1
|
+
import { Context } from 'effect'
|
|
2
|
+
import type { Trigger } from '../ui/trigger'
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
5
|
* A single rendered UI node's observable surface: which contract rendered, the
|
|
5
6
|
* props it received, and the event triggers it was handed. This is exactly what
|
|
6
7
|
* a headless inspector (the proof facade, the dev tool) needs — no DOM, no JSX.
|
|
7
8
|
*/
|
|
8
9
|
export interface UiCapture {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly props: unknown
|
|
12
|
+
readonly events: Record<string, Trigger<unknown>>
|
|
12
13
|
}
|
|
14
|
+
|
|
13
15
|
/** Where captured renders are reported, when a capturing host is present. */
|
|
14
16
|
export interface CaptureSinkApi {
|
|
15
|
-
|
|
17
|
+
record(capture: UiCapture): void
|
|
16
18
|
}
|
|
19
|
+
|
|
17
20
|
/**
|
|
18
21
|
* An OPTIONAL service. Core's `ui` read consults it via `serviceOption`: when a
|
|
19
22
|
* capturing host provides it (proofs, the dev tool), every resolved view reports
|
|
@@ -21,8 +24,7 @@ export interface CaptureSinkApi {
|
|
|
21
24
|
* replacing any presentation. Production never provides it, so nothing is
|
|
22
25
|
* recorded and there is no cost beyond one `serviceOption` lookup per render.
|
|
23
26
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export {}
|
|
28
|
-
//# sourceMappingURL=capture.d.ts.map
|
|
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 {}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Effect } from 'effect'
|
|
2
|
-
import type { YieldWrap } from 'effect/Utils'
|
|
1
|
+
import type { Effect } from 'effect'
|
|
2
|
+
import type { YieldWrap } from 'effect/Utils'
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
5
|
* The context (`R`) a generator body requires, recovered from the effects it
|
|
5
6
|
* yields. Shared by `Composition.live` and `Procedure.live`, whose bodies are
|
|
@@ -8,5 +9,8 @@ import type { YieldWrap } from 'effect/Utils';
|
|
|
8
9
|
* A body that never yields (`Eff = never`) requires nothing — guard that case
|
|
9
10
|
* first, or `infer R` resolves to `unknown` and leaks into the layer's `RIn`.
|
|
10
11
|
*/
|
|
11
|
-
export type Ctx<Eff> = [Eff] extends [never]
|
|
12
|
-
|
|
12
|
+
export type Ctx<Eff> = [Eff] extends [never]
|
|
13
|
+
? never
|
|
14
|
+
: [Eff] extends [YieldWrap<Effect.Effect<any, any, infer R>>]
|
|
15
|
+
? R
|
|
16
|
+
: never
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Cause, Data, Option, Runtime } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tagged errors for the framework's synchronous boundaries. Even when thrown
|
|
5
|
+
* (React render, layer build, reflection), an error carries a `_tag` and its
|
|
6
|
+
* structured fields — never a bare `Error` — so callers and tooling can match on
|
|
7
|
+
* it. `Data.TaggedError` subclasses are real `Error`s, so `throw` still works.
|
|
8
|
+
*/
|
|
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
|
+
|
|
27
|
+
/** A `<slots.X/>` was rendered without a render-target host to realize it. */
|
|
28
|
+
export class SlotRenderingUnavailable extends SlotRenderingUnavailableBase {
|
|
29
|
+
override get message(): string {
|
|
30
|
+
return `reform: rendering slot '${this.slot}' requires @reform/react`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const InvalidProvideTargetBase: EmptyTaggedErrorClass<'reform/InvalidProvideTarget'> =
|
|
35
|
+
Data.TaggedError('reform/InvalidProvideTarget')<{}>
|
|
36
|
+
|
|
37
|
+
/** `provide(target, …)` was handed something that is neither a ui contract nor a slot. */
|
|
38
|
+
export class InvalidProvideTarget extends InvalidProvideTargetBase {
|
|
39
|
+
override get message(): string {
|
|
40
|
+
return 'reform: provide target is neither a ui contract nor a slot'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const UnknownGroupStateBase: TaggedErrorClass<'reform/UnknownGroupState', { readonly name: string }> =
|
|
45
|
+
Data.TaggedError('reform/UnknownGroupState')<{ readonly name: string }>
|
|
46
|
+
|
|
47
|
+
/** `StateGroup.select(group, name)` named a state the group does not contain. */
|
|
48
|
+
export class UnknownGroupState extends UnknownGroupStateBase {
|
|
49
|
+
override get message(): string {
|
|
50
|
+
return `reform: unknown state '${this.name}' in group`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const FeatureLoadFailedBase: TaggedErrorClass<'reform/FeatureLoadFailed', { readonly cause: unknown }> =
|
|
55
|
+
Data.TaggedError('reform/FeatureLoadFailed')<{ readonly cause: unknown }>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A lazy `Feature`'s `load` Effect failed — the dynamic `import()` rejected (a
|
|
59
|
+
* missing chunk, a network failure, a module-eval throw). Surfaced as a typed
|
|
60
|
+
* failure the host hands to the feature's `placeholder.failed`, never a bare
|
|
61
|
+
* rejection. `cause` is the original rejection value.
|
|
62
|
+
*/
|
|
63
|
+
export class FeatureLoadFailed extends FeatureLoadFailedBase {
|
|
64
|
+
override get message(): string {
|
|
65
|
+
return `reform: a feature failed to load (${String(this.cause)})`
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const AsyncReducerBase: EmptyTaggedErrorClass<'reform/AsyncReducer'> =
|
|
70
|
+
Data.TaggedError('reform/AsyncReducer')<{}>
|
|
71
|
+
|
|
72
|
+
/** A reducer fold returned a thenable — folds must be pure synchronous writes. */
|
|
73
|
+
export class AsyncReducer extends AsyncReducerBase {
|
|
74
|
+
override get message(): string {
|
|
75
|
+
return 'reform: a reducer fold must be synchronous (it returned a Promise)'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
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
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Two distinct primitives were registered under one name into the same runtime —
|
|
137
|
+
* a silent footgun, since names key DI tags and channel lanes. Thrown for a
|
|
138
|
+
* channel name reused with a different policy (the second would otherwise be
|
|
139
|
+
* dropped). Reducer/procedure name clashes are warned (logged), not thrown.
|
|
140
|
+
*/
|
|
141
|
+
export class DuplicateRegistration extends DuplicateRegistrationBase {
|
|
142
|
+
override get message(): string {
|
|
143
|
+
return `reform: duplicate ${this.kind} registered under name '${this.name}'`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { format, type Inspectable, NodeInspectSymbol } from 'effect/Inspectable'
|
|
2
|
+
|
|
3
|
+
export { type Inspectable, NodeInspectSymbol }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `Inspectable` trio built around a `toJSON`, so every reform value prints
|
|
7
|
+
* like an Effect value — `_id`-tagged JSON under `console.log`, Node's
|
|
8
|
+
* inspector, `JSON.stringify`, and `String(...)` — instead of `[object Object]`.
|
|
9
|
+
* Mirrors Effect's `BaseProto` / `Inspectable.Class` (`Inspectable.ts:170`).
|
|
10
|
+
*/
|
|
11
|
+
export const inspectable = (toJSON: () => unknown): Inspectable => ({
|
|
12
|
+
toJSON,
|
|
13
|
+
toString: () => format(toJSON()),
|
|
14
|
+
[NodeInspectSymbol]: () => toJSON(),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attach the `Inspectable` trio to an existing object as *non-enumerable* own
|
|
19
|
+
* properties (so they don't show up in `Object.keys`/`for…in` over a definition
|
|
20
|
+
* class). Used where the value is built by other means — the definition classes,
|
|
21
|
+
* whose statics are merged in separately.
|
|
22
|
+
*/
|
|
23
|
+
export const attachInspectable = <T extends object>(target: T, toJSON: () => unknown): T => {
|
|
24
|
+
const trio = inspectable(toJSON) as unknown as Record<PropertyKey, unknown>
|
|
25
|
+
for (const key of Reflect.ownKeys(trio)) {
|
|
26
|
+
Object.defineProperty(target, key, {
|
|
27
|
+
value: trio[key],
|
|
28
|
+
enumerable: false,
|
|
29
|
+
writable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
return target
|
|
34
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Brand,
|
|
3
|
+
Cause,
|
|
4
|
+
Chunk,
|
|
5
|
+
Effect,
|
|
6
|
+
MutableRef,
|
|
7
|
+
Option,
|
|
8
|
+
Queue,
|
|
9
|
+
Schema,
|
|
10
|
+
type Scope,
|
|
11
|
+
Stream,
|
|
12
|
+
} from 'effect'
|
|
13
|
+
import { type AnyAsyncData, AsyncData } from '../calc/asyncData'
|
|
14
|
+
import { reuse } from './reuse'
|
|
15
|
+
import { resolveScheduler } from './scheduler'
|
|
16
|
+
import {
|
|
17
|
+
type InputsObject,
|
|
18
|
+
type InputStores,
|
|
19
|
+
type InvalidateBy,
|
|
20
|
+
sameKey,
|
|
21
|
+
wireSources,
|
|
22
|
+
} from './sources'
|
|
23
|
+
import { makeStore, type Store } from './store'
|
|
24
|
+
import { type AnySource } from '../state/token'
|
|
25
|
+
|
|
26
|
+
// The query-lifecycle driver shared by `AsyncCalc.live` and `RemoteState.live`:
|
|
27
|
+
// sense the inputs, run the query latest-wins (or trailing-conflated), fold the
|
|
28
|
+
// result into an `AnyAsyncData` store with stale-while-revalidate semantics.
|
|
29
|
+
// Extracted so both primitives run the exact same machinery — the asyncCalc
|
|
30
|
+
// test suite is the driver's regression suite.
|
|
31
|
+
|
|
32
|
+
/** `Gated` (whether the `Idle` arm exists) is the inverse of `alwaysOn`. */
|
|
33
|
+
export type GatedOf<AlwaysOn extends boolean> = AlwaysOn extends true ? false : true
|
|
34
|
+
|
|
35
|
+
/** The runtime gated flag for an `alwaysOn` config, typed as its `Gated` literal. */
|
|
36
|
+
export const gatedFlag = <AlwaysOn extends boolean>(alwaysOn: AlwaysOn | undefined): GatedOf<AlwaysOn> =>
|
|
37
|
+
(alwaysOn !== true) as GatedOf<AlwaysOn>
|
|
38
|
+
|
|
39
|
+
// The hidden revision behind `invalidateOn` — a module-local *branded* number.
|
|
40
|
+
// Reform store services are type-identified (`Context.GenericTag<Store<A>>`), so
|
|
41
|
+
// the hidden state's value type must be un-collidable: `Layer.provide` erases
|
|
42
|
+
// exactly `Store<Revision>` from the returned layer and can never type-level
|
|
43
|
+
// erase a user input that happens to be a number state. Never exported from the
|
|
44
|
+
// package index, so the editor catalog (which only indexes module exports)
|
|
45
|
+
// never sees it.
|
|
46
|
+
export type Revision = number & Brand.Brand<'reform/Revision'>
|
|
47
|
+
export const Revision: Brand.Brand.Constructor<Revision> = Brand.nominal<Revision>()
|
|
48
|
+
export const RevisionSchema: Schema.Schema<Revision, number> = Schema.Number.pipe(
|
|
49
|
+
Schema.brand('reform/Revision'),
|
|
50
|
+
)
|
|
51
|
+
export const revisionZero: Revision = Revision(0)
|
|
52
|
+
export const bumpRevision = (r: Revision): Revision => Revision(r + 1)
|
|
53
|
+
|
|
54
|
+
export interface QueryDriverOptions<Inputs extends ReadonlyArray<AnySource>, A, E, R> {
|
|
55
|
+
/** The owning definition's name — only for the defect log line. */
|
|
56
|
+
readonly name: string
|
|
57
|
+
/** The owning primitive's kind label — only for the defect log line. */
|
|
58
|
+
readonly label: string
|
|
59
|
+
/** Whether `disabled` is honored / the `Idle` arm can occur. */
|
|
60
|
+
readonly gated: boolean
|
|
61
|
+
readonly inputs: Inputs
|
|
62
|
+
readonly query: (inputs: InputsObject<Inputs>) => Effect.Effect<A, E, R>
|
|
63
|
+
readonly invalidateBy?: InvalidateBy<Inputs> | undefined
|
|
64
|
+
readonly disabled?: ((inputs: InputsObject<Inputs>) => boolean) | undefined
|
|
65
|
+
readonly coalesce?: 'switch' | 'trailing' | undefined
|
|
66
|
+
readonly reuse?: boolean | undefined
|
|
67
|
+
/**
|
|
68
|
+
* An extra key segment + wake source beyond the declared inputs — the hidden
|
|
69
|
+
* `invalidateOn` revision. Kept out of `wireSources` so the user's `query`
|
|
70
|
+
* and `invalidateBy` see exactly the declared inputs.
|
|
71
|
+
*/
|
|
72
|
+
readonly extraKey?:
|
|
73
|
+
| {
|
|
74
|
+
readonly read: () => unknown
|
|
75
|
+
readonly subscribe: (listener: () => void) => () => void
|
|
76
|
+
}
|
|
77
|
+
| undefined
|
|
78
|
+
/**
|
|
79
|
+
* Fired when a run's `Success` value has landed in the store (same scheduler
|
|
80
|
+
* flush — never before the converged value is readable), with the run's
|
|
81
|
+
* generation. Not fired on `Error`, interrupt, or while disabled. The seam
|
|
82
|
+
* `RemoteState` settles pending intents through.
|
|
83
|
+
*/
|
|
84
|
+
readonly onSettled?: ((generation: number) => void) | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface QueryDriver<A, E> {
|
|
88
|
+
/** The lifecycle store (the full union — callers narrow to their arms). */
|
|
89
|
+
readonly store: Store<AnyAsyncData<A, E>>
|
|
90
|
+
/**
|
|
91
|
+
* The highest generation requested so far (0 before the first kick).
|
|
92
|
+
* Generations are assigned when a run is REQUESTED (enqueued), so a waiter
|
|
93
|
+
* registered as `requested() + 1` is satisfied only by a run that began
|
|
94
|
+
* after registration — the settle-ordering rule `RemoteState` builds on.
|
|
95
|
+
*/
|
|
96
|
+
readonly requested: () => number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the driver: store + subscription + request queue + run fiber, all owned
|
|
101
|
+
* by the ambient scope (callers run this under `Layer.scoped`). A change to an
|
|
102
|
+
* input re-runs the query latest-wins (a new run cancels the in-flight one; or,
|
|
103
|
+
* with `coalesce: 'trailing'`, lets it finish and runs one trailing refetch);
|
|
104
|
+
* while a re-run is in flight the last `Success`/`Error` is kept with
|
|
105
|
+
* `refetching: true`.
|
|
106
|
+
*/
|
|
107
|
+
export const makeQueryDriver = <Inputs extends ReadonlyArray<AnySource>, A, E, R>(
|
|
108
|
+
options: QueryDriverOptions<Inputs, A, E, R>,
|
|
109
|
+
): Effect.Effect<QueryDriver<A, E>, never, InputStores<Inputs> | R | Scope.Scope> =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const scheduler = yield* resolveScheduler
|
|
112
|
+
const sources = yield* wireSources(options.inputs, options.invalidateBy)
|
|
113
|
+
const extraKey = options.extraKey
|
|
114
|
+
|
|
115
|
+
const keyOf = (args: InputsObject<Inputs>): ReadonlyArray<unknown> =>
|
|
116
|
+
extraKey === undefined ? sources.keyOf(args) : [...sources.keyOf(args), extraKey.read()]
|
|
117
|
+
const subscribeAll = (listener: () => void): (() => void) => {
|
|
118
|
+
const offSources = sources.subscribe(listener)
|
|
119
|
+
const offExtra = extraKey?.subscribe(listener)
|
|
120
|
+
return () => {
|
|
121
|
+
offSources()
|
|
122
|
+
offExtra?.()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const disabledNow = (args: InputsObject<Inputs> = sources.snapshot()): boolean =>
|
|
126
|
+
options.gated && options.disabled !== undefined ? options.disabled(args) : false
|
|
127
|
+
|
|
128
|
+
const store = makeStore<AnyAsyncData<A, E>>(
|
|
129
|
+
disabledNow() ? AsyncData.idle : AsyncData.loading,
|
|
130
|
+
scheduler,
|
|
131
|
+
)
|
|
132
|
+
// The last requested key, so a change that doesn't move it (or only churns
|
|
133
|
+
// an input `invalidateBy` ignores) doesn't re-fetch.
|
|
134
|
+
const lastKey = MutableRef.make<ReadonlyArray<unknown> | undefined>(undefined)
|
|
135
|
+
// The run-generation counter: bumped when a run is REQUESTED (enqueued), so
|
|
136
|
+
// `requested()` names the newest run that could possibly be in flight.
|
|
137
|
+
const generation = MutableRef.make(0)
|
|
138
|
+
const nextGeneration = (): number => {
|
|
139
|
+
MutableRef.update(generation, (n) => n + 1)
|
|
140
|
+
return MutableRef.get(generation)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Mark a re-fetch in flight without dropping the visible value (SWR).
|
|
144
|
+
const markRefetching = () => {
|
|
145
|
+
const prev = store.get()
|
|
146
|
+
if (prev._tag === 'Success') store.set(AsyncData.success(prev.value, true))
|
|
147
|
+
else if (prev._tag === 'Error') store.set(AsyncData.error(prev.error, true))
|
|
148
|
+
else store.set(AsyncData.loading)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One run of the query, folded into the store. A failure becomes `Error`; an
|
|
152
|
+
// interrupt (latest-wins cancel) leaves the state untouched; a defect (a bug
|
|
153
|
+
// in the body) is logged and isolated — the driver keeps running. A `Success`
|
|
154
|
+
// additionally reports its generation through `onSettled`, after the write,
|
|
155
|
+
// so settle-driven consequences observe the converged value.
|
|
156
|
+
const runQuery = (request: Request): Effect.Effect<void, never, R> =>
|
|
157
|
+
options.query(request.args).pipe(
|
|
158
|
+
Effect.tapDefect((defect) =>
|
|
159
|
+
Effect.logError(`reform: ${options.label} '${options.name}' query defect`, defect),
|
|
160
|
+
),
|
|
161
|
+
Effect.matchCause({
|
|
162
|
+
onSuccess: (value) => {
|
|
163
|
+
if (!disabledNow()) {
|
|
164
|
+
// `reuse`: share unchanged subtrees with the previous Success
|
|
165
|
+
// value, so a refetch that barely moved keeps identities stable.
|
|
166
|
+
const prev = store.get()
|
|
167
|
+
const shared =
|
|
168
|
+
options.reuse === true && prev._tag === 'Success'
|
|
169
|
+
? reuse(prev.value, value)
|
|
170
|
+
: value
|
|
171
|
+
store.set(AsyncData.success(shared, false))
|
|
172
|
+
options.onSettled?.(request.generation)
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
onFailure: (cause) => {
|
|
176
|
+
const failure = Cause.failureOption(cause)
|
|
177
|
+
if (Option.isSome(failure) && !disabledNow()) {
|
|
178
|
+
store.set(AsyncData.error(failure.value, false))
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
interface Request {
|
|
185
|
+
readonly args: InputsObject<Inputs>
|
|
186
|
+
readonly generation: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The driver. `'switch'` (default): each new request cancels the in-flight
|
|
190
|
+
// run — the same latest-wins semantics a `latest` channel gives procedures.
|
|
191
|
+
// `'trailing'`: a strictly sequential consumer that, on wake, drains every
|
|
192
|
+
// request that piled up during the flight and runs the LATEST one —
|
|
193
|
+
// "exactly one trailing run after settle" by construction. The trailing
|
|
194
|
+
// run's generation is the max drained (the latest request's), so it
|
|
195
|
+
// vouches for every request it conflated.
|
|
196
|
+
const trailing = options.coalesce === 'trailing'
|
|
197
|
+
const requests = yield* Queue.unbounded<Request>()
|
|
198
|
+
yield* Effect.forkScoped(
|
|
199
|
+
trailing
|
|
200
|
+
? Effect.forever(
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const first = yield* Queue.take(requests)
|
|
203
|
+
const queued = yield* Queue.takeAll(requests)
|
|
204
|
+
const request = Option.getOrElse(Chunk.last(queued), () => first)
|
|
205
|
+
// A disable that landed while the request waited: skip the run.
|
|
206
|
+
if (!disabledNow()) yield* runQuery(request)
|
|
207
|
+
// The settle may have written a stale-key result with
|
|
208
|
+
// `refetching: false`; if a newer request is already waiting,
|
|
209
|
+
// restore the syncing flag before the next take — both writes
|
|
210
|
+
// coalesce into one scheduler flush for subscribers.
|
|
211
|
+
const pending = yield* Queue.size(requests)
|
|
212
|
+
if (pending > 0 && !disabledNow()) yield* Effect.sync(markRefetching)
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
: Stream.fromQueue(requests).pipe(
|
|
216
|
+
Stream.flatMap((request) => Stream.fromEffect(runQuery(request)), { switch: true }),
|
|
217
|
+
Stream.runDrain,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const trigger = () => {
|
|
222
|
+
const args = sources.snapshot()
|
|
223
|
+
if (disabledNow(args)) {
|
|
224
|
+
// Switched off: show Idle and forget the key so re-enabling always re-runs.
|
|
225
|
+
MutableRef.set(lastKey, undefined)
|
|
226
|
+
store.set(AsyncData.idle)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const key = keyOf(args)
|
|
230
|
+
const previous = MutableRef.get(lastKey)
|
|
231
|
+
if (previous !== undefined && sameKey(key, previous)) return
|
|
232
|
+
MutableRef.set(lastKey, key)
|
|
233
|
+
markRefetching()
|
|
234
|
+
Queue.unsafeOffer(requests, { args, generation: nextGeneration() })
|
|
235
|
+
}
|
|
236
|
+
const unsubscribe = subscribeAll(trigger)
|
|
237
|
+
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
|
238
|
+
|
|
239
|
+
// Kick off the first fetch unless the query starts disabled.
|
|
240
|
+
const initial = sources.snapshot()
|
|
241
|
+
if (!disabledNow(initial)) {
|
|
242
|
+
MutableRef.set(lastKey, keyOf(initial))
|
|
243
|
+
Queue.unsafeOffer(requests, { args: initial, generation: nextGeneration() })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { store, requested: () => MutableRef.get(generation) }
|
|
247
|
+
})
|
|
@@ -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
|
+
})
|