onejs-react 0.1.18 → 0.1.20

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.18",
3
+ "version": "0.1.20",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
12
12
  import React from "react"
13
- import { useFrameSync, useFrameSyncWith, toArray } from "../hooks"
13
+ import { useFrameSync, useFrameSyncWith, useEventSync, toArray } from "../hooks"
14
14
  import { render, unmount } from "../renderer"
15
15
  import { createMockContainer, flushMicrotasks } from "./mocks"
16
16
 
@@ -579,3 +579,435 @@ describe("useFrameSyncWith (deprecated)", () => {
579
579
  expect(capturedValue.x).toBe(10)
580
580
  })
581
581
  })
582
+
583
+ // ===========================================================================
584
+ // useEventSync
585
+ // ===========================================================================
586
+
587
+ /**
588
+ * Creates a mock C# object with event subscription support.
589
+ * Simulates the bootstrap's add_EventName / remove_EventName proxy mechanism.
590
+ */
591
+ function createMockCSharpObject(initialValues: Record<string, unknown>) {
592
+ const listeners = new Map<string, Set<Function>>()
593
+ const values = { ...initialValues }
594
+
595
+ const proxy = new Proxy(values, {
596
+ get(target, prop) {
597
+ const propName = String(prop)
598
+ if (propName.startsWith("add_")) {
599
+ return (handler: Function) => {
600
+ const eventName = propName.slice(4)
601
+ if (!listeners.has(eventName)) listeners.set(eventName, new Set())
602
+ listeners.get(eventName)!.add(handler)
603
+ }
604
+ }
605
+ if (propName.startsWith("remove_")) {
606
+ return (handler: Function) => {
607
+ const eventName = propName.slice(7)
608
+ listeners.get(eventName)?.delete(handler)
609
+ }
610
+ }
611
+ return target[propName]
612
+ },
613
+ })
614
+
615
+ return {
616
+ proxy,
617
+ fire(eventName: string) {
618
+ listeners.get(eventName)?.forEach(h => (h as any)())
619
+ },
620
+ set(prop: string, value: unknown) {
621
+ (values as any)[prop] = value
622
+ },
623
+ listenerCount(eventName: string) {
624
+ return listeners.get(eventName)?.size ?? 0
625
+ },
626
+ }
627
+ }
628
+
629
+ describe("useEventSync", () => {
630
+ // -- Convention form --
631
+
632
+ describe("convention form", () => {
633
+ it("reads initial value on mount", async () => {
634
+ const obj = createMockCSharpObject({ Health: 100 })
635
+ let capturedValue: unknown
636
+
637
+ function TestComponent() {
638
+ capturedValue = useEventSync(obj.proxy, "Health")
639
+ return null
640
+ }
641
+
642
+ const container = createMockContainer()
643
+ render(React.createElement(TestComponent), container)
644
+ await flushMicrotasks()
645
+
646
+ expect(capturedValue).toBe(100)
647
+ })
648
+
649
+ it("subscribes to OnPropertyChanged event", async () => {
650
+ const obj = createMockCSharpObject({ Health: 100 })
651
+
652
+ function TestComponent() {
653
+ useEventSync(obj.proxy, "Health")
654
+ return null
655
+ }
656
+
657
+ const container = createMockContainer()
658
+ render(React.createElement(TestComponent), container)
659
+ await flushMicrotasks()
660
+
661
+ expect(obj.listenerCount("OnHealthChanged")).toBe(1)
662
+ })
663
+
664
+ it("updates when event fires", async () => {
665
+ const obj = createMockCSharpObject({ Health: 100 })
666
+ let capturedValue: unknown
667
+
668
+ function TestComponent() {
669
+ capturedValue = useEventSync(obj.proxy, "Health")
670
+ return null
671
+ }
672
+
673
+ const container = createMockContainer()
674
+ render(React.createElement(TestComponent), container)
675
+ await flushMicrotasks()
676
+ expect(capturedValue).toBe(100)
677
+
678
+ obj.set("Health", 75)
679
+ obj.fire("OnHealthChanged")
680
+ await flushMicrotasks()
681
+ expect(capturedValue).toBe(75)
682
+ })
683
+
684
+ it("unsubscribes on unmount", async () => {
685
+ const obj = createMockCSharpObject({ Health: 100 })
686
+
687
+ function TestComponent() {
688
+ useEventSync(obj.proxy, "Health")
689
+ return null
690
+ }
691
+
692
+ const container = createMockContainer()
693
+ render(React.createElement(TestComponent), container)
694
+ await flushMicrotasks()
695
+ expect(obj.listenerCount("OnHealthChanged")).toBe(1)
696
+
697
+ unmount(container)
698
+ await flushMicrotasks()
699
+ expect(obj.listenerCount("OnHealthChanged")).toBe(0)
700
+ })
701
+
702
+ it("does not poll via RAF", async () => {
703
+ const obj = createMockCSharpObject({ Health: 100 })
704
+
705
+ function TestComponent() {
706
+ useEventSync(obj.proxy, "Health")
707
+ return null
708
+ }
709
+
710
+ const container = createMockContainer()
711
+ render(React.createElement(TestComponent), container)
712
+ await flushMicrotasks()
713
+
714
+ // RAF should not have been called by useEventSync
715
+ // (useFrameSync calls it, useEventSync should not)
716
+ const rafCallCount = (globalThis as any).requestAnimationFrame.mock.calls.length
717
+ // Advance several frames — count should not grow from useEventSync
718
+ await advanceFrame()
719
+ await advanceFrame()
720
+ await advanceFrame()
721
+ const rafCallCountAfter = (globalThis as any).requestAnimationFrame.mock.calls.length
722
+ expect(rafCallCountAfter).toBe(rafCallCount)
723
+ })
724
+
725
+ it("handles null source without crashing", async () => {
726
+ let capturedValue: unknown
727
+
728
+ function TestComponent() {
729
+ capturedValue = useEventSync(null as any, "Health")
730
+ return null
731
+ }
732
+
733
+ const container = createMockContainer()
734
+ render(React.createElement(TestComponent), container)
735
+ await flushMicrotasks()
736
+
737
+ expect(capturedValue).toBeUndefined()
738
+ })
739
+
740
+ it("re-subscribes when deps change", async () => {
741
+ const obj1 = createMockCSharpObject({ Health: 100 })
742
+ const obj2 = createMockCSharpObject({ Health: 200 })
743
+ let capturedValue: unknown
744
+ let currentSource = obj1
745
+
746
+ function TestComponent({ source }: { source: any }) {
747
+ capturedValue = useEventSync(source.proxy, "Health", [source.proxy])
748
+ return null
749
+ }
750
+
751
+ const container = createMockContainer()
752
+ render(React.createElement(TestComponent, { source: currentSource }), container)
753
+ await flushMicrotasks()
754
+ expect(capturedValue).toBe(100)
755
+ expect(obj1.listenerCount("OnHealthChanged")).toBe(1)
756
+
757
+ // Switch source
758
+ currentSource = obj2
759
+ render(React.createElement(TestComponent, { source: currentSource }), container)
760
+ await flushMicrotasks()
761
+ expect(capturedValue).toBe(200)
762
+ expect(obj1.listenerCount("OnHealthChanged")).toBe(0)
763
+ expect(obj2.listenerCount("OnHealthChanged")).toBe(1)
764
+ })
765
+
766
+ it("handles multiple rapid events correctly", async () => {
767
+ const obj = createMockCSharpObject({ Score: 0 })
768
+ let capturedValue: unknown
769
+
770
+ function TestComponent() {
771
+ capturedValue = useEventSync(obj.proxy, "Score")
772
+ return null
773
+ }
774
+
775
+ const container = createMockContainer()
776
+ render(React.createElement(TestComponent), container)
777
+ await flushMicrotasks()
778
+
779
+ obj.set("Score", 10)
780
+ obj.fire("OnScoreChanged")
781
+ obj.set("Score", 20)
782
+ obj.fire("OnScoreChanged")
783
+ obj.set("Score", 30)
784
+ obj.fire("OnScoreChanged")
785
+ await flushMicrotasks()
786
+
787
+ expect(capturedValue).toBe(30)
788
+ })
789
+ })
790
+
791
+ // -- Explicit form --
792
+
793
+ describe("explicit form", () => {
794
+ it("reads initial value from custom getter", async () => {
795
+ const obj = createMockCSharpObject({ Items: { Count: 5 } })
796
+ let capturedValue: unknown
797
+
798
+ function TestComponent() {
799
+ capturedValue = useEventSync(
800
+ () => (obj.proxy as any).Items.Count,
801
+ [[obj.proxy, "OnItemsChanged"]]
802
+ )
803
+ return null
804
+ }
805
+
806
+ const container = createMockContainer()
807
+ render(React.createElement(TestComponent), container)
808
+ await flushMicrotasks()
809
+
810
+ expect(capturedValue).toBe(5)
811
+ })
812
+
813
+ it("subscribes to multiple events", async () => {
814
+ const obj = createMockCSharpObject({ Count: 0 })
815
+
816
+ function TestComponent() {
817
+ useEventSync(
818
+ () => (obj.proxy as any).Count,
819
+ [[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
820
+ )
821
+ return null
822
+ }
823
+
824
+ const container = createMockContainer()
825
+ render(React.createElement(TestComponent), container)
826
+ await flushMicrotasks()
827
+
828
+ expect(obj.listenerCount("OnItemAdded")).toBe(1)
829
+ expect(obj.listenerCount("OnItemRemoved")).toBe(1)
830
+ })
831
+
832
+ it("updates on any subscribed event", async () => {
833
+ const obj = createMockCSharpObject({ Count: 0 })
834
+ let capturedValue: unknown
835
+
836
+ function TestComponent() {
837
+ capturedValue = useEventSync(
838
+ () => (obj.proxy as any).Count,
839
+ [[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
840
+ )
841
+ return null
842
+ }
843
+
844
+ const container = createMockContainer()
845
+ render(React.createElement(TestComponent), container)
846
+ await flushMicrotasks()
847
+ expect(capturedValue).toBe(0)
848
+
849
+ obj.set("Count", 3)
850
+ obj.fire("OnItemAdded")
851
+ await flushMicrotasks()
852
+ expect(capturedValue).toBe(3)
853
+
854
+ obj.set("Count", 2)
855
+ obj.fire("OnItemRemoved")
856
+ await flushMicrotasks()
857
+ expect(capturedValue).toBe(2)
858
+ })
859
+
860
+ it("unsubscribes all events on unmount", async () => {
861
+ const obj = createMockCSharpObject({ Count: 0 })
862
+
863
+ function TestComponent() {
864
+ useEventSync(
865
+ () => (obj.proxy as any).Count,
866
+ [[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
867
+ )
868
+ return null
869
+ }
870
+
871
+ const container = createMockContainer()
872
+ render(React.createElement(TestComponent), container)
873
+ await flushMicrotasks()
874
+ expect(obj.listenerCount("OnItemAdded")).toBe(1)
875
+ expect(obj.listenerCount("OnItemRemoved")).toBe(1)
876
+
877
+ unmount(container)
878
+ await flushMicrotasks()
879
+ expect(obj.listenerCount("OnItemAdded")).toBe(0)
880
+ expect(obj.listenerCount("OnItemRemoved")).toBe(0)
881
+ })
882
+
883
+ it("supports events from multiple sources", async () => {
884
+ const inventory = createMockCSharpObject({ Count: 5 })
885
+ const player = createMockCSharpObject({ Level: 1 })
886
+ let capturedValue: unknown
887
+
888
+ function TestComponent() {
889
+ capturedValue = useEventSync(
890
+ () => (inventory.proxy as any).Count * (player.proxy as any).Level,
891
+ [[inventory.proxy, "OnChanged"], [player.proxy, "OnLevelUp"]]
892
+ )
893
+ return null
894
+ }
895
+
896
+ const container = createMockContainer()
897
+ render(React.createElement(TestComponent), container)
898
+ await flushMicrotasks()
899
+ expect(capturedValue).toBe(5)
900
+
901
+ player.set("Level", 2)
902
+ player.fire("OnLevelUp")
903
+ await flushMicrotasks()
904
+ expect(capturedValue).toBe(10)
905
+
906
+ inventory.set("Count", 10)
907
+ inventory.fire("OnChanged")
908
+ await flushMicrotasks()
909
+ expect(capturedValue).toBe(20)
910
+ })
911
+
912
+ it("handles getter that throws", async () => {
913
+ let shouldThrow = false
914
+ let capturedValue: unknown
915
+
916
+ const obj = createMockCSharpObject({})
917
+
918
+ function TestComponent() {
919
+ capturedValue = useEventSync(
920
+ () => {
921
+ if (shouldThrow) throw new Error("destroyed")
922
+ return 42
923
+ },
924
+ [[obj.proxy, "OnChanged"]]
925
+ )
926
+ return null
927
+ }
928
+
929
+ const container = createMockContainer()
930
+ render(React.createElement(TestComponent), container)
931
+ await flushMicrotasks()
932
+ expect(capturedValue).toBe(42)
933
+
934
+ shouldThrow = true
935
+ obj.fire("OnChanged")
936
+ await flushMicrotasks()
937
+ // Should not crash — value stays at last good value
938
+ expect(capturedValue).toBe(42)
939
+ })
940
+
941
+ it("works with static-like event sources", async () => {
942
+ const staticClass = createMockCSharpObject({ Score: 999 })
943
+ let capturedValue: unknown
944
+
945
+ function TestComponent() {
946
+ capturedValue = useEventSync(
947
+ () => (staticClass.proxy as any).Score,
948
+ [[staticClass.proxy, "OnScoreChanged"]]
949
+ )
950
+ return null
951
+ }
952
+
953
+ const container = createMockContainer()
954
+ render(React.createElement(TestComponent), container)
955
+ await flushMicrotasks()
956
+ expect(capturedValue).toBe(999)
957
+
958
+ staticClass.set("Score", 1500)
959
+ staticClass.fire("OnScoreChanged")
960
+ await flushMicrotasks()
961
+ expect(capturedValue).toBe(1500)
962
+ })
963
+
964
+ it("handler still fires when event passes arguments (ignored by design)", async () => {
965
+ const obj = createMockCSharpObject({ Health: 100 })
966
+ let capturedValue: unknown
967
+
968
+ function TestComponent() {
969
+ capturedValue = useEventSync(obj.proxy, "Health")
970
+ return null
971
+ }
972
+
973
+ const container = createMockContainer()
974
+ render(React.createElement(TestComponent), container)
975
+ await flushMicrotasks()
976
+ expect(capturedValue).toBe(100)
977
+
978
+ // C# event fires — handler re-reads getter, ignoring event args
979
+ obj.set("Health", 80)
980
+ obj.fire("OnHealthChanged")
981
+ await flushMicrotasks()
982
+ expect(capturedValue).toBe(80)
983
+ })
984
+
985
+ it("multiple components subscribe to the same event independently", async () => {
986
+ const obj = createMockCSharpObject({ Health: 100 })
987
+ let value1: unknown, value2: unknown
988
+
989
+ function Component1() { value1 = useEventSync(() => (obj.proxy as any).Health, [[obj.proxy, "OnHealthChanged"]]); return null }
990
+ function Component2() { value2 = useEventSync(() => (obj.proxy as any).Health, [[obj.proxy, "OnHealthChanged"]]); return null }
991
+
992
+ function Parent() {
993
+ return React.createElement(React.Fragment, null,
994
+ React.createElement(Component1),
995
+ React.createElement(Component2)
996
+ )
997
+ }
998
+
999
+ const container = createMockContainer()
1000
+ render(React.createElement(Parent), container)
1001
+ await flushMicrotasks()
1002
+ expect(value1).toBe(100)
1003
+ expect(value2).toBe(100)
1004
+ expect(obj.listenerCount("OnHealthChanged")).toBe(2)
1005
+
1006
+ obj.set("Health", 50)
1007
+ obj.fire("OnHealthChanged")
1008
+ await flushMicrotasks()
1009
+ expect(value1).toBe(50)
1010
+ expect(value2).toBe(50)
1011
+ })
1012
+ })
1013
+ })
@@ -262,6 +262,7 @@ export function createMockCS() {
262
262
  Path: {
263
263
  Combine: (...parts: string[]) => parts.join("/"),
264
264
  GetDirectoryName: (p: string) => p.substring(0, p.lastIndexOf("/")),
265
+ IsPathRooted: (p: string) => p.startsWith("/"),
265
266
  },
266
267
  File: {
267
268
  Exists: (path: string) => mockFileSystem.has(path),
package/src/hooks.ts CHANGED
@@ -376,3 +376,113 @@ export function toArray<T = unknown>(collection: unknown): T[] {
376
376
  }
377
377
  return result
378
378
  }
379
+
380
+ /**
381
+ * Event source descriptor for useEventSync: [sourceObject, eventName].
382
+ * The eventName should NOT include the "add_" / "remove_" prefix.
383
+ */
384
+ export type EventSource = [source: object, eventName: string]
385
+
386
+ /**
387
+ * Syncs a value from C# to React state via event subscription instead of polling.
388
+ * Zero work when nothing changes — the getter is only called when an event fires.
389
+ *
390
+ * Use this instead of `useFrameSync` when C# fires events on state change.
391
+ * `useFrameSync` polls every frame (causing GC pressure); `useEventSync` does
392
+ * zero work between events.
393
+ *
394
+ * **Convention form**: Derives the getter and event name from a property name.
395
+ * `useEventSync(source, "Health")` subscribes to `source.add_OnHealthChanged`
396
+ * and reads `source.Health`. The C# side must have an event named `On{Prop}Changed`.
397
+ *
398
+ * **Explicit form**: User-provided getter and event descriptors.
399
+ * Supports multiple event sources, static events, and derived state.
400
+ *
401
+ * If the source object can be null or change over time, pass it in deps:
402
+ * `useEventSync(player, "Health", [player])`
403
+ *
404
+ * Events must fire on Unity's main thread (the normal case for MonoBehaviour methods).
405
+ *
406
+ * @example
407
+ * // Convention: subscribes to player.add_OnHealthChanged, reads player.Health
408
+ * const health = useEventSync(player, "Health")
409
+ *
410
+ * @example
411
+ * // Convention with deps (required if source can change or start null)
412
+ * const health = useEventSync(currentPlayer, "Health", [currentPlayer])
413
+ *
414
+ * @example
415
+ * // Explicit: custom getter, multiple events
416
+ * const itemCount = useEventSync(
417
+ * () => inventory.Items.Count,
418
+ * [[inventory, "OnItemAdded"], [inventory, "OnItemRemoved"]]
419
+ * )
420
+ *
421
+ * @example
422
+ * // Explicit: static events
423
+ * const state = useEventSync(
424
+ * () => CS.GameManager.Score,
425
+ * [[CS.GameManager, "OnScoreChanged"]]
426
+ * )
427
+ */
428
+ export function useEventSync<T>(getter: () => T, events: EventSource[], deps?: readonly unknown[]): T
429
+ export function useEventSync(source: object, propertyName: string, deps?: readonly unknown[]): unknown
430
+ export function useEventSync<T>(
431
+ sourceOrGetter: object | (() => T),
432
+ propOrEvents: string | EventSource[],
433
+ depsOrNothing?: readonly unknown[]
434
+ ): T {
435
+ if (typeof sourceOrGetter === "function") {
436
+ return useEventSyncImpl(
437
+ sourceOrGetter as () => T,
438
+ propOrEvents as EventSource[],
439
+ depsOrNothing ?? []
440
+ )
441
+ }
442
+
443
+ const source = sourceOrGetter
444
+ const propName = propOrEvents as string
445
+ return useEventSyncImpl(
446
+ () => source ? (source as any)[propName] : undefined,
447
+ source ? [[source, `On${propName}Changed`]] : [],
448
+ depsOrNothing ?? []
449
+ )
450
+ }
451
+
452
+ function useEventSyncImpl<T>(
453
+ getter: () => T,
454
+ events: EventSource[],
455
+ deps: readonly unknown[]
456
+ ): T {
457
+ const [value, setValue] = useState<T>(() => {
458
+ try { return getter() } catch { return undefined as T }
459
+ })
460
+ const getterRef = useRef(getter)
461
+ getterRef.current = getter
462
+
463
+ useEffect(() => {
464
+ try { setValue(getterRef.current()) } catch {}
465
+
466
+ const handler = () => {
467
+ try { setValue(getterRef.current()) } catch {}
468
+ }
469
+
470
+ for (const [source, eventName] of events) {
471
+ try {
472
+ const addFn = (source as any)[`add_${eventName}`]
473
+ if (typeof addFn === "function") addFn(handler)
474
+ } catch {}
475
+ }
476
+
477
+ return () => {
478
+ for (const [source, eventName] of events) {
479
+ try {
480
+ const removeFn = (source as any)[`remove_${eventName}`]
481
+ if (typeof removeFn === "function") removeFn(handler)
482
+ } catch {}
483
+ }
484
+ }
485
+ }, deps)
486
+
487
+ return value
488
+ }
@@ -303,19 +303,39 @@ const CLASS_ESCAPE_MAP: [string, string][] = [
303
303
  ];
304
304
 
305
305
  /**
306
- * Escape special characters in a class name for USS compatibility
306
+ * Escape special characters in a class name for USS compatibility.
307
307
  * Tailwind class names like "hover:bg-red-500" become "hover_c_bg-red-500"
308
+ * Results are cached since the same class names are used repeatedly across renders.
308
309
  */
310
+ const _escapeCache = new Map<string, string>();
309
311
  function escapeClassName(name: string): string {
312
+ const cached = _escapeCache.get(name);
313
+ if (cached !== undefined) return cached;
310
314
  let escaped = name;
311
315
  for (const [char, replacement] of CLASS_ESCAPE_MAP) {
312
316
  if (escaped.includes(char)) {
313
317
  escaped = escaped.split(char).join(replacement);
314
318
  }
315
319
  }
320
+ _escapeCache.set(name, escaped);
316
321
  return escaped;
317
322
  }
318
323
 
324
+ // Shallow equality check for plain objects (style, props).
325
+ // Returns true if both have identical own-property values (===).
326
+ function shallowEqual(a: Record<string, unknown> | undefined, b: Record<string, unknown> | undefined): boolean {
327
+ if (a === b) return true;
328
+ if (!a || !b) return false;
329
+ const keysA = Object.keys(a);
330
+ const keysB = Object.keys(b);
331
+ if (keysA.length !== keysB.length) return false;
332
+ for (let i = 0; i < keysA.length; i++) {
333
+ const k = keysA[i];
334
+ if (a[k] !== b[k]) return false;
335
+ }
336
+ return true;
337
+ }
338
+
319
339
  // Get all expanded property keys for a style object
320
340
  function getExpandedStyleKeys(style: ViewStyle | undefined): Set<string> {
321
341
  const keys = new Set<string>();
@@ -422,14 +442,20 @@ function clearRemovedStyles(element: CSObject, oldKeys: Set<string>, newKeys: Se
422
442
  }
423
443
  }
424
444
 
425
- // Parse className string into a Set of escaped class names
445
+ // Parse className string into a Set of escaped class names.
446
+ // Results are cached since the same className strings recur across renders.
447
+ const _parseCache = new Map<string, Set<string>>();
426
448
  function parseClassNames(className: string | undefined): Set<string> {
427
449
  if (!className) return new Set();
428
- return new Set(
450
+ const cached = _parseCache.get(className);
451
+ if (cached !== undefined) return cached;
452
+ const result = new Set(
429
453
  className.split(/\s+/)
430
454
  .filter(Boolean)
431
455
  .map(escapeClassName)
432
456
  );
457
+ _parseCache.set(className, result);
458
+ return result;
433
459
  }
434
460
 
435
461
  // Apply className(s) to element (with escaping for Tailwind/USS compatibility)
@@ -618,70 +644,68 @@ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
618
644
 
619
645
  // MARK: Component-specific prop handlers
620
646
 
621
- // Apply common props (text, value, label)
622
- function applyCommonProps(element: CSObject, props: Record<string, unknown>) {
647
+ // Apply common props (text, value, label) - skip unchanged values
648
+ function applyCommonProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
623
649
  const el = element as any;
624
- if (props.text !== undefined) el.text = props.text as string;
625
- if (props.value !== undefined) el.value = props.value;
626
- if (props.label !== undefined) el.label = props.label as string;
650
+ if (props.text !== undefined && props.text !== oldProps?.text) el.text = props.text as string;
651
+ if (props.value !== undefined && props.value !== oldProps?.value) el.value = props.value;
652
+ if (props.label !== undefined && props.label !== oldProps?.label) el.label = props.label as string;
627
653
  }
628
654
 
629
- // Helper to set enum prop if defined
630
- function setEnumProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string, enumType: CSEnum) {
631
- if (props[propKey] !== undefined) {
655
+ // Helper to set enum prop if defined and changed
656
+ function setEnumProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string, enumType: CSEnum, oldProps?: Record<string, unknown>) {
657
+ if (props[propKey] !== undefined && props[propKey] !== oldProps?.[propKey]) {
632
658
  (target as Record<string, unknown>)[key as string] = enumType[props[propKey] as string];
633
659
  }
634
660
  }
635
661
 
636
- // Helper to set value prop if defined
637
- function setValueProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string) {
638
- if (props[propKey] !== undefined) {
662
+ // Helper to set value prop if defined and changed
663
+ function setValueProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string, oldProps?: Record<string, unknown>) {
664
+ if (props[propKey] !== undefined && props[propKey] !== oldProps?.[propKey]) {
639
665
  (target as Record<string, unknown>)[key as string] = props[propKey];
640
666
  }
641
667
  }
642
668
 
643
- // Apply TextField-specific properties
644
- function applyTextFieldProps(element: CSObject, props: Record<string, unknown>) {
669
+ // Apply TextField-specific properties - skip unchanged values
670
+ function applyTextFieldProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
645
671
  const el = element as any;
646
- if (props.readOnly !== undefined) el.isReadOnly = props.readOnly;
647
- if (props.multiline !== undefined) el.multiline = props.multiline;
648
- if (props.maxLength !== undefined) el.maxLength = props.maxLength;
649
- if (props.isPasswordField !== undefined) el.isPasswordField = props.isPasswordField;
650
- if (props.maskChar !== undefined) el.maskChar = (props.maskChar as string).charAt(0);
651
- if (props.isDelayed !== undefined) el.isDelayed = props.isDelayed;
652
- if (props.selectAllOnFocus !== undefined) el.selectAllOnFocus = props.selectAllOnFocus;
653
- if (props.selectAllOnMouseUp !== undefined) el.selectAllOnMouseUp = props.selectAllOnMouseUp;
654
- if (props.hideMobileInput !== undefined) el.hideMobileInput = props.hideMobileInput;
655
- if (props.autoCorrection !== undefined) el.autoCorrection = props.autoCorrection;
656
- // Note: placeholder is handled differently in Unity - it's set via the textEdition interface
657
- // For now we skip it as it requires more complex handling
672
+ if (props.readOnly !== undefined && props.readOnly !== oldProps?.readOnly) el.isReadOnly = props.readOnly;
673
+ if (props.multiline !== undefined && props.multiline !== oldProps?.multiline) el.multiline = props.multiline;
674
+ if (props.maxLength !== undefined && props.maxLength !== oldProps?.maxLength) el.maxLength = props.maxLength;
675
+ if (props.isPasswordField !== undefined && props.isPasswordField !== oldProps?.isPasswordField) el.isPasswordField = props.isPasswordField;
676
+ if (props.maskChar !== undefined && props.maskChar !== oldProps?.maskChar) el.maskChar = (props.maskChar as string).charAt(0);
677
+ if (props.isDelayed !== undefined && props.isDelayed !== oldProps?.isDelayed) el.isDelayed = props.isDelayed;
678
+ if (props.selectAllOnFocus !== undefined && props.selectAllOnFocus !== oldProps?.selectAllOnFocus) el.selectAllOnFocus = props.selectAllOnFocus;
679
+ if (props.selectAllOnMouseUp !== undefined && props.selectAllOnMouseUp !== oldProps?.selectAllOnMouseUp) el.selectAllOnMouseUp = props.selectAllOnMouseUp;
680
+ if (props.hideMobileInput !== undefined && props.hideMobileInput !== oldProps?.hideMobileInput) el.hideMobileInput = props.hideMobileInput;
681
+ if (props.autoCorrection !== undefined && props.autoCorrection !== oldProps?.autoCorrection) el.autoCorrection = props.autoCorrection;
658
682
  }
659
683
 
660
- // Apply Slider-specific properties
661
- function applySliderProps(element: CSObject, props: Record<string, unknown>) {
684
+ // Apply Slider-specific properties - skip unchanged values
685
+ function applySliderProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
662
686
  const el = element as any;
663
- if (props.lowValue !== undefined) el.lowValue = props.lowValue;
664
- if (props.highValue !== undefined) el.highValue = props.highValue;
665
- if (props.showInputField !== undefined) el.showInputField = props.showInputField;
666
- if (props.inverted !== undefined) el.inverted = props.inverted;
667
- if (props.pageSize !== undefined) el.pageSize = props.pageSize;
668
- if (props.fill !== undefined) el.fill = props.fill;
669
- if (props.direction !== undefined) {
687
+ if (props.lowValue !== undefined && props.lowValue !== oldProps?.lowValue) el.lowValue = props.lowValue;
688
+ if (props.highValue !== undefined && props.highValue !== oldProps?.highValue) el.highValue = props.highValue;
689
+ if (props.showInputField !== undefined && props.showInputField !== oldProps?.showInputField) el.showInputField = props.showInputField;
690
+ if (props.inverted !== undefined && props.inverted !== oldProps?.inverted) el.inverted = props.inverted;
691
+ if (props.pageSize !== undefined && props.pageSize !== oldProps?.pageSize) el.pageSize = props.pageSize;
692
+ if (props.fill !== undefined && props.fill !== oldProps?.fill) el.fill = props.fill;
693
+ if (props.direction !== undefined && props.direction !== oldProps?.direction) {
670
694
  el.direction = CS.UnityEngine.UIElements.SliderDirection[props.direction as string];
671
695
  }
672
696
  }
673
697
 
674
- // Apply Toggle-specific properties
675
- function applyToggleProps(element: CSObject, props: Record<string, unknown>) {
698
+ // Apply Toggle-specific properties - skip unchanged values
699
+ function applyToggleProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
676
700
  const el = element as any;
677
- if (props.text !== undefined) el.text = props.text;
678
- if (props.toggleOnLabelClick !== undefined) el.toggleOnLabelClick = props.toggleOnLabelClick;
701
+ if (props.text !== undefined && props.text !== oldProps?.text) el.text = props.text;
702
+ if (props.toggleOnLabelClick !== undefined && props.toggleOnLabelClick !== oldProps?.toggleOnLabelClick) el.toggleOnLabelClick = props.toggleOnLabelClick;
679
703
  }
680
704
 
681
- // Apply Image-specific properties
682
- function applyImageProps(element: CSObject, props: Record<string, unknown>) {
705
+ // Apply Image-specific properties - skip unchanged values
706
+ function applyImageProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
683
707
  const el = element as any;
684
- if (props.image !== undefined) {
708
+ if (props.image !== undefined && props.image !== oldProps?.image) {
685
709
  const img = props.image;
686
710
  if (img != null && (img as any).GetType?.().Name === "VectorImage") {
687
711
  el.image = null;
@@ -691,73 +715,73 @@ function applyImageProps(element: CSObject, props: Record<string, unknown>) {
691
715
  el.image = img;
692
716
  }
693
717
  }
694
- if (props.sprite !== undefined) el.sprite = props.sprite;
695
- if (props.vectorImage !== undefined) el.vectorImage = props.vectorImage;
696
- if (props.scaleMode !== undefined) {
718
+ if (props.sprite !== undefined && props.sprite !== oldProps?.sprite) el.sprite = props.sprite;
719
+ if (props.vectorImage !== undefined && props.vectorImage !== oldProps?.vectorImage) el.vectorImage = props.vectorImage;
720
+ if (props.scaleMode !== undefined && props.scaleMode !== oldProps?.scaleMode) {
697
721
  el.scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
698
722
  }
699
- if (props.tintColor !== undefined) {
723
+ if (props.tintColor !== undefined && props.tintColor !== oldProps?.tintColor) {
700
724
  const color = parseColor(props.tintColor as string);
701
725
  if (color) el.tintColor = color;
702
726
  }
703
- if (props.sourceRect !== undefined) {
727
+ if (props.sourceRect !== undefined && props.sourceRect !== oldProps?.sourceRect) {
704
728
  const rect = props.sourceRect as { x: number; y: number; width: number; height: number };
705
729
  el.sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
706
730
  }
707
- if (props.uv !== undefined) {
731
+ if (props.uv !== undefined && props.uv !== oldProps?.uv) {
708
732
  const rect = props.uv as { x: number; y: number; width: number; height: number };
709
733
  el.uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
710
734
  }
711
735
  }
712
736
 
713
- // Apply ScrollView-specific properties
714
- function applyScrollViewProps(element: CSScrollView, props: Record<string, unknown>) {
737
+ // Apply ScrollView-specific properties - skip unchanged values
738
+ function applyScrollViewProps(element: CSScrollView, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
715
739
  const UIE = CS.UnityEngine.UIElements;
716
- setEnumProp(element, 'mode', props, 'mode', UIE.ScrollViewMode);
717
- setEnumProp(element, 'horizontalScrollerVisibility', props, 'horizontalScrollerVisibility', UIE.ScrollerVisibility);
718
- setEnumProp(element, 'verticalScrollerVisibility', props, 'verticalScrollerVisibility', UIE.ScrollerVisibility);
719
- setEnumProp(element, 'touchScrollBehavior', props, 'touchScrollBehavior', UIE.TouchScrollBehavior);
720
- setEnumProp(element, 'nestedInteractionKind', props, 'nestedInteractionKind', UIE.NestedInteractionKind);
721
- setValueProp(element, 'elasticity', props, 'elasticity');
722
- setValueProp(element, 'elasticAnimationIntervalMs', props, 'elasticAnimationIntervalMs');
723
- setValueProp(element, 'scrollDecelerationRate', props, 'scrollDecelerationRate');
724
- setValueProp(element, 'mouseWheelScrollSize', props, 'mouseWheelScrollSize');
725
- setValueProp(element, 'horizontalPageSize', props, 'horizontalPageSize');
726
- setValueProp(element, 'verticalPageSize', props, 'verticalPageSize');
740
+ setEnumProp(element, 'mode', props, 'mode', UIE.ScrollViewMode, oldProps);
741
+ setEnumProp(element, 'horizontalScrollerVisibility', props, 'horizontalScrollerVisibility', UIE.ScrollerVisibility, oldProps);
742
+ setEnumProp(element, 'verticalScrollerVisibility', props, 'verticalScrollerVisibility', UIE.ScrollerVisibility, oldProps);
743
+ setEnumProp(element, 'touchScrollBehavior', props, 'touchScrollBehavior', UIE.TouchScrollBehavior, oldProps);
744
+ setEnumProp(element, 'nestedInteractionKind', props, 'nestedInteractionKind', UIE.NestedInteractionKind, oldProps);
745
+ setValueProp(element, 'elasticity', props, 'elasticity', oldProps);
746
+ setValueProp(element, 'elasticAnimationIntervalMs', props, 'elasticAnimationIntervalMs', oldProps);
747
+ setValueProp(element, 'scrollDecelerationRate', props, 'scrollDecelerationRate', oldProps);
748
+ setValueProp(element, 'mouseWheelScrollSize', props, 'mouseWheelScrollSize', oldProps);
749
+ setValueProp(element, 'horizontalPageSize', props, 'horizontalPageSize', oldProps);
750
+ setValueProp(element, 'verticalPageSize', props, 'verticalPageSize', oldProps);
727
751
  }
728
752
 
729
- // Apply ListView-specific properties
730
- function applyListViewProps(element: CSListView, props: Record<string, unknown>) {
753
+ // Apply ListView-specific properties - skip unchanged values
754
+ function applyListViewProps(element: CSListView, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
731
755
  const UIE = CS.UnityEngine.UIElements;
732
756
 
733
757
  // Data binding callbacks
734
- setValueProp(element, 'itemsSource', props, 'itemsSource');
735
- setValueProp(element, 'makeItem', props, 'makeItem');
736
- setValueProp(element, 'bindItem', props, 'bindItem');
737
- setValueProp(element, 'unbindItem', props, 'unbindItem');
738
- setValueProp(element, 'destroyItem', props, 'destroyItem');
758
+ setValueProp(element, 'itemsSource', props, 'itemsSource', oldProps);
759
+ setValueProp(element, 'makeItem', props, 'makeItem', oldProps);
760
+ setValueProp(element, 'bindItem', props, 'bindItem', oldProps);
761
+ setValueProp(element, 'unbindItem', props, 'unbindItem', oldProps);
762
+ setValueProp(element, 'destroyItem', props, 'destroyItem', oldProps);
739
763
 
740
764
  // Virtualization
741
- setValueProp(element, 'fixedItemHeight', props, 'fixedItemHeight');
742
- setEnumProp(element, 'virtualizationMethod', props, 'virtualizationMethod', UIE.CollectionVirtualizationMethod);
765
+ setValueProp(element, 'fixedItemHeight', props, 'fixedItemHeight', oldProps);
766
+ setEnumProp(element, 'virtualizationMethod', props, 'virtualizationMethod', UIE.CollectionVirtualizationMethod, oldProps);
743
767
 
744
768
  // Selection
745
- setEnumProp(element, 'selectionType', props, 'selectionType', UIE.SelectionType);
746
- setValueProp(element, 'selectedIndex', props, 'selectedIndex');
747
- setValueProp(element, 'selectedIndices', props, 'selectedIndices');
769
+ setEnumProp(element, 'selectionType', props, 'selectionType', UIE.SelectionType, oldProps);
770
+ setValueProp(element, 'selectedIndex', props, 'selectedIndex', oldProps);
771
+ setValueProp(element, 'selectedIndices', props, 'selectedIndices', oldProps);
748
772
 
749
773
  // Reordering
750
- setValueProp(element, 'reorderable', props, 'reorderable');
751
- setEnumProp(element, 'reorderMode', props, 'reorderMode', UIE.ListViewReorderMode);
774
+ setValueProp(element, 'reorderable', props, 'reorderable', oldProps);
775
+ setEnumProp(element, 'reorderMode', props, 'reorderMode', UIE.ListViewReorderMode, oldProps);
752
776
 
753
777
  // Header/Footer
754
- setValueProp(element, 'showFoldoutHeader', props, 'showFoldoutHeader');
755
- setValueProp(element, 'headerTitle', props, 'headerTitle');
756
- setValueProp(element, 'showAddRemoveFooter', props, 'showAddRemoveFooter');
778
+ setValueProp(element, 'showFoldoutHeader', props, 'showFoldoutHeader', oldProps);
779
+ setValueProp(element, 'headerTitle', props, 'headerTitle', oldProps);
780
+ setValueProp(element, 'showAddRemoveFooter', props, 'showAddRemoveFooter', oldProps);
757
781
 
758
782
  // Appearance
759
- setValueProp(element, 'showBorder', props, 'showBorder');
760
- setEnumProp(element, 'showAlternatingRowBackgrounds', props, 'showAlternatingRowBackgrounds', UIE.AlternatingRowBackground);
783
+ setValueProp(element, 'showBorder', props, 'showBorder', oldProps);
784
+ setEnumProp(element, 'showAlternatingRowBackgrounds', props, 'showAlternatingRowBackgrounds', UIE.AlternatingRowBackground, oldProps);
761
785
  }
762
786
 
763
787
  // Props handled by the reconciler infrastructure - not forwarded to C# elements
@@ -767,46 +791,47 @@ const RESERVED_PROPS = new Set([
767
791
  ...Object.keys(EVENT_PROPS),
768
792
  ]);
769
793
 
770
- // Forward non-reserved props directly to C# element (for custom elements)
771
- function applyCustomProps(element: CSObject, props: Record<string, unknown>) {
794
+ // Forward non-reserved props directly to C# element (for custom elements) - skip unchanged
795
+ function applyCustomProps(element: CSObject, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
772
796
  for (const [key, value] of Object.entries(props)) {
773
797
  if (value === undefined || RESERVED_PROPS.has(key)) continue;
798
+ if (value === oldProps?.[key]) continue;
774
799
  (element as any)[key] = value;
775
800
  }
776
801
  }
777
802
 
778
803
  // Apply component-specific props based on element type
779
- function applyComponentProps(element: CSObject, type: string, props: Record<string, unknown>) {
804
+ function applyComponentProps(element: CSObject, type: string, props: Record<string, unknown>, oldProps?: Record<string, unknown>) {
780
805
  // Custom elements: forward all non-reserved props directly to C# element
781
806
  if (!BUILT_IN_TYPES.has(type)) {
782
- applyCustomProps(element, props);
807
+ applyCustomProps(element, props, oldProps);
783
808
  return;
784
809
  }
785
810
 
786
811
  // For Slider, apply range props (lowValue/highValue) BEFORE value
787
812
  // Unity's Slider clamps value to [lowValue, highValue], so range must be set first
788
813
  if (type === 'ojs-slider') {
789
- applySliderProps(element, props);
790
- applyCommonProps(element, props);
814
+ applySliderProps(element, props, oldProps);
815
+ applyCommonProps(element, props, oldProps);
791
816
  return;
792
817
  }
793
818
 
794
- applyCommonProps(element, props);
819
+ applyCommonProps(element, props, oldProps);
795
820
 
796
821
  if (type === 'ojs-textfield') {
797
- applyTextFieldProps(element, props);
822
+ applyTextFieldProps(element, props, oldProps);
798
823
  } else if (type === 'ojs-toggle') {
799
- applyToggleProps(element, props);
824
+ applyToggleProps(element, props, oldProps);
800
825
  } else if (type === 'ojs-image') {
801
- applyImageProps(element, props);
826
+ applyImageProps(element, props, oldProps);
802
827
  } else if (type === 'ojs-scrollview') {
803
- applyScrollViewProps(element as CSScrollView, props);
828
+ applyScrollViewProps(element as CSScrollView, props, oldProps);
804
829
  } else if (type === 'ojs-listview') {
805
- applyListViewProps(element as CSListView, props);
830
+ applyListViewProps(element as CSListView, props, oldProps);
806
831
  } else if (type === 'ojs-frostedglass') {
807
832
  const el = element as any;
808
- if (props.blurRadius !== undefined) el.BlurRadius = props.blurRadius;
809
- if (props.tintColor !== undefined) el.TintColor = props.tintColor;
833
+ if (props.blurRadius !== undefined && props.blurRadius !== oldProps?.blurRadius) el.BlurRadius = props.blurRadius;
834
+ if (props.tintColor !== undefined && props.tintColor !== oldProps?.tintColor) el.TintColor = props.tintColor;
810
835
  }
811
836
  }
812
837
 
@@ -844,8 +869,9 @@ function createInstance(type: string, props: BaseProps): Instance {
844
869
  function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseProps) {
845
870
  const element = instance.element;
846
871
 
847
- // Update style - clear removed properties, then apply new ones
848
- if (oldProps.style !== newProps.style) {
872
+ // Update style - skip if values are shallowly equal (inline style objects are
873
+ // new references each render but usually contain the same values)
874
+ if (oldProps.style !== newProps.style && !shallowEqual(oldProps.style as any, newProps.style as any)) {
849
875
  const newStyleKeys = getExpandedStyleKeys(newProps.style);
850
876
  clearRemovedStyles(element, instance.appliedStyleKeys, newStyleKeys);
851
877
  instance.appliedStyleKeys = applyStyle(element, newProps.style);
@@ -862,8 +888,8 @@ function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseP
862
888
  // Update vector drawing callback
863
889
  applyVisualContentCallback(instance, newProps);
864
890
 
865
- // Update component-specific props
866
- applyComponentProps(element, instance.type, newProps as Record<string, unknown>);
891
+ // Update component-specific props - pass oldProps to skip unchanged values
892
+ applyComponentProps(element, instance.type, newProps as Record<string, unknown>, oldProps as Record<string, unknown>);
867
893
 
868
894
  // Update pickingMode
869
895
  if (oldProps.pickingMode !== newProps.pickingMode) {
package/src/index.ts CHANGED
@@ -45,7 +45,8 @@ export type {
45
45
  export { Transform2D, useVectorContent } from './vector';
46
46
 
47
47
  // Sync Hooks & C# Interop Utilities
48
- export { useFrameSync, useFrameSyncWith, useThrottledSync, toArray } from './hooks';
48
+ export { useFrameSync, useFrameSyncWith, useThrottledSync, useEventSync, toArray } from './hooks';
49
+ export type { EventSource } from './hooks';
49
50
 
50
51
  // Types
51
52
  export type {