@playfast/reform-remote 1.1.0 → 1.3.0

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 (54) hide show
  1. package/dist/client.d.ts +33 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +97 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/clientBinding.d.ts +18 -0
  6. package/dist/clientBinding.d.ts.map +1 -0
  7. package/dist/clientBinding.js +70 -0
  8. package/dist/clientBinding.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/memory.d.ts +7 -0
  14. package/dist/memory.d.ts.map +1 -0
  15. package/dist/memory.js +21 -0
  16. package/dist/memory.js.map +1 -0
  17. package/dist/react.d.ts +15 -0
  18. package/dist/react.d.ts.map +1 -0
  19. package/dist/react.js +16 -0
  20. package/dist/react.js.map +1 -0
  21. package/dist/server.d.ts +16 -0
  22. package/dist/server.d.ts.map +1 -0
  23. package/dist/server.js +56 -0
  24. package/dist/server.js.map +1 -0
  25. package/dist/transport.d.ts +41 -0
  26. package/dist/transport.d.ts.map +1 -0
  27. package/dist/transport.js +157 -0
  28. package/dist/transport.js.map +1 -0
  29. package/dist/wireNode.d.ts +24 -0
  30. package/dist/wireNode.d.ts.map +1 -0
  31. package/dist/wireNode.js +80 -0
  32. package/dist/wireNode.js.map +1 -0
  33. package/dist/wireRender.d.ts +20 -0
  34. package/dist/wireRender.d.ts.map +1 -0
  35. package/dist/wireRender.js +118 -0
  36. package/dist/wireRender.js.map +1 -0
  37. package/package.json +17 -5
  38. package/src/client-prop-decode.test.ts +98 -0
  39. package/src/client.ts +25 -9
  40. package/src/clientBinding.ts +103 -0
  41. package/src/counterFixtures.ts +142 -0
  42. package/src/fixtures.ts +10 -453
  43. package/src/listFixtures.ts +174 -0
  44. package/src/optionalFixtures.ts +64 -0
  45. package/src/react.ts +14 -3
  46. package/src/server.test.ts +6 -6
  47. package/src/server.ts +11 -349
  48. package/src/slottedFixtures.ts +120 -0
  49. package/src/transport.ts +15 -47
  50. package/src/wire-id-uniqueness.test.ts +287 -0
  51. package/src/wire-identity.test.ts +369 -0
  52. package/src/wire-keyed-identity.test.ts +434 -0
  53. package/src/wireNode.ts +153 -0
  54. package/src/wireRender.ts +249 -0
package/src/client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createElement, Fragment, type ReactNode, useRef } from 'react'
2
- import { Effect, Record as Rec, Schema } from 'effect'
2
+ import { Effect, Option, Record as Rec, Schema } from 'effect'
3
3
  import { type MadeView, Ui, type UiContract, UiViewContract, type ViewImpl } from '@playfast/reform'
4
4
  import {
5
5
  type AnyMadeView,
@@ -71,14 +71,26 @@ const registerView = (view: AnyMadeView): RegisteredRemoteView =>
71
71
  )
72
72
  }
73
73
  const propsSchema = Schema.make<C['props'], unknown>(propsReflection.ast)
74
+ const decodeProps = Schema.decodeUnknownOption(propsSchema)
74
75
  return {
75
76
  name: contract.manifest.name,
77
+ // Props off the wire are untrusted — the envelope types a data prop as
78
+ // `Schema.Unknown`, so version skew against a differently-shaped server first
79
+ // shows up here, inside React's render phase. A throw would take the whole tree
80
+ // down; this node costs itself, which is what an unregistered node name already
81
+ // does one level up.
76
82
  view: (encoded, slots, events) =>
77
- exact(
78
- Schema.decodeUnknownSync(propsSchema)(encoded),
79
- bindSlots<C>(slots),
80
- bindEvents<C>(events),
81
- ),
83
+ Option.match(decodeProps(encoded), {
84
+ onNone: () => {
85
+ Effect.runSync(
86
+ Effect.logWarning(
87
+ `reform-remote: dropped node '${contract.manifest.name}' — props do not match its wire schema`,
88
+ ),
89
+ )
90
+ return null
91
+ },
92
+ onSome: (props) => exact(props, bindSlots<C>(slots), bindEvents<C>(events)),
93
+ }),
82
94
  }
83
95
  },
84
96
  )
@@ -123,9 +135,8 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
123
135
  }
124
136
 
