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 +1 -1
- package/src/__tests__/hooks.test.tsx +433 -1
- package/src/__tests__/mocks.ts +1 -0
- package/src/hooks.ts +110 -0
- package/src/host-config.ts +126 -100
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -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
|
+
})
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/host-config.ts
CHANGED
|
@@ -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
|
-
|
|
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 -
|
|
848
|
-
|
|
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 {
|