onejs-react 0.1.8 → 0.1.10

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.
@@ -13,13 +13,13 @@ import { createMockCS, resetAllMocks } from "./mocks";
13
13
 
14
14
  // Extend globalThis type for our mocks
15
15
  declare global {
16
- // eslint-disable-next-line no-var
17
- var CS: ReturnType<typeof createMockCS>;
18
16
  // eslint-disable-next-line no-var
19
17
  var __eventAPI: {
20
18
  addEventListener: ReturnType<typeof vi.fn>;
21
19
  removeEventListener: ReturnType<typeof vi.fn>;
22
20
  removeAllEventListeners: ReturnType<typeof vi.fn>;
21
+ setParent: ReturnType<typeof vi.fn>;
22
+ removeParent: ReturnType<typeof vi.fn>;
23
23
  };
24
24
  }
25
25
 
@@ -33,13 +33,15 @@ beforeEach(() => {
33
33
  resetAllMocks();
34
34
 
35
35
  // Create fresh mock CS global
36
- global.CS = createMockCS();
36
+ (globalThis as any).CS = createMockCS();
37
37
 
38
38
  // Mock event API with spies
39
39
  global.__eventAPI = {
40
40
  addEventListener: vi.fn(),
41
41
  removeEventListener: vi.fn(),
42
42
  removeAllEventListeners: vi.fn(),
43
+ setParent: vi.fn(),
44
+ removeParent: vi.fn(),
43
45
  };
44
46
 
45
47
  // Use real console but spy on it for test assertions
@@ -303,10 +303,12 @@ describe("style-parser", () => {
303
303
  expect(parseStyleValue("flexShrink", 0)).toBe(0)
304
304
  })
305
305
 
306
- it("passes through enum properties unchanged", () => {
307
- expect(parseStyleValue("flexDirection", "row")).toBe("row")
308
- expect(parseStyleValue("display", "none")).toBe("none")
309
- expect(parseStyleValue("position", "absolute")).toBe("absolute")
306
+ it("converts enum properties to Unity enum values", () => {
307
+ const CS = (globalThis as any).CS
308
+ const UIE = CS.UnityEngine.UIElements
309
+ expect(parseStyleValue("flexDirection", "row")).toBe(UIE.FlexDirection.Row)
310
+ expect(parseStyleValue("display", "none")).toBe(UIE.DisplayStyle.None)
311
+ expect(parseStyleValue("position", "absolute")).toBe(UIE.Position.Absolute)
310
312
  })
311
313
 
312
314
  it("passes through unknown properties unchanged", () => {
package/src/hooks.ts CHANGED
@@ -1,30 +1,69 @@
1
- import { useState, useEffect, useRef } from "react"
1
+ import { useState, useEffect, useRef, useReducer } from "react"
2
+
3
+ // QuickJS environment declarations
4
+ declare function requestAnimationFrame(callback: (time: number) => void): number;
5
+ declare function cancelAnimationFrame(id: number): void;
2
6
 
3
7
  /**
4
8
  * Syncs a value from C# (or any external source) to React state, checking every frame.
5
9
  *
6
- * This hook eliminates the need for C# events or codegen - just read any property
7
- * and React will automatically update when it changes.
10
+ * Has two modes depending on whether a `select` function is provided:
11
+ *
12
+ * **Simple mode** (no selector): Compares values with `Object.is`. Best for primitives
13
+ * (numbers, strings, booleans) and cases where the getter returns a new object each time.
14
+ *
15
+ * **Selector mode**: Extracts an array of comparable values to watch. Re-renders only when
16
+ * any selected value changes. Essential for C# proxy objects where the proxy reference is
17
+ * cached — without a selector, you'd be comparing the same proxy to itself.
18
+ * The returned value is always read fresh from the getter during render.
8
19
  *
9
20
  * @param getter - Function that returns the current value (called every frame)
10
- * @param deps - Optional dependency array (if getter depends on changing references)
21
+ * @param selectOrDeps - Either a selector function or a dependency array
22
+ * @param deps - Optional dependency array (only when using a selector)
11
23
  * @returns The current value, updated each frame if changed
12
24
  *
13
25
  * @example
14
- * // Sync a C# property to React
15
- * const health = useFrameSync(() => player.health)
16
- * const position = useFrameSync(() => transform.position)
26
+ * // Simple: sync a C# property (primitives)
27
+ * const health = useFrameSync(() => player.Health)
28
+ * const score = useFrameSync(() => gameManager.Score)
29
+ *
30
+ * @example
31
+ * // Selector: watch specific properties on a C# proxy object
32
+ * const place = useFrameSync(
33
+ * () => gameState.currentPlace,
34
+ * (p) => [p?.Name, p?.NPCs?.Count, p?.Items?.Count]
35
+ * )
17
36
  *
18
37
  * @example
19
- * // With dependencies (if the object reference can change)
20
- * const health = useFrameSync(() => currentPlayer.health, [currentPlayer])
38
+ * // Selector: with a version stamp from C#
39
+ * const quest = useFrameSync(
40
+ * () => questManager.activeQuest ?? null,
41
+ * (q) => [q?.Version]
42
+ * )
21
43
  *
22
44
  * @example
23
- * // Derived values work too
24
- * const healthPercent = useFrameSync(() => player.health / player.maxHealth * 100)
45
+ * // Simple with dependencies (if the source reference can change)
46
+ * const health = useFrameSync(() => currentPlayer.Health, [currentPlayer])
25
47
  */
26
- export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []): T {
27
- // Safely get initial value
48
+ export function useFrameSync<T>(
49
+ getter: () => T,
50
+ selectOrDeps?: ((value: T) => readonly unknown[]) | readonly unknown[],
51
+ deps?: readonly unknown[]
52
+ ): T {
53
+ // Determine which mode we're in
54
+ const hasSelector = typeof selectOrDeps === "function"
55
+ const select = hasSelector ? selectOrDeps as (value: T) => readonly unknown[] : undefined
56
+ const effectDeps = hasSelector ? (deps ?? []) : (selectOrDeps as readonly unknown[] ?? [])
57
+
58
+ if (select) {
59
+ return useFrameSyncSelect(getter, select, effectDeps)
60
+ } else {
61
+ return useFrameSyncSimple(getter, effectDeps)
62
+ }
63
+ }
64
+
65
+ /** Simple mode: compare with Object.is. */
66
+ function useFrameSyncSimple<T>(getter: () => T, deps: readonly unknown[]): T {
28
67
  const getInitialValue = (): T => {
29
68
  try {
30
69
  return getter()
@@ -38,11 +77,9 @@ export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []):
38
77
  const getterRef = useRef(getter)
39
78
  const runningRef = useRef(false)
40
79
 
41
- // Keep getter ref updated
42
80
  getterRef.current = getter
43
81
 
44
82
  useEffect(() => {
45
- // Re-initialize when deps change
46
83
  try {
47
84
  const initial = getterRef.current()
48
85
  lastValueRef.current = initial
@@ -81,20 +118,103 @@ export function useFrameSync<T>(getter: () => T, deps: readonly unknown[] = []):
81
118
  return value
82
119
  }
83
120
 
121
+ /** Selector mode: extract comparable values, always return fresh from getter. */
122
+ function useFrameSyncSelect<T>(
123
+ getter: () => T,
124
+ select: (value: T) => readonly unknown[],
125
+ deps: readonly unknown[]
126
+ ): T {
127
+ const [, forceRender] = useReducer((x: number) => x + 1, 0)
128
+ const getterRef = useRef(getter)
129
+ const selectRef = useRef(select)
130
+ const lastSelectedRef = useRef<readonly unknown[]>([])
131
+ const runningRef = useRef(false)
132
+ const initializedRef = useRef(false)
133
+
134
+ getterRef.current = getter
135
+ selectRef.current = select
136
+
137
+ // Initialize dependency tracking on first render
138
+ if (!initializedRef.current) {
139
+ initializedRef.current = true
140
+ try {
141
+ const val = getter()
142
+ lastSelectedRef.current = select(val)
143
+ } catch {
144
+ // Getter or select failed, keep empty deps
145
+ }
146
+ }
147
+
148
+ useEffect(() => {
149
+ try {
150
+ const val = getterRef.current()
151
+ lastSelectedRef.current = selectRef.current(val)
152
+ } catch {
153
+ // Getter or select failed
154
+ }
155
+
156
+ runningRef.current = true
157
+
158
+ const check = () => {
159
+ if (!runningRef.current) return
160
+
161
+ try {
162
+ const current = getterRef.current()
163
+ const selected = selectRef.current(current)
164
+ const prev = lastSelectedRef.current
165
+ const changed = selected.length !== prev.length ||
166
+ selected.some((val, i) => !Object.is(val, prev[i]))
167
+
168
+ if (changed) {
169
+ lastSelectedRef.current = selected
170
+ forceRender()
171
+ }
172
+ } catch {
173
+ // Getter or select might fail if object was destroyed
174
+ }
175
+
176
+ if (runningRef.current) {
177
+ requestAnimationFrame(check)
178
+ }
179
+ }
180
+
181
+ requestAnimationFrame(check)
182
+
183
+ return () => {
184
+ runningRef.current = false
185
+ }
186
+ }, deps)
187
+
188
+ // Always read fresh from getter during render.
189
+ // This ensures we return the latest proxy with current C# state.
190
+ try {
191
+ return getterRef.current()
192
+ } catch {
193
+ return undefined as T
194
+ }
195
+ }
196
+
84
197
  /**
85
- * Similar to useFrameSync but with a custom equality function.
86
- * Useful for objects/structs where reference equality isn't sufficient.
198
+ * @deprecated Use `useFrameSync` with a selector instead.
87
199
  *
88
- * @param getter - Function that returns the current value
89
- * @param isEqual - Custom equality function
90
- * @param deps - Optional dependency array
200
+ * `useFrameSyncWith` compares the value returned by the getter using a custom
201
+ * equality function. However, this does NOT work with C# proxy objects because
202
+ * the proxy reference is cached — you end up comparing the same object to itself.
91
203
  *
92
- * @example
93
- * // Sync a Vector3, comparing by value not reference
204
+ * Instead, use `useFrameSync` with a selector that extracts comparable values:
205
+ * ```ts
206
+ * // Before (broken with C# proxies):
94
207
  * const pos = useFrameSyncWith(
95
208
  * () => transform.position,
96
209
  * (a, b) => a.x === b.x && a.y === b.y && a.z === b.z
97
210
  * )
211
+ *
212
+ * // After (works correctly):
213
+ * const pos = useFrameSync(
214
+ * () => transform.position,
215
+ * (p) => [p.x, p.y, p.z]
216
+ * )
217
+ * ```
98
218
  */
99
219
  export function useFrameSyncWith<T>(
100
220
  getter: () => T,
@@ -214,3 +334,45 @@ export function useThrottledSync<T>(
214
334
 
215
335
  return value
216
336
  }
337
+
338
+ /**
339
+ * Converts a C# collection (List<T>, array, etc.) to a JavaScript array.
340
+ *
341
+ * C# collections exposed through the OneJS proxy are not JS arrays — they
342
+ * lack .map(), .filter(), and other array methods. This utility converts
343
+ * them for use in React rendering.
344
+ *
345
+ * Supports objects with a `.Count` property (List<T>, IList) or a `.Length`
346
+ * property (C# arrays). Returns an empty array for null/undefined input.
347
+ *
348
+ * @param collection - A C# collection, or null/undefined
349
+ * @returns A JavaScript array containing the elements
350
+ *
351
+ * @example
352
+ * // Map over a C# List in JSX
353
+ * {toArray(inventory.Items).map(item => <ItemView key={item.Id} item={item} />)}
354
+ *
355
+ * @example
356
+ * // Convert a C# array
357
+ * const renderers = toArray(go.GetComponentsInChildren(CS.UnityEngine.Renderer))
358
+ *
359
+ * @example
360
+ * // Safe with null — returns []
361
+ * const npcs = toArray(currentPlace?.NPCs)
362
+ *
363
+ * @example
364
+ * // With explicit type parameter
365
+ * const items = toArray<Item>(questLog.ActiveQuests)
366
+ */
367
+ export function toArray<T = unknown>(collection: unknown): T[] {
368
+ if (collection == null) return []
369
+ const col = collection as Record<string, unknown>
370
+ const len = typeof col.Count === "number" ? col.Count
371
+ : typeof col.Length === "number" ? col.Length
372
+ : 0
373
+ const result: T[] = []
374
+ for (let i = 0; i < len; i++) {
375
+ result.push((col as any)[i])
376
+ }
377
+ return result
378
+ }
@@ -56,7 +56,10 @@ declare const CS: {
56
56
  CollectionVirtualizationMethod: CSEnum;
57
57
  DisplayStyle: CSEnum;
58
58
  PickingMode: CSEnum;
59
+ SliderDirection: CSEnum;
59
60
  };
61
+ ScaleMode: CSEnum;
62
+ Rect: new (...args: any[]) => any;
60
63
  };
61
64
  OneJS: {
62
65
  GPU: {
@@ -578,9 +581,10 @@ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
578
581
 
579
582
  // Apply common props (text, value, label)
580
583
  function applyCommonProps(element: CSObject, props: Record<string, unknown>) {
581
- if (props.text !== undefined) (element as { text: string }).text = props.text as string;
582
- if (props.value !== undefined) (element as { value: unknown }).value = props.value;
583
- if (props.label !== undefined) (element as { label: string }).label = props.label as string;
584
+ const el = element as any;
585
+ if (props.text !== undefined) el.text = props.text as string;
586
+ if (props.value !== undefined) el.value = props.value;
587
+ if (props.label !== undefined) el.label = props.label as string;
584
588
  }
585
589
 
586
590
  // Helper to set enum prop if defined
@@ -599,106 +603,62 @@ function setValueProp<T>(target: T, key: keyof T, props: Record<string, unknown>
599
603
 
600
604
  // Apply TextField-specific properties
601
605
  function applyTextFieldProps(element: CSObject, props: Record<string, unknown>) {
602
- // Map readOnly prop to isReadOnly property
603
- if (props.readOnly !== undefined) {
604
- (element as { isReadOnly: boolean }).isReadOnly = props.readOnly as boolean;
605
- }
606
- if (props.multiline !== undefined) {
607
- (element as { multiline: boolean }).multiline = props.multiline as boolean;
608
- }
609
- if (props.maxLength !== undefined) {
610
- (element as { maxLength: number }).maxLength = props.maxLength as number;
611
- }
612
- if (props.isPasswordField !== undefined) {
613
- (element as { isPasswordField: boolean }).isPasswordField = props.isPasswordField as boolean;
614
- }
615
- if (props.maskChar !== undefined) {
616
- (element as { maskChar: string }).maskChar = (props.maskChar as string).charAt(0);
617
- }
618
- if (props.isDelayed !== undefined) {
619
- (element as { isDelayed: boolean }).isDelayed = props.isDelayed as boolean;
620
- }
621
- if (props.selectAllOnFocus !== undefined) {
622
- (element as { selectAllOnFocus: boolean }).selectAllOnFocus = props.selectAllOnFocus as boolean;
623
- }
624
- if (props.selectAllOnMouseUp !== undefined) {
625
- (element as { selectAllOnMouseUp: boolean }).selectAllOnMouseUp = props.selectAllOnMouseUp as boolean;
626
- }
627
- if (props.hideMobileInput !== undefined) {
628
- (element as { hideMobileInput: boolean }).hideMobileInput = props.hideMobileInput as boolean;
629
- }
630
- if (props.autoCorrection !== undefined) {
631
- (element as { autoCorrection: boolean }).autoCorrection = props.autoCorrection as boolean;
632
- }
606
+ const el = element as any;
607
+ if (props.readOnly !== undefined) el.isReadOnly = props.readOnly;
608
+ if (props.multiline !== undefined) el.multiline = props.multiline;
609
+ if (props.maxLength !== undefined) el.maxLength = props.maxLength;
610
+ if (props.isPasswordField !== undefined) el.isPasswordField = props.isPasswordField;
611
+ if (props.maskChar !== undefined) el.maskChar = (props.maskChar as string).charAt(0);
612
+ if (props.isDelayed !== undefined) el.isDelayed = props.isDelayed;
613
+ if (props.selectAllOnFocus !== undefined) el.selectAllOnFocus = props.selectAllOnFocus;
614
+ if (props.selectAllOnMouseUp !== undefined) el.selectAllOnMouseUp = props.selectAllOnMouseUp;
615
+ if (props.hideMobileInput !== undefined) el.hideMobileInput = props.hideMobileInput;
616
+ if (props.autoCorrection !== undefined) el.autoCorrection = props.autoCorrection;
633
617
  // Note: placeholder is handled differently in Unity - it's set via the textEdition interface
634
618
  // For now we skip it as it requires more complex handling
635
619
  }
636
620
 
637
621
  // Apply Slider-specific properties
638
622
  function applySliderProps(element: CSObject, props: Record<string, unknown>) {
639
- if (props.lowValue !== undefined) {
640
- (element as { lowValue: number }).lowValue = props.lowValue as number;
641
- }
642
- if (props.highValue !== undefined) {
643
- (element as { highValue: number }).highValue = props.highValue as number;
644
- }
645
- if (props.showInputField !== undefined) {
646
- (element as { showInputField: boolean }).showInputField = props.showInputField as boolean;
647
- }
648
- if (props.inverted !== undefined) {
649
- (element as { inverted: boolean }).inverted = props.inverted as boolean;
650
- }
651
- if (props.pageSize !== undefined) {
652
- (element as { pageSize: number }).pageSize = props.pageSize as number;
653
- }
654
- if (props.fill !== undefined) {
655
- (element as { fill: boolean }).fill = props.fill as boolean;
656
- }
623
+ const el = element as any;
624
+ if (props.lowValue !== undefined) el.lowValue = props.lowValue;
625
+ if (props.highValue !== undefined) el.highValue = props.highValue;
626
+ if (props.showInputField !== undefined) el.showInputField = props.showInputField;
627
+ if (props.inverted !== undefined) el.inverted = props.inverted;
628
+ if (props.pageSize !== undefined) el.pageSize = props.pageSize;
629
+ if (props.fill !== undefined) el.fill = props.fill;
657
630
  if (props.direction !== undefined) {
658
- const UIE = CS.UnityEngine.UIElements;
659
- (element as { direction: unknown }).direction = UIE.SliderDirection[props.direction as string];
631
+ el.direction = CS.UnityEngine.UIElements.SliderDirection[props.direction as string];
660
632
  }
661
633
  }
662
634
 
663
635
  // Apply Toggle-specific properties
664
636
  function applyToggleProps(element: CSObject, props: Record<string, unknown>) {
665
- if (props.text !== undefined) {
666
- (element as { text: string }).text = props.text as string;
667
- }
668
- if (props.toggleOnLabelClick !== undefined) {
669
- (element as { toggleOnLabelClick: boolean }).toggleOnLabelClick = props.toggleOnLabelClick as boolean;
670
- }
637
+ const el = element as any;
638
+ if (props.text !== undefined) el.text = props.text;
639
+ if (props.toggleOnLabelClick !== undefined) el.toggleOnLabelClick = props.toggleOnLabelClick;
671
640
  }
672
641
 
673
642
  // Apply Image-specific properties
674
643
  function applyImageProps(element: CSObject, props: Record<string, unknown>) {
675
- if (props.image !== undefined) {
676
- (element as { image: unknown }).image = props.image;
677
- }
678
- if (props.sprite !== undefined) {
679
- (element as { sprite: unknown }).sprite = props.sprite;
680
- }
681
- if (props.vectorImage !== undefined) {
682
- (element as { vectorImage: unknown }).vectorImage = props.vectorImage;
683
- }
644
+ const el = element as any;
645
+ if (props.image !== undefined) el.image = props.image;
646
+ if (props.sprite !== undefined) el.sprite = props.sprite;
647
+ if (props.vectorImage !== undefined) el.vectorImage = props.vectorImage;
684
648
  if (props.scaleMode !== undefined) {
685
- const scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
686
- (element as { scaleMode: unknown }).scaleMode = scaleMode;
649
+ el.scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
687
650
  }
688
651
  if (props.tintColor !== undefined) {
689
- // Parse color string to Unity Color
690
652
  const color = parseColor(props.tintColor as string);
691
- if (color) {
692
- (element as { tintColor: unknown }).tintColor = color;
693
- }
653
+ if (color) el.tintColor = color;
694
654
  }
695
655
  if (props.sourceRect !== undefined) {
696
656
  const rect = props.sourceRect as { x: number; y: number; width: number; height: number };
697
- (element as { sourceRect: unknown }).sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
657
+ el.sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
698
658
  }
699
659
  if (props.uv !== undefined) {
700
660
  const rect = props.uv as { x: number; y: number; width: number; height: number };
701
- (element as { uv: unknown }).uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
661
+ el.uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
702
662
  }
703
663
  }
704
664
 
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 {