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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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
- * This hook eliminates the need for C# events or codegen - just read any property
7
- * and React will automatically update when it changes.
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 deps - Optional dependency array (if getter depends on changing references)
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
- * // Sync a C# property to React
15
- * const health = useFrameSync(() => player.health)
16
- * const position = useFrameSync(() => transform.position)
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
- * // With dependencies (if the object reference can change)
20
- * const health = useFrameSync(() => currentPlayer.health, [currentPlayer])
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
- * // Derived values work too
24
- * const healthPercent = useFrameSync(() => player.health / player.maxHealth * 100)
41
+ * // Simple with dependencies (if the source reference can change)
42
+ * const health = useFrameSync(() => currentPlayer.Health, [currentPlayer])
25
43
  */
26
- export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []): T {
27
- // Safely get initial value
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
- * Similar to useFrameSync but with a custom equality function.
86
- * Useful for objects/structs where reference equality isn't sufficient.
194
+ * @deprecated Use `useFrameSync` with a selector instead.
87
195
  *
88
- * @param getter - Function that returns the current value
89
- * @param isEqual - Custom equality function
90
- * @param deps - Optional dependency array
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
- * @example
93
- * // Sync a Vector3, comparing by value not reference
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
+ }
@@ -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
- // Free old native callback handle to prevent callback table leak
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
- // Register with argument wrapping and track the handle for cleanup
501
- const handle = __csHelpers.createDelegateCallback(callback);
502
- if (handle >= 0) {
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 (C# interop)
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 {