@playfast/reform-remote-web 1.0.2 → 1.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote-web",
3
3
  "playbook": "./playbook",
4
- "version": "1.0.2",
4
+ "version": "1.0.3",
5
5
  "type": "module",
6
6
  "description": "Browser/web WebSocket client transport for reform-remote — streams a server-run reform scene to a thin renderer.",
7
7
  "keywords": [
@@ -0,0 +1,118 @@
1
+ import { expect, test } from 'vitest'
2
+ import type { ServerMessage } from '@playfast/reform-remote'
3
+ import { createWebSocketClientTransport } from './transport'
4
+
5
+ // The scene-handshake race that blanks the app on WebKit ~8% of cold loads.
6
+ //
7
+ // `serveShared.addClient` (reform-remote) pushes the initial `Snapshot` the instant the WebSocket
8
+ // opens — the client never asks for it. On our side the socket is opened eagerly (connectRemote)
9
+ // while `RemoteUI` only registers its `onMessage` handler during its FIRST React render, a
10
+ // scheduler flush later. So there is a window where the server's Snapshot arrives before any
11
+ // handler is subscribed. If the transport drops messages that land in that window, `RemoteUI`
12
+ // never receives the scene, renders null forever, and the whole app is silently blank. WebKit's
13
+ // scheduler/WS-I/O timing loses this race intermittently; Chromium wins it.
14
+ //
15
+ // These tests pin the transport's contract deterministically with a fake WebSocket whose open and
16
+ // message events we fire by hand — no real I/O, no timing flake.
17
+
18
+ type Listener = (event: unknown) => void
19
+
20
+ class FakeSocket {
21
+ static readonly CONNECTING = 0
22
+ static readonly OPEN = 1
23
+ static readonly CLOSING = 2
24
+ static readonly CLOSED = 3
25
+ static instances: FakeSocket[] = []
26
+
27
+ readyState: number = FakeSocket.CONNECTING
28
+ readonly sent: string[] = []
29
+ readonly url: string
30
+ readonly protocols: string | ReadonlyArray<string> | undefined
31
+ private readonly listeners = new Map<string, Set<Listener>>()
32
+
33
+ constructor(url: string, protocols?: string | ReadonlyArray<string>) {
34
+ this.url = url
35
+ this.protocols = protocols
36
+ FakeSocket.instances.push(this)
37
+ }
38
+
39
+ addEventListener(type: string, cb: Listener): void {
40
+ const set = this.listeners.get(type) ?? new Set<Listener>()
41
+ set.add(cb)
42
+ this.listeners.set(type, set)
43
+ }
44
+
45
+ removeEventListener(type: string, cb: Listener): void {
46
+ this.listeners.get(type)?.delete(cb)
47
+ }
48
+
49
+ send(data: string): void {
50
+ this.sent.push(data)
51
+ }
52
+
53
+ close(): void {
54
+ this.readyState = FakeSocket.CLOSED
55
+ this.fire('close', {})
56
+ }
57
+
58
+ // --- test controls (not part of the WebSocket API) ---
59
+ emitOpen(): void {
60
+ this.readyState = FakeSocket.OPEN
61
+ this.fire('open', {})
62
+ }
63
+
64
+ emitServerMessage(message: ServerMessage): void {
65
+ this.fire('message', { data: JSON.stringify(message) })
66
+ }
67
+
68
+ private fire(type: string, event: unknown): void {
69
+ this.listeners.get(type)?.forEach((cb) => cb(event))
70
+ }
71
+ }
72
+
73
+ const makeTransport = () => {
74
+ FakeSocket.instances = []
75
+ const transport = createWebSocketClientTransport({
76
+ url: 'ws://handshake-race',
77
+ WebSocket: FakeSocket as unknown as typeof WebSocket,
78
+ })
79
+ const socket = FakeSocket.instances[0]
80
+ if (socket === undefined) {
81
+ throw new Error('transport did not construct a socket')
82
+ }
83
+ return { transport, socket }
84
+ }
85
+
86
+ const SNAPSHOT: ServerMessage = { _tag: 'Snapshot', tree: [] }
87
+
88
+ test('delivers a server message that arrived before the first subscriber (handshake race)', () => {
89
+ const { transport, socket } = makeTransport()
90
+ socket.emitOpen()
91
+ // Server pushes the Snapshot on connect, BEFORE RemoteUI mounts and subscribes.
92
+ socket.emitServerMessage(SNAPSHOT)
93
+ // RemoteUI renders and subscribes only now.
94
+ const received: ServerMessage[] = []
95
+ transport.onMessage((message) => received.push(message))
96
+ expect(received).toEqual([SNAPSHOT])
97
+ })
98
+
99
+ test('still delivers messages that arrive after subscription', () => {
100
+ const { transport, socket } = makeTransport()
101
+ socket.emitOpen()
102
+ const received: ServerMessage[] = []
103
+ transport.onMessage((message) => received.push(message))
104
+ socket.emitServerMessage(SNAPSHOT)
105
+ expect(received).toEqual([SNAPSHOT])
106
+ })
107
+
108
+ test('does not replay the buffered message to a later second subscriber', () => {
109
+ const { transport, socket } = makeTransport()
110
+ socket.emitOpen()
111
+ socket.emitServerMessage(SNAPSHOT)
112
+ const first: ServerMessage[] = []
113
+ transport.onMessage((message) => first.push(message))
114
+ const second: ServerMessage[] = []
115
+ transport.onMessage((message) => second.push(message))
116
+ expect(first).toEqual([SNAPSHOT])
117
+ expect(second).toEqual([])
118
+ })
package/src/transport.ts CHANGED
@@ -71,7 +71,18 @@ export const createWebSocketClientTransport = (
71
71
  attempts: number
72
72
  timer: Option.Option<ReturnType<typeof setTimeout>>
73
73
  queue: ReadonlyArray<InvokeMessage>
74
- } = { socket: Option.none(), status: 'connecting', attempts: 0, timer: Option.none(), queue: [] }
74
+ // Inbound messages that arrived before any handler subscribed. The server pushes the initial
75
+ // Snapshot the instant the socket opens, but a consumer (e.g. RemoteUI) only subscribes after
76
+ // it mounts — so without this buffer the first scene is dropped and the UI blanks forever.
77
+ inbox: ReadonlyArray<ServerMessage>
78
+ } = {
79
+ socket: Option.none(),
80
+ status: 'connecting',
81
+ attempts: 0,
82
+ timer: Option.none(),
83
+ queue: [],
84
+ inbox: [],
85
+ }
75
86
 
