onejs-react 0.1.7 → 0.1.9
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 +1 -1
- package/src/__tests__/hooks.test.tsx +581 -0
- package/src/hooks.ts +180 -22
- package/src/host-config.ts +5 -33
- package/src/index.ts +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for C# interop hooks and utilities
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - toArray: Converting C# collections (List<T>, arrays) to JS arrays
|
|
6
|
+
* - useFrameSync (simple mode): Object.is comparison for primitives
|
|
7
|
+
* - useFrameSync (selector mode): Deps-based change detection for C# proxy objects
|
|
8
|
+
* - useFrameSyncWith (deprecated): Custom equality comparison
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
12
|
+
import React from "react"
|
|
13
|
+
import { useFrameSync, useFrameSyncWith, toArray } from "../hooks"
|
|
14
|
+
import { render, unmount } from "../renderer"
|
|
15
|
+
import { createMockContainer, flushMicrotasks } from "./mocks"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// RAF mock — needed because hooks use requestAnimationFrame for polling
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
type RafCallback = (time: number) => void
|
|
22
|
+
|
|
23
|
+
let rafQueue: Array<{ id: number; callback: RafCallback }> = []
|
|
24
|
+
let nextRafId = 0
|
|
25
|
+
|
|
26
|
+
/** Flush all pending requestAnimationFrame callbacks (one round). */
|
|
27
|
+
function flushRaf() {
|
|
28
|
+
const queue = [...rafQueue]
|
|
29
|
+
rafQueue = []
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
for (const { callback } of queue) {
|
|
32
|
+
callback(now)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Flush RAF then microtasks — simulates a full frame tick for the hook. */
|
|
37
|
+
async function advanceFrame() {
|
|
38
|
+
flushRaf()
|
|
39
|
+
await flushMicrotasks()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
rafQueue = []
|
|
44
|
+
nextRafId = 0
|
|
45
|
+
;(globalThis as any).requestAnimationFrame = vi.fn((cb: RafCallback) => {
|
|
46
|
+
const id = ++nextRafId
|
|
47
|
+
rafQueue.push({ id, callback: cb })
|
|
48
|
+
return id
|
|
49
|
+
})
|
|
50
|
+
;(globalThis as any).cancelAnimationFrame = vi.fn((id: number) => {
|
|
51
|
+
rafQueue = rafQueue.filter((e) => e.id !== id)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
delete (globalThis as any).requestAnimationFrame
|
|
57
|
+
delete (globalThis as any).cancelAnimationFrame
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// toArray
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
|
|
64
|
+
describe("toArray", () => {
|
|
65
|
+
it("returns empty array for null", () => {
|
|
66
|
+
expect(toArray(null)).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("returns empty array for undefined", () => {
|
|
70
|
+
expect(toArray(undefined)).toEqual([])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("converts a C# List (Count property)", () => {
|
|
74
|
+
const mockList: Record<string, unknown> = {
|
|
75
|
+
Count: 3,
|
|
76
|
+
0: "alpha",
|
|
77
|
+
1: "beta",
|
|
78
|
+
2: "gamma",
|
|
79
|
+
}
|
|
80
|
+
expect(toArray(mockList)).toEqual(["alpha", "beta", "gamma"])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("converts a C# array (Length property)", () => {
|
|
84
|
+
const mockArray: Record<string, unknown> = {
|
|
85
|
+
Length: 2,
|
|
86
|
+
0: 10,
|
|
87
|
+
1: 20,
|
|
88
|
+
}
|
|
89
|
+
expect(toArray(mockArray)).toEqual([10, 20])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("returns empty array for empty collection with Count=0", () => {
|
|
93
|
+
expect(toArray({ Count: 0 })).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("returns empty array for empty collection with Length=0", () => {
|
|
97
|
+
expect(toArray({ Length: 0 })).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("prefers Count over Length when both exist", () => {
|
|
101
|
+
const mock: Record<string, unknown> = {
|
|
102
|
+
Count: 2,
|
|
103
|
+
Length: 3,
|
|
104
|
+
0: "x",
|
|
105
|
+
1: "y",
|
|
106
|
+
2: "z",
|
|
107
|
+
}
|
|
108
|
+
// Should use Count (2), not Length (3)
|
|
109
|
+
expect(toArray(mock)).toEqual(["x", "y"])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("returns empty array for objects without Count or Length", () => {
|
|
113
|
+
expect(toArray({})).toEqual([])
|
|
114
|
+
expect(toArray({ foo: "bar" })).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("returns empty array for non-numeric Count/Length", () => {
|
|
118
|
+
expect(toArray({ Count: "not a number" })).toEqual([])
|
|
119
|
+
expect(toArray({ Length: null })).toEqual([])
|
|
120
|
+
expect(toArray({ Count: true })).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("handles collection with object elements", () => {
|
|
124
|
+
const mockList: Record<string, unknown> = {
|
|
125
|
+
Count: 2,
|
|
126
|
+
0: { id: 1, name: "Sword" },
|
|
127
|
+
1: { id: 2, name: "Shield" },
|
|
128
|
+
}
|
|
129
|
+
const result = toArray<{ id: number; name: string }>(mockList)
|
|
130
|
+
expect(result).toHaveLength(2)
|
|
131
|
+
expect(result[0].id).toBe(1)
|
|
132
|
+
expect(result[0].name).toBe("Sword")
|
|
133
|
+
expect(result[1].id).toBe(2)
|
|
134
|
+
expect(result[1].name).toBe("Shield")
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("handles single-element collection", () => {
|
|
138
|
+
expect(toArray({ Count: 1, 0: "only" })).toEqual(["only"])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("handles large collections", () => {
|
|
142
|
+
const mock: Record<string, unknown> = { Count: 100 }
|
|
143
|
+
for (let i = 0; i < 100; i++) {
|
|
144
|
+
mock[i] = i * 2
|
|
145
|
+
}
|
|
146
|
+
const result = toArray<number>(mock)
|
|
147
|
+
expect(result).toHaveLength(100)
|
|
148
|
+
expect(result[0]).toBe(0)
|
|
149
|
+
expect(result[50]).toBe(100)
|
|
150
|
+
expect(result[99]).toBe(198)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// ===========================================================================
|
|
155
|
+
// useFrameSync — simple mode (no selector)
|
|
156
|
+
// ===========================================================================
|
|
157
|
+
|
|
158
|
+
describe("useFrameSync (simple mode)", () => {
|
|
159
|
+
let container: ReturnType<typeof createMockContainer>
|
|
160
|
+
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
container = createMockContainer()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
unmount(container as any)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
function renderHookCapture<T>(
|
|
170
|
+
getter: () => T,
|
|
171
|
+
deps?: readonly unknown[]
|
|
172
|
+
) {
|
|
173
|
+
let capturedValue: T = undefined as T
|
|
174
|
+
let renderCount = 0
|
|
175
|
+
|
|
176
|
+
function TestComponent() {
|
|
177
|
+
const value = useFrameSync(getter, deps)
|
|
178
|
+
capturedValue = value
|
|
179
|
+
renderCount++
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
render(<TestComponent />, container as any)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
get value() { return capturedValue },
|
|
187
|
+
get renderCount() { return renderCount },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it("returns initial value from getter", async () => {
|
|
192
|
+
const capture = renderHookCapture(() => 42)
|
|
193
|
+
await flushMicrotasks()
|
|
194
|
+
expect(capture.value).toBe(42)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("updates when primitive value changes", async () => {
|
|
198
|
+
let counter = 0
|
|
199
|
+
const capture = renderHookCapture(() => counter)
|
|
200
|
+
await flushMicrotasks()
|
|
201
|
+
await advanceFrame()
|
|
202
|
+
const initialRenders = capture.renderCount
|
|
203
|
+
|
|
204
|
+
counter = 1
|
|
205
|
+
await advanceFrame()
|
|
206
|
+
expect(capture.renderCount).toBeGreaterThan(initialRenders)
|
|
207
|
+
expect(capture.value).toBe(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("does not re-render when value is unchanged", async () => {
|
|
211
|
+
const capture = renderHookCapture(() => "stable")
|
|
212
|
+
await flushMicrotasks()
|
|
213
|
+
await advanceFrame()
|
|
214
|
+
const initialRenders = capture.renderCount
|
|
215
|
+
|
|
216
|
+
await advanceFrame()
|
|
217
|
+
await advanceFrame()
|
|
218
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("handles null return from getter", async () => {
|
|
222
|
+
const capture = renderHookCapture(() => null)
|
|
223
|
+
await flushMicrotasks()
|
|
224
|
+
expect(capture.value).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("handles getter that throws", async () => {
|
|
228
|
+
const capture = renderHookCapture(() => { throw new Error("boom") })
|
|
229
|
+
await flushMicrotasks()
|
|
230
|
+
expect(capture.value).toBeUndefined()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("stops polling after unmount", async () => {
|
|
234
|
+
const getter = vi.fn(() => 42)
|
|
235
|
+
renderHookCapture(getter)
|
|
236
|
+
await flushMicrotasks()
|
|
237
|
+
await advanceFrame()
|
|
238
|
+
|
|
239
|
+
const callsBeforeUnmount = getter.mock.calls.length
|
|
240
|
+
|
|
241
|
+
unmount(container as any)
|
|
242
|
+
await flushMicrotasks()
|
|
243
|
+
|
|
244
|
+
await advanceFrame()
|
|
245
|
+
await advanceFrame()
|
|
246
|
+
await advanceFrame()
|
|
247
|
+
|
|
248
|
+
// At most one extra call from the in-flight RAF callback
|
|
249
|
+
expect(getter.mock.calls.length).toBeLessThanOrEqual(callsBeforeUnmount + 1)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("uses Object.is for comparison (NaN === NaN)", async () => {
|
|
253
|
+
let val = NaN
|
|
254
|
+
const capture = renderHookCapture(() => val)
|
|
255
|
+
await flushMicrotasks()
|
|
256
|
+
await advanceFrame()
|
|
257
|
+
const initialRenders = capture.renderCount
|
|
258
|
+
|
|
259
|
+
// NaN should be Object.is equal to NaN — no re-render
|
|
260
|
+
val = NaN
|
|
261
|
+
await advanceFrame()
|
|
262
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ===========================================================================
|
|
267
|
+
// useFrameSync — selector mode
|
|
268
|
+
// ===========================================================================
|
|
269
|
+
|
|
270
|
+
describe("useFrameSync (selector mode)", () => {
|
|
271
|
+
let container: ReturnType<typeof createMockContainer>
|
|
272
|
+
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
container = createMockContainer()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
unmount(container as any)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
function renderHookCapture<T>(
|
|
282
|
+
getter: () => T,
|
|
283
|
+
select: (v: T) => readonly unknown[],
|
|
284
|
+
deps?: readonly unknown[]
|
|
285
|
+
) {
|
|
286
|
+
let capturedValue: T = undefined as T
|
|
287
|
+
let renderCount = 0
|
|
288
|
+
|
|
289
|
+
function TestComponent() {
|
|
290
|
+
const value = useFrameSync(getter, select, deps)
|
|
291
|
+
capturedValue = value
|
|
292
|
+
renderCount++
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
render(<TestComponent />, container as any)
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
get value() { return capturedValue },
|
|
300
|
+
get renderCount() { return renderCount },
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it("returns initial value from getter on first render", async () => {
|
|
305
|
+
const capture = renderHookCapture(
|
|
306
|
+
() => ({ name: "Town", population: 100 }),
|
|
307
|
+
(v) => [v.name, v.population]
|
|
308
|
+
)
|
|
309
|
+
await flushMicrotasks()
|
|
310
|
+
|
|
311
|
+
expect(capture.value).toEqual({ name: "Town", population: 100 })
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("returns null when getter returns null", async () => {
|
|
315
|
+
const capture = renderHookCapture(
|
|
316
|
+
() => null,
|
|
317
|
+
(v) => [v]
|
|
318
|
+
)
|
|
319
|
+
await flushMicrotasks()
|
|
320
|
+
|
|
321
|
+
expect(capture.value).toBeNull()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("returns undefined when getter throws", async () => {
|
|
325
|
+
const capture = renderHookCapture(
|
|
326
|
+
() => { throw new Error("destroyed") },
|
|
327
|
+
(v) => [v]
|
|
328
|
+
)
|
|
329
|
+
await flushMicrotasks()
|
|
330
|
+
|
|
331
|
+
expect(capture.value).toBeUndefined()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("re-renders when a selected dep changes", async () => {
|
|
335
|
+
const data = { name: "Town A", count: 1, untracked: "foo" }
|
|
336
|
+
|
|
337
|
+
const capture = renderHookCapture(
|
|
338
|
+
() => data,
|
|
339
|
+
(v) => [v.name, v.count]
|
|
340
|
+
)
|
|
341
|
+
await flushMicrotasks()
|
|
342
|
+
const initialRenders = capture.renderCount
|
|
343
|
+
|
|
344
|
+
// Advance one frame with no changes — should not re-render
|
|
345
|
+
await advanceFrame()
|
|
346
|
+
expect(capture.renderCount).toBe(initialRenders)
|
|
347
|
+
|
|
348
|
+
// Change a tracked dep
|
|
349
|
+
data.name = "Town B"
|
|
350
|
+
await advanceFrame()
|
|
351
|
+
|
|
352
|
+
expect(capture.renderCount).toBeGreaterThan(initialRenders)
|
|
353
|
+
expect(capture.value.name).toBe("Town B")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it("does not re-render when untracked properties change", async () => {
|
|
357
|
+
const data = { name: "Town", count: 1, untracked: "a" }
|
|
358
|
+
|
|
359
|
+
const capture = renderHookCapture(
|
|
360
|
+
() => data,
|
|
361
|
+
(v) => [v.name, v.count]
|
|
362
|
+
)
|
|
363
|
+
await flushMicrotasks()
|
|
364
|
+
// Let the effect run and schedule the first RAF check
|
|
365
|
+
await advanceFrame()
|
|
366
|
+
const afterInit = capture.renderCount
|
|
367
|
+
|
|
368
|
+
// Change only an untracked property
|
|
369
|
+
data.untracked = "b"
|
|
370
|
+
await advanceFrame()
|
|
371
|
+
|
|
372
|
+
expect(capture.renderCount).toBe(afterInit)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it("detects changes in the number of selected deps", async () => {
|
|
376
|
+
let depCount = 2
|
|
377
|
+
|
|
378
|
+
const capture = renderHookCapture(
|
|
379
|
+
() => "value",
|
|
380
|
+
() => {
|
|
381
|
+
// Return different-length arrays based on external state
|
|
382
|
+
const arr: unknown[] = []
|
|
383
|
+
for (let i = 0; i < depCount; i++) arr.push(i)
|
|
384
|
+
return arr
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
await flushMicrotasks()
|
|
388
|
+
await advanceFrame()
|
|
389
|
+
const afterInit = capture.renderCount
|
|
390
|
+
|
|
391
|
+
// Change the number of deps
|
|
392
|
+
depCount = 3
|
|
393
|
+
await advanceFrame()
|
|
394
|
+
|
|
395
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it("simulates C# proxy caching — same object reference, property changes", async () => {
|
|
399
|
+
// This is the core scenario: a C# proxy always returns the same
|
|
400
|
+
// JS object reference, but its properties change because they
|
|
401
|
+
// read through to C# on each access.
|
|
402
|
+
const proxy = { Name: "Village", NPCCount: 3, Version: 1 }
|
|
403
|
+
|
|
404
|
+
const capture = renderHookCapture(
|
|
405
|
+
() => proxy, // Always returns the SAME object reference
|
|
406
|
+
(p) => [p.Name, p.NPCCount, p.Version]
|
|
407
|
+
)
|
|
408
|
+
await flushMicrotasks()
|
|
409
|
+
await advanceFrame()
|
|
410
|
+
expect(capture.value.Name).toBe("Village")
|
|
411
|
+
const afterInit = capture.renderCount
|
|
412
|
+
|
|
413
|
+
// Mutate the proxy (simulates C# property change via proxy)
|
|
414
|
+
proxy.Name = "City"
|
|
415
|
+
proxy.Version = 2
|
|
416
|
+
await advanceFrame()
|
|
417
|
+
|
|
418
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
419
|
+
expect(capture.value.Name).toBe("City")
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it("handles nullable C# references with optional chaining in select", async () => {
|
|
423
|
+
let currentPlace: { Name: string; Items: { Count: number } } | null = {
|
|
424
|
+
Name: "Tavern",
|
|
425
|
+
Items: { Count: 5 },
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const capture = renderHookCapture(
|
|
429
|
+
() => currentPlace,
|
|
430
|
+
(p) => [p?.Name, p?.Items?.Count]
|
|
431
|
+
)
|
|
432
|
+
await flushMicrotasks()
|
|
433
|
+
await advanceFrame()
|
|
434
|
+
expect(capture.value?.Name).toBe("Tavern")
|
|
435
|
+
const afterInit = capture.renderCount
|
|
436
|
+
|
|
437
|
+
// Set to null — should detect change (deps go from ["Tavern", 5] to [undefined, undefined])
|
|
438
|
+
currentPlace = null
|
|
439
|
+
await advanceFrame()
|
|
440
|
+
|
|
441
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
442
|
+
expect(capture.value).toBeNull()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it("works with version stamp pattern", async () => {
|
|
446
|
+
const gameState = { Version: 1, data: "initial" }
|
|
447
|
+
|
|
448
|
+
const capture = renderHookCapture(
|
|
449
|
+
() => gameState,
|
|
450
|
+
(s) => [s.Version]
|
|
451
|
+
)
|
|
452
|
+
await flushMicrotasks()
|
|
453
|
+
await advanceFrame()
|
|
454
|
+
const afterInit = capture.renderCount
|
|
455
|
+
|
|
456
|
+
// No version change — no re-render
|
|
457
|
+
gameState.data = "changed but version same"
|
|
458
|
+
await advanceFrame()
|
|
459
|
+
expect(capture.renderCount).toBe(afterInit)
|
|
460
|
+
|
|
461
|
+
// Bump version — triggers re-render
|
|
462
|
+
gameState.Version = 2
|
|
463
|
+
gameState.data = "updated"
|
|
464
|
+
await advanceFrame()
|
|
465
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
466
|
+
expect(capture.value.data).toBe("updated")
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it("stops polling after unmount", async () => {
|
|
470
|
+
const getter = vi.fn(() => 42)
|
|
471
|
+
|
|
472
|
+
renderHookCapture(getter, (v) => [v])
|
|
473
|
+
await flushMicrotasks()
|
|
474
|
+
await advanceFrame()
|
|
475
|
+
|
|
476
|
+
const callsBeforeUnmount = getter.mock.calls.length
|
|
477
|
+
|
|
478
|
+
// Unmount
|
|
479
|
+
unmount(container as any)
|
|
480
|
+
await flushMicrotasks()
|
|
481
|
+
|
|
482
|
+
// Advance more frames — getter should not be called
|
|
483
|
+
await advanceFrame()
|
|
484
|
+
await advanceFrame()
|
|
485
|
+
await advanceFrame()
|
|
486
|
+
|
|
487
|
+
// At most one extra call from the in-flight RAF callback
|
|
488
|
+
expect(getter.mock.calls.length).toBeLessThanOrEqual(callsBeforeUnmount + 1)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("continues polling across multiple frames", async () => {
|
|
492
|
+
const data = { value: 0 }
|
|
493
|
+
|
|
494
|
+
const capture = renderHookCapture(
|
|
495
|
+
() => data,
|
|
496
|
+
(d) => [d.value]
|
|
497
|
+
)
|
|
498
|
+
await flushMicrotasks()
|
|
499
|
+
await advanceFrame()
|
|
500
|
+
const afterInit = capture.renderCount
|
|
501
|
+
|
|
502
|
+
// Frame 2: change value
|
|
503
|
+
data.value = 1
|
|
504
|
+
await advanceFrame()
|
|
505
|
+
expect(capture.renderCount).toBeGreaterThan(afterInit)
|
|
506
|
+
const afterFirst = capture.renderCount
|
|
507
|
+
|
|
508
|
+
// Frame 3: change value again
|
|
509
|
+
data.value = 2
|
|
510
|
+
await advanceFrame()
|
|
511
|
+
expect(capture.renderCount).toBeGreaterThan(afterFirst)
|
|
512
|
+
expect(capture.value.value).toBe(2)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it("always returns fresh value from getter on render", async () => {
|
|
516
|
+
// Even between detected changes, the returned value should
|
|
517
|
+
// be the current live getter result (not a stale snapshot).
|
|
518
|
+
let callCount = 0
|
|
519
|
+
const capture = renderHookCapture(
|
|
520
|
+
() => {
|
|
521
|
+
callCount++
|
|
522
|
+
return { fresh: callCount }
|
|
523
|
+
},
|
|
524
|
+
() => [1] // Deps never change after init
|
|
525
|
+
)
|
|
526
|
+
await flushMicrotasks()
|
|
527
|
+
|
|
528
|
+
// The value should reflect a recent getter call
|
|
529
|
+
expect(capture.value.fresh).toBeGreaterThan(0)
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
// ===========================================================================
|
|
534
|
+
// useFrameSyncWith (deprecated)
|
|
535
|
+
// ===========================================================================
|
|
536
|
+
|
|
537
|
+
describe("useFrameSyncWith (deprecated)", () => {
|
|
538
|
+
let container: ReturnType<typeof createMockContainer>
|
|
539
|
+
|
|
540
|
+
beforeEach(() => {
|
|
541
|
+
container = createMockContainer()
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
afterEach(() => {
|
|
545
|
+
unmount(container as any)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it("works with custom equality for new JS objects", async () => {
|
|
549
|
+
// useFrameSyncWith works when the getter returns a NEW object each time
|
|
550
|
+
// (not a cached proxy). This is its valid use case.
|
|
551
|
+
let x = 1, y = 2, z = 3
|
|
552
|
+
let capturedValue: { x: number; y: number; z: number } = undefined as any
|
|
553
|
+
|
|
554
|
+
function TestComponent() {
|
|
555
|
+
const value = useFrameSyncWith(
|
|
556
|
+
() => ({ x, y, z }),
|
|
557
|
+
(a, b) => a.x === b.x && a.y === b.y && a.z === b.z
|
|
558
|
+
)
|
|
559
|
+
capturedValue = value
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
render(<TestComponent />, container as any)
|
|
564
|
+
await flushMicrotasks()
|
|
565
|
+
expect(capturedValue).toEqual({ x: 1, y: 2, z: 3 })
|
|
566
|
+
|
|
567
|
+
// No change — should not update (custom equality says they're equal)
|
|
568
|
+
await advanceFrame()
|
|
569
|
+
const stableValue = capturedValue
|
|
570
|
+
|
|
571
|
+
await advanceFrame()
|
|
572
|
+
// The reference may change (new object each getter call) but
|
|
573
|
+
// custom equality prevents unnecessary state updates
|
|
574
|
+
expect(capturedValue).toEqual({ x: 1, y: 2, z: 3 })
|
|
575
|
+
|
|
576
|
+
// Change a value — should trigger update
|
|
577
|
+
x = 10
|
|
578
|
+
await advanceFrame()
|
|
579
|
+
expect(capturedValue.x).toBe(10)
|
|
580
|
+
})
|
|
581
|
+
})
|
package/src/hooks.ts
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from "react"
|
|
1
|
+
import { useState, useEffect, useRef, useReducer } from "react"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Syncs a value from C# (or any external source) to React state, checking every frame.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Has two modes depending on whether a `select` function is provided:
|
|
7
|
+
*
|
|
8
|
+
* **Simple mode** (no selector): Compares values with `Object.is`. Best for primitives
|
|
9
|
+
* (numbers, strings, booleans) and cases where the getter returns a new object each time.
|
|
10
|
+
*
|
|
11
|
+
* **Selector mode**: Extracts an array of comparable values to watch. Re-renders only when
|
|
12
|
+
* any selected value changes. Essential for C# proxy objects where the proxy reference is
|
|
13
|
+
* cached — without a selector, you'd be comparing the same proxy to itself.
|
|
14
|
+
* The returned value is always read fresh from the getter during render.
|
|
8
15
|
*
|
|
9
16
|
* @param getter - Function that returns the current value (called every frame)
|
|
10
|
-
* @param
|
|
17
|
+
* @param selectOrDeps - Either a selector function or a dependency array
|
|
18
|
+
* @param deps - Optional dependency array (only when using a selector)
|
|
11
19
|
* @returns The current value, updated each frame if changed
|
|
12
20
|
*
|
|
13
21
|
* @example
|
|
14
|
-
* //
|
|
15
|
-
* const health = useFrameSync(() => player.
|
|
16
|
-
* const
|
|
22
|
+
* // Simple: sync a C# property (primitives)
|
|
23
|
+
* const health = useFrameSync(() => player.Health)
|
|
24
|
+
* const score = useFrameSync(() => gameManager.Score)
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Selector: watch specific properties on a C# proxy object
|
|
28
|
+
* const place = useFrameSync(
|
|
29
|
+
* () => gameState.currentPlace,
|
|
30
|
+
* (p) => [p?.Name, p?.NPCs?.Count, p?.Items?.Count]
|
|
31
|
+
* )
|
|
17
32
|
*
|
|
18
33
|
* @example
|
|
19
|
-
* //
|
|
20
|
-
* const
|
|
34
|
+
* // Selector: with a version stamp from C#
|
|
35
|
+
* const quest = useFrameSync(
|
|
36
|
+
* () => questManager.activeQuest ?? null,
|
|
37
|
+
* (q) => [q?.Version]
|
|
38
|
+
* )
|
|
21
39
|
*
|
|
22
40
|
* @example
|
|
23
|
-
* //
|
|
24
|
-
* const
|
|
41
|
+
* // Simple with dependencies (if the source reference can change)
|
|
42
|
+
* const health = useFrameSync(() => currentPlayer.Health, [currentPlayer])
|
|
25
43
|
*/
|
|
26
|
-
export function useFrameSync<T>(
|
|
27
|
-
|
|
44
|
+
export function useFrameSync<T>(
|
|
45
|
+
getter: () => T,
|
|
46
|
+
selectOrDeps?: ((value: T) => readonly unknown[]) | readonly unknown[],
|
|
47
|
+
deps?: readonly unknown[]
|
|
48
|
+
): T {
|
|
49
|
+
// Determine which mode we're in
|
|
50
|
+
const hasSelector = typeof selectOrDeps === "function"
|
|
51
|
+
const select = hasSelector ? selectOrDeps as (value: T) => readonly unknown[] : undefined
|
|
52
|
+
const effectDeps = hasSelector ? (deps ?? []) : (selectOrDeps as readonly unknown[] ?? [])
|
|
53
|
+
|
|
54
|
+
if (select) {
|
|
55
|
+
return useFrameSyncSelect(getter, select, effectDeps)
|
|
56
|
+
} else {
|
|
57
|
+
return useFrameSyncSimple(getter, effectDeps)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Simple mode: compare with Object.is. */
|
|
62
|
+
function useFrameSyncSimple<T>(getter: () => T, deps: readonly unknown[]): T {
|
|
28
63
|
const getInitialValue = (): T => {
|
|
29
64
|
try {
|
|
30
65
|
return getter()
|
|
@@ -38,11 +73,9 @@ export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []):
|
|
|
38
73
|
const getterRef = useRef(getter)
|
|
39
74
|
const runningRef = useRef(false)
|
|
40
75
|
|
|
41
|
-
// Keep getter ref updated
|
|
42
76
|
getterRef.current = getter
|
|
43
77
|
|
|
44
78
|
useEffect(() => {
|
|
45
|
-
// Re-initialize when deps change
|
|
46
79
|
try {
|
|
47
80
|
const initial = getterRef.current()
|
|
48
81
|
lastValueRef.current = initial
|
|
@@ -81,20 +114,103 @@ export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []):
|
|
|
81
114
|
return value
|
|
82
115
|
}
|
|
83
116
|
|
|
117
|
+
/** Selector mode: extract comparable values, always return fresh from getter. */
|
|
118
|
+
function useFrameSyncSelect<T>(
|
|
119
|
+
getter: () => T,
|
|
120
|
+
select: (value: T) => readonly unknown[],
|
|
121
|
+
deps: readonly unknown[]
|
|
122
|
+
): T {
|
|
123
|
+
const [, forceRender] = useReducer((x: number) => x + 1, 0)
|
|
124
|
+
const getterRef = useRef(getter)
|
|
125
|
+
const selectRef = useRef(select)
|
|
126
|
+
const lastSelectedRef = useRef<readonly unknown[]>([])
|
|
127
|
+
const runningRef = useRef(false)
|
|
128
|
+
const initializedRef = useRef(false)
|
|
129
|
+
|
|
130
|
+
getterRef.current = getter
|
|
131
|
+
selectRef.current = select
|
|
132
|
+
|
|
133
|
+
// Initialize dependency tracking on first render
|
|
134
|
+
if (!initializedRef.current) {
|
|
135
|
+
initializedRef.current = true
|
|
136
|
+
try {
|
|
137
|
+
const val = getter()
|
|
138
|
+
lastSelectedRef.current = select(val)
|
|
139
|
+
} catch {
|
|
140
|
+
// Getter or select failed, keep empty deps
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
try {
|
|
146
|
+
const val = getterRef.current()
|
|
147
|
+
lastSelectedRef.current = selectRef.current(val)
|
|
148
|
+
} catch {
|
|
149
|
+
// Getter or select failed
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
runningRef.current = true
|
|
153
|
+
|
|
154
|
+
const check = () => {
|
|
155
|
+
if (!runningRef.current) return
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const current = getterRef.current()
|
|
159
|
+
const selected = selectRef.current(current)
|
|
160
|
+
const prev = lastSelectedRef.current
|
|
161
|
+
const changed = selected.length !== prev.length ||
|
|
162
|
+
selected.some((val, i) => !Object.is(val, prev[i]))
|
|
163
|
+
|
|
164
|
+
if (changed) {
|
|
165
|
+
lastSelectedRef.current = selected
|
|
166
|
+
forceRender()
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// Getter or select might fail if object was destroyed
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (runningRef.current) {
|
|
173
|
+
requestAnimationFrame(check)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
requestAnimationFrame(check)
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
runningRef.current = false
|
|
181
|
+
}
|
|
182
|
+
}, deps)
|
|
183
|
+
|
|
184
|
+
// Always read fresh from getter during render.
|
|
185
|
+
// This ensures we return the latest proxy with current C# state.
|
|
186
|
+
try {
|
|
187
|
+
return getterRef.current()
|
|
188
|
+
} catch {
|
|
189
|
+
return undefined as T
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
84
193
|
/**
|
|
85
|
-
*
|
|
86
|
-
* Useful for objects/structs where reference equality isn't sufficient.
|
|
194
|
+
* @deprecated Use `useFrameSync` with a selector instead.
|
|
87
195
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
196
|
+
* `useFrameSyncWith` compares the value returned by the getter using a custom
|
|
197
|
+
* equality function. However, this does NOT work with C# proxy objects because
|
|
198
|
+
* the proxy reference is cached — you end up comparing the same object to itself.
|
|
91
199
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
200
|
+
* Instead, use `useFrameSync` with a selector that extracts comparable values:
|
|
201
|
+
* ```ts
|
|
202
|
+
* // Before (broken with C# proxies):
|
|
94
203
|
* const pos = useFrameSyncWith(
|
|
95
204
|
* () => transform.position,
|
|
96
205
|
* (a, b) => a.x === b.x && a.y === b.y && a.z === b.z
|
|
97
206
|
* )
|
|
207
|
+
*
|
|
208
|
+
* // After (works correctly):
|
|
209
|
+
* const pos = useFrameSync(
|
|
210
|
+
* () => transform.position,
|
|
211
|
+
* (p) => [p.x, p.y, p.z]
|
|
212
|
+
* )
|
|
213
|
+
* ```
|
|
98
214
|
*/
|
|
99
215
|
export function useFrameSyncWith<T>(
|
|
100
216
|
getter: () => T,
|
|
@@ -214,3 +330,45 @@ export function useThrottledSync<T>(
|
|
|
214
330
|
|
|
215
331
|
return value
|
|
216
332
|
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Converts a C# collection (List<T>, array, etc.) to a JavaScript array.
|
|
336
|
+
*
|
|
337
|
+
* C# collections exposed through the OneJS proxy are not JS arrays — they
|
|
338
|
+
* lack .map(), .filter(), and other array methods. This utility converts
|
|
339
|
+
* them for use in React rendering.
|
|
340
|
+
*
|
|
341
|
+
* Supports objects with a `.Count` property (List<T>, IList) or a `.Length`
|
|
342
|
+
* property (C# arrays). Returns an empty array for null/undefined input.
|
|
343
|
+
*
|
|
344
|
+
* @param collection - A C# collection, or null/undefined
|
|
345
|
+
* @returns A JavaScript array containing the elements
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* // Map over a C# List in JSX
|
|
349
|
+
* {toArray(inventory.Items).map(item => <ItemView key={item.Id} item={item} />)}
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* // Convert a C# array
|
|
353
|
+
* const renderers = toArray(go.GetComponentsInChildren(CS.UnityEngine.Renderer))
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* // Safe with null — returns []
|
|
357
|
+
* const npcs = toArray(currentPlace?.NPCs)
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* // With explicit type parameter
|
|
361
|
+
* const items = toArray<Item>(questLog.ActiveQuests)
|
|
362
|
+
*/
|
|
363
|
+
export function toArray<T = unknown>(collection: unknown): T[] {
|
|
364
|
+
if (collection == null) return []
|
|
365
|
+
const col = collection as Record<string, unknown>
|
|
366
|
+
const len = typeof col.Count === "number" ? col.Count
|
|
367
|
+
: typeof col.Length === "number" ? col.Length
|
|
368
|
+
: 0
|
|
369
|
+
const result: T[] = []
|
|
370
|
+
for (let i = 0; i < len; i++) {
|
|
371
|
+
result.push((col as any)[i])
|
|
372
|
+
}
|
|
373
|
+
return result
|
|
374
|
+
}
|
package/src/host-config.ts
CHANGED
|
@@ -12,12 +12,6 @@ declare function clearTimeout(id: number): void;
|
|
|
12
12
|
|
|
13
13
|
declare const console: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
|
14
14
|
|
|
15
|
-
// Native delegate callback helpers (from QuickJSBootstrap __csHelpers)
|
|
16
|
-
declare const __csHelpers: {
|
|
17
|
-
createDelegateCallback(fn: Function): number;
|
|
18
|
-
freeDelegateCallback(handle: number): void;
|
|
19
|
-
[key: string]: unknown;
|
|
20
|
-
};
|
|
21
15
|
|
|
22
16
|
// Priority constants from react-reconciler/constants
|
|
23
17
|
// These match React's internal lane priorities
|
|
@@ -156,8 +150,6 @@ export interface Instance {
|
|
|
156
150
|
hasMixedContent?: boolean;
|
|
157
151
|
// For vector drawing: track the current generateVisualContent callback
|
|
158
152
|
visualContentCallback?: GenerateVisualContentCallback;
|
|
159
|
-
// Native callback handle for the above (used to free slot on replacement)
|
|
160
|
-
visualContentCallbackHandle?: number;
|
|
161
153
|
}
|
|
162
154
|
|
|
163
155
|
export type TextInstance = Instance; // For Label elements with text content
|
|
@@ -444,14 +436,6 @@ function untrackParent(child: CSObject) {
|
|
|
444
436
|
}
|
|
445
437
|
}
|
|
446
438
|
|
|
447
|
-
// Free native callback handles tracked on an instance (prevents callback table leak)
|
|
448
|
-
function cleanupCallbackHandles(instance: Instance) {
|
|
449
|
-
if (instance.visualContentCallbackHandle !== undefined) {
|
|
450
|
-
__csHelpers.freeDelegateCallback(instance.visualContentCallbackHandle);
|
|
451
|
-
instance.visualContentCallbackHandle = undefined;
|
|
452
|
-
instance.visualContentCallback = undefined;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
439
|
|
|
456
440
|
// Apply event handlers
|
|
457
441
|
function applyEvents(instance: Instance, props: BaseProps) {
|
|
@@ -486,27 +470,17 @@ function applyVisualContentCallback(instance: Instance, props: BaseProps) {
|
|
|
486
470
|
if (callback !== existingCallback) {
|
|
487
471
|
const element = instance.element as unknown as { generateVisualContent: GenerateVisualContentCallback | null };
|
|
488
472
|
|
|
489
|
-
//
|
|
473
|
+
// Remove old callback if exists
|
|
490
474
|
if (existingCallback) {
|
|
475
|
+
// Clear the delegate via C# interop
|
|
491
476
|
element.generateVisualContent = null;
|
|
492
|
-
if (instance.visualContentCallbackHandle !== undefined) {
|
|
493
|
-
__csHelpers.freeDelegateCallback(instance.visualContentCallbackHandle);
|
|
494
|
-
}
|
|
495
|
-
instance.visualContentCallbackHandle = undefined;
|
|
496
477
|
}
|
|
497
478
|
|
|
498
479
|
// Add new callback if provided
|
|
499
480
|
if (callback) {
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
instance.visualContentCallbackHandle = handle;
|
|
504
|
-
// Pass pre-resolved handle directly — bypasses __resolveValue's auto-registration
|
|
505
|
-
element.generateVisualContent = { __csCallbackHandle: handle } as any;
|
|
506
|
-
} else {
|
|
507
|
-
// WebGL path: no native callback table
|
|
508
|
-
element.generateVisualContent = callback;
|
|
509
|
-
}
|
|
481
|
+
// Assign callback to generateVisualContent property
|
|
482
|
+
// The C# interop layer handles the delegate conversion
|
|
483
|
+
element.generateVisualContent = callback;
|
|
510
484
|
instance.visualContentCallback = callback;
|
|
511
485
|
} else {
|
|
512
486
|
instance.visualContentCallback = undefined;
|
|
@@ -975,7 +949,6 @@ export const hostConfig = {
|
|
|
975
949
|
removeMergedTextChild(parentInstance, child);
|
|
976
950
|
} else {
|
|
977
951
|
__eventAPI.removeAllEventListeners(child.element);
|
|
978
|
-
cleanupCallbackHandles(child);
|
|
979
952
|
parentInstance.element.Remove(child.element);
|
|
980
953
|
}
|
|
981
954
|
untrackParent(child.element);
|
|
@@ -983,7 +956,6 @@ export const hostConfig = {
|
|
|
983
956
|
|
|
984
957
|
removeChildFromContainer(container: Container, child: Instance) {
|
|
985
958
|
__eventAPI.removeAllEventListeners(child.element);
|
|
986
|
-
cleanupCallbackHandles(child);
|
|
987
959
|
container.Remove(child.element);
|
|
988
960
|
untrackParent(child.element);
|
|
989
961
|
},
|
package/src/index.ts
CHANGED
|
@@ -38,8 +38,8 @@ export type {
|
|
|
38
38
|
// Vector Drawing
|
|
39
39
|
export { Transform2D, useVectorContent } from './vector';
|
|
40
40
|
|
|
41
|
-
// Sync Hooks
|
|
42
|
-
export { useFrameSync, useFrameSyncWith, useThrottledSync } from './hooks';
|
|
41
|
+
// Sync Hooks & C# Interop Utilities
|
|
42
|
+
export { useFrameSync, useFrameSyncWith, useThrottledSync, toArray } from './hooks';
|
|
43
43
|
|
|
44
44
|
// Types
|
|
45
45
|
export type {
|