@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.
- package/package.json +31 -10
- package/src/boundary/boundary.test.ts +301 -0
- package/src/calc/asyncCalc.test.ts +556 -0
- package/src/calc/asyncData.ts +9 -1
- package/src/calc/calc.test.ts +287 -0
- package/src/calc/calcFamily.test.ts +206 -0
- package/src/calc/compose.test.ts +68 -0
- package/src/channel/channel.ts +9 -5
- package/src/compose/host.ts +4 -1
- package/src/compose/props.ts +5 -1
- package/src/compose/ui.test.ts +62 -0
- package/src/compose/ui.ts +173 -44
- package/src/compose/ui.typecheck.ts +56 -0
- package/src/definition/definition.ts +2 -0
- package/src/event/event.test.ts +23 -0
- package/src/feature/feature.mount.test.ts +82 -0
- package/src/feature/feature.test.ts +60 -0
- package/src/feature/feature.typecheck.ts +108 -0
- package/src/index.ts +38 -2
- package/src/internal/capture.ts +4 -4
- package/src/internal/errors.test.ts +33 -0
- package/src/internal/errors.ts +92 -16
- package/src/internal/inspect.test.ts +28 -0
- package/src/internal/queryDriver.ts +1 -1
- package/src/internal/reuse.test.ts +116 -0
- package/src/internal/scheduler.ts +3 -1
- package/src/internal/sources.ts +2 -2
- package/src/internal/stateRegistry.ts +32 -0
- package/src/internal/store.test.ts +80 -0
- package/src/internal/track.ts +3 -1
- package/src/remote/remoteState.test.ts +695 -0
- package/src/remote/remoteState.typecheck.ts +195 -0
- package/src/runtime/bus.ts +6 -2
- package/src/runtime/hardening.test.ts +69 -0
- package/src/runtime/loop.test.ts +178 -0
- package/src/runtime/loop.ts +4 -2
- package/src/scene/seedScene.test.ts +169 -0
- package/src/state/state.ts +4 -1
- package/src/state/stateFamily.test.ts +138 -0
- package/src/state/stateFamily.ts +4 -1
- package/src/state/stateGroup.ts +31 -4
- package/src/state/token.ts +5 -4
- package/src/synced/syncedStore.ts +99 -0
- 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/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
|
-
|
|
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
|
|
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'
|
package/src/internal/capture.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
CaptureSink,
|
|
29
|
-
|
|
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
|
+
})
|
package/src/internal/errors.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/src/internal/sources.ts
CHANGED
|
@@ -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
|
|
51
|
-
? input.manifest
|
|
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
|
+
})
|
package/src/internal/track.ts
CHANGED
|
@@ -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
|
-
|
|
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
|