76
87
  const setStatus = (status: ClientStatus): void => {
77
88
  if (status === state.status) {
@@ -127,6 +138,12 @@ export const createWebSocketClientTransport = (
127
138
  socket.addEventListener('message', (event: MessageEvent) => {
128
139
  // oxlint-disable-next-line reform-rules/no-json-parse-stringify, reform-rules/no-type-assertion -- wire frame decode; ServerMessage is a structural type with no Schema here
129
140
  const message = JSON.parse(event.data as string) as ServerMessage
141
+ if (handlers.size === 0) {
142
+ // No subscriber yet: buffer so the first one to subscribe still receives this message
143
+ // rather than losing it (the server's initial Snapshot can beat the consumer's mount).
144
+ state.inbox = [...state.inbox, message]
145
+ return
146
+ }
130
147
  handlers.forEach((handler) => handler(message))
131
148
  })
132
149
  socket.addEventListener('close', () => handleDrop(socket))
@@ -142,6 +159,13 @@ export const createWebSocketClientTransport = (
142
159
  },
143
160
  onMessage: (handler) => {
144
161
  handlers.add(handler)
162
+ // Replay anything that arrived before this first subscription, then clear the buffer so a
163
+ // later second subscriber does not receive the same messages again.
164
+ if (state.inbox.length > 0) {
165
+ const buffered = state.inbox
166
+ state.inbox = []
167
+ buffered.forEach((message) => handler(message))
168
+ }
145
169
  return () => void handlers.delete(handler)
146
170
  },
147
171
  status: () => state.status,