125
137
  const encoded: Record<string, unknown> = Rec.fromEntries(
126
- node.props.flatMap(
127
- (prop): ReadonlyArray<readonly [string, unknown]> =>
128
- prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
138
+ node.props.flatMap((prop): ReadonlyArray<readonly [string, unknown]> =>
139
+ prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
129
140
  ),
130
141
  )
131
142
  const eventEntries = node.props.flatMap(
@@ -155,6 +166,11 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
155
166
  .filter((child) => requestedKey === undefined || child.key === requestedKey)
156
167
  .map((child) =>
157
168
  createElement(WireNodeView, {
169
+ // Reconcile on the semantic key when the server sent one: node ids are
170
+ // positional, so a reorder would otherwise slide item state between rows.
171
+ // Matches the local host, which keys by `placement.key`.
172
+ // The node id, not the app's key: ids are derived from the key and so are
173
+ // stable across a reorder, but unlike a key they are unique by construction.
158
174
  key: child.id,
159
175
  node: child,
160
176
  tree: currentTree,
@@ -0,0 +1,103 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Match, MutableRef, Option, Predicate } from 'effect'
3
+ import { Wire, type WireTree } from '@playfast/reform/internal'
4
+ import {
5
+ type ClientConfig,
6
+ type RemoteContract,
7
+ type RemoteViewSet,
8
+ renderWireTree,
9
+ } from './client'
10
+ import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport'
11
+
12
+ export interface ClientBinding {
13
+ readonly node: () => ReactNode
14
+ readonly subscribe: (listener: () => void) => () => void
15
+ readonly snapshot: () => WireTree
16
+ // Stop notifying React. The wire stays in sync — see `connect`.
17
+ readonly dispose: () => void
18
+ // Done with the connection: drop the transport subscription too. A binding that
19
+ // has been closed never updates again.
20
+ readonly close: () => void
21
+ }
22
+
23
+ export interface ConnectOptions<C extends RemoteContract> {
24
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
25
+ readonly views: RemoteViewSet<C>
26
+ // How long the transport subscription outlives a `dispose()`. See `dispose` below.
27
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent falls back to the built-in default
28
+ readonly releaseWindowMs?: number
29
+ }
30
+
31
+ // oxlint-disable-next-line reform-rules/no-magic-numbers -- the window's length is the value being named
32
+ const defaultReleaseWindowMs = 10_000
33
+
34
+ // Browsers hand back a number, which has no `unref`.
35
+ const detachTimer = (handle: unknown): void => {
36
+ if (Predicate.hasProperty(handle, 'unref') && Predicate.isFunction(handle.unref)) {
37
+ handle.unref()
38
+ }
39
+ }
40
+
41
+ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
42
+ const { transport, views } = options
43
+ const state: { tree: WireTree } = { tree: [] }
44
+ const listeners = new Set<() => void>()
45
+ const config: ClientConfig<C> = {
46
+ views,
47
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
48
+ }
49
+ const receive = (message: ServerMessage): void => {
50
+ state.tree = Match.value(message).pipe(
51
+ Match.tag('Snapshot', ({ tree }) => tree),
52
+ Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
53
+ Match.exhaustive,
54
+ )
55
+ listeners.forEach((listener) => listener())
56
+ }
57
+ // Subscribed for the binding's whole life, not for the span React happens to be
58
+ // listening. Patches are deltas against the server's own baseline and a delete is
59
+ // never re-sent, so a frame missed while detached is a permanent phantom node —
60
+ // and an effect cleanup is not an unmount (StrictMode, <Suspense>, <Activity> all
61
+ // tear effects down and put them back). `dispose` therefore only goes quiet; the
62
+ // transport subscription is released by `close`, which the connection's owner calls.
63
+ const off = transport.onMessage(receive)
64
+ const pending = MutableRef.make(Option.none<ReturnType<typeof setTimeout>>())
65
+ const close = (): void => {
66
+ listeners.clear()
67
+ MutableRef.set(pending, Option.none())
68
+ off()
69
+ }
70
+ // `dispose()` cannot know whether React is unmounting the host or merely hiding it,
71
+ // so it goes quiet and starts a clock. A `subscribe` inside the window is the host
72
+ // coming back and stops it; nothing inside the window means the host is gone, and
73
+ // the transport subscription is released rather than left to accumulate one dead
74
+ // handler per mount/unmount cycle.
75
+ const cancelClose = (): void =>
76
+ Option.match(MutableRef.get(pending), {
77
+ onNone: () => undefined,
78
+ onSome: (handle) => {
79
+ MutableRef.set(pending, Option.none())
80
+ clearTimeout(handle)
81
+ },
82
+ })
83
+ return {
84
+ node: () => renderWireTree(state.tree, config),
85
+ subscribe: (listener) => {
86
+ cancelClose()
87
+ listeners.add(listener)
88
+ return () => void listeners.delete(listener)
89
+ },
90
+ snapshot: () => state.tree,
91
+ dispose: () => {
92
+ listeners.clear()
93
+ if (Option.isSome(MutableRef.get(pending))) {
94
+ return
95
+ }
96
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- must be unref-able; a binding waiting to be collected must not hold the host process open
97
+ const handle = setTimeout(close, options.releaseWindowMs ?? defaultReleaseWindowMs)
98
+ detachTimer(handle)
99
+ MutableRef.set(pending, Option.some(handle))
100
+ },
101
+ close,
102
+ }
103
+ }
@@ -0,0 +1,142 @@
1
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
2
+ import {
3
+ Channel,
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ Procedure,
8
+ Reducer,
9
+ State,
10
+ StateGroup,
11
+ Ui,
12
+ mount,
13
+ provide,
14
+ scene,
15
+ type CapturedScene,
16
+ type DerivedContract,
17
+ type EventOf,
18
+ ui,
19
+ } from '@playfast/reform'
20
+ import { type EngineServices, type WiredUi } from '@playfast/reform/internal'
21
+
22
+ const CountBase: State.StateDefinition<'count', number, typeof S.Number> = State.make(
23
+ 'count',
24
+ S.Number,
25
+ )
26
+ class Count extends CountBase {}
27
+ class Counters extends StateGroup.make(Count) {}
28
+ class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
29
+ class Bump extends Reducer.make('Bump', {
30
+ states: [Count],
31
+ events: [Bumped],
32
+ }) {}
33
+ const CounterUiBase: WiredUi<
34
+ DerivedContract<{
35
+ props: S.Struct<{ count: typeof S.Number }>
36
+ events: { bump: S.Struct<{ by: typeof S.Number }> }
37
+ }>,
38
+ 'Counter'
39
+ > = ui('Counter', {
40
+ props: S.Struct({ count: S.Number }),
41
+ events: { bump: S.Struct({ by: S.Number }) },
42
+ })
43
+ export class CounterUi extends CounterUiBase {}
44
+
45
+ class Counter extends Composition.make('Counter', {
46
+ title: 'Counter',
47
+ ui: CounterUi,
48
+ states: [Count],
49
+ })<Counter>() {}
50
+
51
+ type CounterScene = CapturedScene<
52
+ typeof CounterUi.Contract,
53
+ readonly [typeof Count],
54
+ | EngineServices
55
+ | State.StateStore<'count', number>
56
+ | Ui.UiService<'Counter'>
57
+ | Composition.CompositionId<'Counter', Counter, never>,
58
+ unknown,
59
+ 'Counter',
60
+ Counter
61
+ >
62
+
63
+ export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
64
+ Event.construct(Bumped, { by: amount })
65
+
66
+ const makeCounterScene = (
67
+ boot?: ReadonlyArray<EventOf<'Bumped', { by: number }>>,
68
+ ): CounterScene => {
69
+ const presentation = Layer.mergeAll(
70
+ provide(
71
+ CounterUi,
72
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
73
+ ),
74
+ StateGroup.live(Counters, { count: 0 }),
75
+ )
76
+ const app = Layer.mergeAll(
77
+ Composition.live(Counter, function* () {
78
+ const count = yield* StateGroup.select(Counters, 'count')
79
+ const bump = yield* Event.trigger(Bumped)
80
+ return mount({ props: { count }, slots: {}, events: { bump } })
81
+ }),
82
+ Reducer.live(Bump, (count, event) => count + event.by),
83
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
84
+ return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
85
+ }
86
+ export const counterScene: typeof makeCounterScene = makeCounterScene
87
+
88
+ const makeStructureCounterScene = (): CounterScene => {
89
+ const presentation = Layer.mergeAll(
90
+ provide(
91
+ CounterUi,
92
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
93
+ ),
94
+ StateGroup.live(Counters, { count: 0 }),
95
+ )
96
+ const app = Layer.mergeAll(
97
+ Composition.live(Counter, function* () {
98
+ const count = yield* StateGroup.select(Counters, 'count')
99
+ const bump = yield* Event.trigger(Bumped)
100
+ return mount({ props: { count }, slots: {}, events: { bump } })
101
+ }),
102
+ Reducer.live(Bump, (count, event) => count + event.by),
103
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
104
+ return scene(Counter, { provide: [app] })
105
+ }
106
+ export const structureCounterScene: typeof makeStructureCounterScene = makeStructureCounterScene
107
+
108
+ class Kick extends Event.make('Kick', S.Struct({ to: S.Number })) {}
109
+ class Loader extends Channel.make('Loader', { policy: { _tag: 'latest' } }) {}
110
+ class DelayedBump extends Procedure.make('DelayedBump', {
111
+ channel: Loader,
112
+ events: [Kick],
113
+ }) {}
114
+
115
+ const makeAsyncCounterScene = (delay: Duration.DurationInput = '40 millis'): CounterScene => {
116
+ const presentation = Layer.mergeAll(
117
+ provide(
118
+ CounterUi,
119
+ Ui.make(CounterUi, ({ count }) => `count:${count}`),
120
+ ),
121
+ StateGroup.live(Counters, { count: 0 }),
122
+ )
123
+ const app = Layer.mergeAll(
124
+ Composition.live(Counter, function* () {
125
+ const count = yield* StateGroup.select(Counters, 'count')
126
+ const bump = yield* Event.trigger(Bumped)
127
+ return mount({ props: { count }, slots: {}, events: { bump } })
128
+ }),
129
+ Reducer.live(Bump, (count, event) => count + event.by),
130
+ Procedure.live(DelayedBump, function* (event) {
131
+ yield* Effect.sleep(delay)
132
+ yield* Event.dispatch(Bumped, { by: event.to })
133
+ }),
134
+ // Without Channel.live the boot Kick is dropped.
135
+ Channel.live(Loader),
136
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
137
+ return scene(Counter, {
138
+ provide: [app],
139
+ boot: [Event.construct(Kick, { to: 7 })],
140
+ })
141
+ }
142
+ export const asyncCounterScene: typeof makeAsyncCounterScene = makeAsyncCounterScene