@linxin666/dsh-pet 0.2.7 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -29
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/assets/whale/pet.json +54 -54
- package/assets/whale-refined/pet.json +96 -1
- package/lib/client.js +84 -14
- package/lib/client.js.map +1 -1
- package/lib/index.js +80 -59
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +23 -17
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +50 -3
- package/lib/types/client/ui-teardown.d.ts +29 -0
- package/lib/types/client/ui-teardown.d.ts.map +1 -0
- package/lib/types/client/ui-teardown.js +41 -0
- package/lib/types/registry.d.ts +5 -1
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +14 -10
- package/lib/types/service.d.ts +8 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +19 -1
- package/package.json +12 -9
- package/src/client/PetSprite.test.tsx +168 -26
- package/src/client/PetSprite.tsx +21 -15
- package/src/client/index.test.tsx +118 -11
- package/src/client/index.ts +51 -3
- package/src/client/ui-teardown.ts +47 -0
- package/src/registry.test.ts +14 -0
- package/src/registry.ts +14 -10
- package/src/service.ts +19 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-pet",
|
|
3
3
|
"description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.9",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
],
|
|
37
37
|
"license": "Apache-2.0",
|
|
38
38
|
"dsh": {
|
|
39
|
+
"engines": {
|
|
40
|
+
"dsh": ">=0.1.1-rc.1"
|
|
41
|
+
},
|
|
39
42
|
"bundle": {
|
|
40
43
|
"patch": "./cordis.patch.yml"
|
|
41
44
|
},
|
|
@@ -59,14 +62,14 @@
|
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
61
64
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.
|
|
63
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.
|
|
64
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.
|
|
66
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.
|
|
67
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.
|
|
68
|
-
"@deepseek-ai/dsh-session": "^0.1.1-rc.
|
|
69
|
-
"@deepseek-ai/dsh-settings": "^0.1.1-rc.
|
|
65
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
70
73
|
"@testing-library/dom": "^10.4.1",
|
|
71
74
|
"@testing-library/react": "^16.3.2",
|
|
72
75
|
"@types/node": "^22.20.0",
|
|
@@ -687,7 +687,7 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
687
687
|
expect(ornament()).toBeNull()
|
|
688
688
|
})
|
|
689
689
|
|
|
690
|
-
it('advances the ornament frames on the
|
|
690
|
+
it('advances the ornament frames on the duration timer and wraps when looping', () => {
|
|
691
691
|
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
692
692
|
matches: false,
|
|
693
693
|
media: '(prefers-reduced-motion: reduce)',
|
|
@@ -698,7 +698,17 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
698
698
|
removeListener: () => {},
|
|
699
699
|
dispatchEvent: () => false,
|
|
700
700
|
})
|
|
701
|
-
|
|
701
|
+
let now = 0
|
|
702
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
703
|
+
// The ornament schedules by frame duration (setTimeout), the sprite
|
|
704
|
+
// still uses rAF; capture both so stepping advances both loops.
|
|
705
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
706
|
+
let timerId = 0
|
|
707
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
708
|
+
timers.push({ at: now + delay, callback })
|
|
709
|
+
return ++timerId
|
|
710
|
+
}) as typeof window.setTimeout)
|
|
711
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
702
712
|
const frames: FrameRequestCallback[] = []
|
|
703
713
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
704
714
|
frames.push(callback)
|
|
@@ -707,18 +717,31 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
707
717
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
708
718
|
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
709
719
|
const el = ornament()!
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
720
|
+
// Run the sprite rAF callbacks immediately (its idle track never moves);
|
|
721
|
+
// run ornament timers as the clock reaches them, repeatedly, because a
|
|
722
|
+
// due timer reschedules the next one at now + duration.
|
|
723
|
+
const step = (ms: number): void => {
|
|
724
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
725
|
+
now += ms
|
|
726
|
+
for (;;) {
|
|
727
|
+
const due = timers.filter(t => t.at <= now)
|
|
728
|
+
if (due.length === 0) break
|
|
729
|
+
for (const t of due) {
|
|
730
|
+
const idx = timers.indexOf(t)
|
|
731
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
732
|
+
t.callback()
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
713
736
|
// frameWidth = round(64 * 18 / 48) = 24 px; thinking binds frames 0..3.
|
|
714
737
|
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
715
738
|
act(() => { step(161) })
|
|
716
739
|
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
717
|
-
act(() => { step(
|
|
740
|
+
act(() => { step(161) })
|
|
718
741
|
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
719
|
-
act(() => { step(
|
|
742
|
+
act(() => { step(161) })
|
|
720
743
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
721
|
-
act(() => { step(
|
|
744
|
+
act(() => { step(161) })
|
|
722
745
|
// The looping segment wraps back to its first frame.
|
|
723
746
|
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
724
747
|
})
|
|
@@ -734,7 +757,15 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
734
757
|
removeListener: () => {},
|
|
735
758
|
dispatchEvent: () => false,
|
|
736
759
|
})
|
|
737
|
-
|
|
760
|
+
let now = 0
|
|
761
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
762
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
763
|
+
let timerId = 0
|
|
764
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
765
|
+
timers.push({ at: now + delay, callback })
|
|
766
|
+
return ++timerId
|
|
767
|
+
}) as typeof window.setTimeout)
|
|
768
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
738
769
|
const frames: FrameRequestCallback[] = []
|
|
739
770
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
740
771
|
frames.push(callback)
|
|
@@ -743,13 +774,25 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
743
774
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
744
775
|
renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration: { ...decoration, loop: false } } })
|
|
745
776
|
const el = ornament()!
|
|
746
|
-
const step = (
|
|
777
|
+
const step = (ms: number): void => {
|
|
778
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
779
|
+
now += ms
|
|
780
|
+
for (;;) {
|
|
781
|
+
const due = timers.filter(t => t.at <= now)
|
|
782
|
+
if (due.length === 0) break
|
|
783
|
+
for (const t of due) {
|
|
784
|
+
const idx = timers.indexOf(t)
|
|
785
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
786
|
+
t.callback()
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
747
790
|
// done binds frames 2..3; the segment starts on frame 2.
|
|
748
791
|
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
749
792
|
act(() => { step(161) })
|
|
750
793
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
751
|
-
// The ornament stopped scheduling (only the sprite
|
|
752
|
-
expect(
|
|
794
|
+
// The ornament stopped scheduling timers (only the sprite rAF remains).
|
|
795
|
+
expect(timers).toHaveLength(0)
|
|
753
796
|
act(() => { step(161) })
|
|
754
797
|
// The last frame holds.
|
|
755
798
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
@@ -767,6 +810,13 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
767
810
|
dispatchEvent: () => false,
|
|
768
811
|
})
|
|
769
812
|
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
813
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
814
|
+
let timerId = 0
|
|
815
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
816
|
+
timers.push({ at: delay, callback })
|
|
817
|
+
return ++timerId
|
|
818
|
+
}) as typeof window.setTimeout)
|
|
819
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
770
820
|
const frames: FrameRequestCallback[] = []
|
|
771
821
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
772
822
|
frames.push(callback)
|
|
@@ -784,15 +834,15 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
784
834
|
})
|
|
785
835
|
const el = ornament()!
|
|
786
836
|
// The ornament settles on its only frame, exactly like the reduced-motion
|
|
787
|
-
// hold — no
|
|
837
|
+
// hold — no timer may start (only the sprite's idle rAF is pending).
|
|
788
838
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
789
|
-
expect(
|
|
839
|
+
expect(timers).toHaveLength(0)
|
|
790
840
|
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
791
841
|
act(() => { step(161) })
|
|
792
842
|
act(() => { step(322) })
|
|
793
843
|
// The frame never moves and the ornament never reschedules itself.
|
|
794
844
|
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
795
|
-
expect(
|
|
845
|
+
expect(timers).toHaveLength(0)
|
|
796
846
|
})
|
|
797
847
|
|
|
798
848
|
it('does not advance the background while a frame is still in play', () => {
|
|
@@ -806,7 +856,15 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
806
856
|
removeListener: () => {},
|
|
807
857
|
dispatchEvent: () => false,
|
|
808
858
|
})
|
|
809
|
-
|
|
859
|
+
let now = 0
|
|
860
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
861
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
862
|
+
let timerId = 0
|
|
863
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
864
|
+
timers.push({ at: now + delay, callback })
|
|
865
|
+
return ++timerId
|
|
866
|
+
}) as typeof window.setTimeout)
|
|
867
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
810
868
|
const frames: FrameRequestCallback[] = []
|
|
811
869
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
812
870
|
frames.push(callback)
|
|
@@ -815,7 +873,19 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
815
873
|
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
816
874
|
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
817
875
|
const el = ornament()!
|
|
818
|
-
const step = (
|
|
876
|
+
const step = (ms: number): void => {
|
|
877
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
878
|
+
now += ms
|
|
879
|
+
for (;;) {
|
|
880
|
+
const due = timers.filter(t => t.at <= now)
|
|
881
|
+
if (due.length === 0) break
|
|
882
|
+
for (const t of due) {
|
|
883
|
+
const idx = timers.indexOf(t)
|
|
884
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
885
|
+
t.callback()
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
819
889
|
// thinking binds frames 0..3 at 160 ms/frame. The effect holds frame 0;
|
|
820
890
|
// a step at 80 ms — inside the first frame — must not move the ornament,
|
|
821
891
|
// and only crossing the 160 ms boundary advances to the next frame.
|
|
@@ -826,6 +896,58 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
826
896
|
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
827
897
|
})
|
|
828
898
|
|
|
899
|
+
it('catches up every due frame after a long idle gap', () => {
|
|
900
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
901
|
+
matches: false,
|
|
902
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
903
|
+
onchange: null,
|
|
904
|
+
addEventListener: () => {},
|
|
905
|
+
removeEventListener: () => {},
|
|
906
|
+
addListener: () => {},
|
|
907
|
+
removeListener: () => {},
|
|
908
|
+
dispatchEvent: () => false,
|
|
909
|
+
})
|
|
910
|
+
let now = 0
|
|
911
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
912
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
913
|
+
let timerId = 0
|
|
914
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
915
|
+
timers.push({ at: now + delay, callback })
|
|
916
|
+
return ++timerId
|
|
917
|
+
}) as typeof window.setTimeout)
|
|
918
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
919
|
+
const frames: FrameRequestCallback[] = []
|
|
920
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
921
|
+
frames.push(callback)
|
|
922
|
+
return frames.length
|
|
923
|
+
})
|
|
924
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
925
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
926
|
+
const el = ornament()!
|
|
927
|
+
const step = (ms: number): void => {
|
|
928
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
929
|
+
now += ms
|
|
930
|
+
for (;;) {
|
|
931
|
+
const due = timers.filter(t => t.at <= now)
|
|
932
|
+
if (due.length === 0) break
|
|
933
|
+
for (const t of due) {
|
|
934
|
+
const idx = timers.indexOf(t)
|
|
935
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
936
|
+
t.callback()
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// A 500 ms gap (jank / background tab) spans three 160 ms frames; the
|
|
941
|
+
// ornament must advance through all of them (0 -> 1 -> 2 -> 3), not
|
|
942
|
+
// drop the surplus time.
|
|
943
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
944
|
+
act(() => { step(500) })
|
|
945
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
946
|
+
act(() => { step(161) })
|
|
947
|
+
// The next frame tick wraps the looping segment back to its first frame.
|
|
948
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
949
|
+
})
|
|
950
|
+
|
|
829
951
|
it('does not restart the frame loop when an equal-content decoration re-renders', () => {
|
|
830
952
|
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
831
953
|
matches: false,
|
|
@@ -837,21 +959,41 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
837
959
|
removeListener: () => {},
|
|
838
960
|
dispatchEvent: () => false,
|
|
839
961
|
})
|
|
840
|
-
|
|
962
|
+
let now = 0
|
|
963
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
964
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
965
|
+
let timerId = 0
|
|
966
|
+
const timerSpy = vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
967
|
+
timers.push({ at: now + delay, callback })
|
|
968
|
+
return ++timerId
|
|
969
|
+
}) as typeof window.setTimeout)
|
|
970
|
+
const clearSpy = vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
841
971
|
const frames: FrameRequestCallback[] = []
|
|
842
|
-
|
|
972
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
843
973
|
frames.push(callback)
|
|
844
974
|
return frames.length
|
|
845
975
|
})
|
|
846
|
-
|
|
976
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
847
977
|
// The definition comes from '/api/pet/pets', fetched once — a state
|
|
848
978
|
// poll never replaces it, so both renders share one definition object.
|
|
849
979
|
const definition = petDefinition()
|
|
850
980
|
const { result } = renderPet({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
851
|
-
const step = (
|
|
981
|
+
const step = (ms: number): void => {
|
|
982
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
983
|
+
now += ms
|
|
984
|
+
for (;;) {
|
|
985
|
+
const due = timers.filter(t => t.at <= now)
|
|
986
|
+
if (due.length === 0) break
|
|
987
|
+
for (const t of due) {
|
|
988
|
+
const idx = timers.indexOf(t)
|
|
989
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
990
|
+
t.callback()
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
852
994
|
act(() => { step(161) })
|
|
853
995
|
expect(ornament()!.style.backgroundPosition).toBe('-24px 0px')
|
|
854
|
-
const schedulesBefore =
|
|
996
|
+
const schedulesBefore = timerSpy.mock.calls.length
|
|
855
997
|
// The 2 s poll delivers a fresh JSON round-trip: identical content, new
|
|
856
998
|
// object identities everywhere. The loop must not cancel/restart.
|
|
857
999
|
const repolled: DecorationView = {
|
|
@@ -861,10 +1003,10 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
861
1003
|
phases: { ...decoration.phases },
|
|
862
1004
|
}
|
|
863
1005
|
result.rerender(<PetSprite {...petProps({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: repolled } })} />)
|
|
864
|
-
act(() => { step(
|
|
1006
|
+
act(() => { step(161) })
|
|
865
1007
|
expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
|
|
866
|
-
expect(
|
|
867
|
-
// One reschedule per
|
|
868
|
-
expect(
|
|
1008
|
+
expect(clearSpy).not.toHaveBeenCalled()
|
|
1009
|
+
// One reschedule per frame tick; no effect restart added new timers.
|
|
1010
|
+
expect(timerSpy.mock.calls.length).toBe(schedulesBefore + 1)
|
|
869
1011
|
})
|
|
870
1012
|
})
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -98,32 +98,38 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
98
98
|
// tick would keep rescheduling a no-op rAF forever. Settle on the one
|
|
99
99
|
// frame instead — same as the reduced-motion static hold.
|
|
100
100
|
if (reduceMotion || segment.from === segment.to) return
|
|
101
|
-
let
|
|
101
|
+
let timer = 0
|
|
102
102
|
let index = segment.from
|
|
103
103
|
let elapsed = 0
|
|
104
104
|
let last = performance.now()
|
|
105
|
-
const tick = (
|
|
106
|
-
const
|
|
107
|
-
|
|
105
|
+
const tick = (): void => {
|
|
106
|
+
const now = performance.now()
|
|
107
|
+
const delta = now - last
|
|
108
|
+
last = now
|
|
108
109
|
elapsed += delta
|
|
109
|
-
const duration = decoration.durations[index] ??
|
|
110
|
+
const duration = decoration.durations[index] ?? 120
|
|
111
|
+
// The segment's frame rate (duration ms, typically 90-160) is far
|
|
112
|
+
// below the rAF cadence, so a 60fps loop would spend ~90% of its
|
|
113
|
+
// ticks doing nothing. Schedule by the remaining time to the next
|
|
114
|
+
// frame instead — the ornament wakes once per frame, not once per
|
|
115
|
+
// screen refresh. A late wake (background tab, jank) carries extra
|
|
116
|
+
// elapsed time, so catch up every due frame like the sprite loop.
|
|
110
117
|
if (elapsed >= duration) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
// would churn style recalculations for no visual change.
|
|
118
|
+
do {
|
|
119
|
+
elapsed -= duration
|
|
120
|
+
if (index < segment.to) index += 1
|
|
121
|
+
else if (decoration.loop) index = segment.from
|
|
122
|
+
} while (elapsed >= duration)
|
|
123
|
+
// Only advance the background when the frame actually changes.
|
|
118
124
|
el.style.backgroundPosition = position(index)
|
|
119
125
|
}
|
|
120
126
|
// A non-looping segment settles on its last frame; stop scheduling
|
|
121
127
|
// instead of repainting the same position every frame.
|
|
122
128
|
if (!decoration.loop && index === segment.to) return
|
|
123
|
-
|
|
129
|
+
timer = window.setTimeout(tick, Math.max(1, duration - elapsed))
|
|
124
130
|
}
|
|
125
|
-
|
|
126
|
-
return () =>
|
|
131
|
+
timer = window.setTimeout(tick, 0)
|
|
132
|
+
return () => window.clearTimeout(timer)
|
|
127
133
|
}, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
|
|
128
134
|
if (!shown) return null
|
|
129
135
|
return (
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* The global pet entry container opts into the L2 semantic attributes
|
|
4
4
|
* (issue #506): the apply body mounts [data-dsh-pet-root] with
|
|
5
|
-
* data-dsh-plugin="pet" so skins can target the pet subtree.
|
|
5
|
+
* data-dsh-plugin="pet" so skins can target the pet subtree. The same
|
|
6
|
+
* tests pin the fiber-lifecycle contract (issue #785): a hot-reloaded or
|
|
7
|
+
* re-injected bundle instance must never leave the previous React root,
|
|
8
|
+
* container, or settings subscription behind, so the page always holds
|
|
9
|
+
* exactly one [data-dsh-pet-root].
|
|
6
10
|
*/
|
|
7
11
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
8
12
|
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
@@ -51,12 +55,24 @@ beforeAll(() => {
|
|
|
51
55
|
document.documentElement.lang = 'zh'
|
|
52
56
|
})
|
|
53
57
|
|
|
58
|
+
/** A client root context with observable fiber disposal. */
|
|
59
|
+
interface FakeClientLifecycle {
|
|
60
|
+
ctx: ClientContext
|
|
61
|
+
dispose(): void
|
|
62
|
+
settingsListenerCount(): number
|
|
63
|
+
emitSettings(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const activeLifecycles: FakeClientLifecycle[] = []
|
|
67
|
+
|
|
54
68
|
afterEach(() => {
|
|
69
|
+
for (const lifecycle of activeLifecycles.splice(0).reverse()) lifecycle.dispose()
|
|
55
70
|
document.body.replaceChildren()
|
|
56
71
|
})
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
function fakeContext(): FakeClientLifecycle {
|
|
74
|
+
const disposers: (() => void)[] = []
|
|
75
|
+
const settingsListeners = new Set<() => void>()
|
|
60
76
|
const scope = {
|
|
61
77
|
getSnapshot: () => ({
|
|
62
78
|
status: 'ready',
|
|
@@ -67,29 +83,120 @@ function fakeContext(): ClientContext {
|
|
|
67
83
|
revision: 1,
|
|
68
84
|
mode: 'host',
|
|
69
85
|
}),
|
|
70
|
-
subscribe: () =>
|
|
86
|
+
subscribe: (listener: () => void) => {
|
|
87
|
+
settingsListeners.add(listener)
|
|
88
|
+
return () => { settingsListeners.delete(listener) }
|
|
89
|
+
},
|
|
71
90
|
}
|
|
72
|
-
|
|
73
|
-
effect: (fn: () => unknown) => {
|
|
91
|
+
const ctx = {
|
|
92
|
+
effect: (fn: () => unknown, _label?: string) => {
|
|
74
93
|
const dispose = fn()
|
|
75
|
-
|
|
94
|
+
if (typeof dispose !== 'function') return () => {}
|
|
95
|
+
const cleanup = dispose as () => void
|
|
96
|
+
disposers.push(cleanup)
|
|
97
|
+
return cleanup
|
|
76
98
|
},
|
|
77
99
|
locale: { register: () => () => {} },
|
|
78
100
|
get: () => undefined,
|
|
79
101
|
settingsScope: { bind: () => scope },
|
|
80
102
|
slots: {
|
|
81
|
-
|
|
103
|
+
// Cordis runs the factory when the slot mounts and its returned
|
|
104
|
+
// disposer when the fiber disposes; mirror that so slot content
|
|
105
|
+
// (settings card subscription) is cleaned with the fiber.
|
|
106
|
+
inject: (_name: string, callback: () => () => void) => {
|
|
107
|
+
const dispose = callback()
|
|
108
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
109
|
+
return dispose
|
|
110
|
+
},
|
|
82
111
|
register: () => () => {},
|
|
83
112
|
},
|
|
84
113
|
sessions: undefined,
|
|
85
114
|
} as unknown as ClientContext
|
|
115
|
+
let disposed = false
|
|
116
|
+
const lifecycle: FakeClientLifecycle = {
|
|
117
|
+
ctx,
|
|
118
|
+
dispose: () => {
|
|
119
|
+
if (disposed) return
|
|
120
|
+
disposed = true
|
|
121
|
+
for (const dispose of disposers.splice(0).reverse()) dispose()
|
|
122
|
+
},
|
|
123
|
+
settingsListenerCount: () => settingsListeners.size,
|
|
124
|
+
emitSettings: () => {
|
|
125
|
+
for (const listener of settingsListeners) listener()
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
activeLifecycles.push(lifecycle)
|
|
129
|
+
return lifecycle
|
|
86
130
|
}
|
|
87
131
|
|
|
88
|
-
describe('pet client apply
|
|
89
|
-
it('mounts the pet root container with data-dsh-plugin
|
|
90
|
-
apply(fakeContext())
|
|
132
|
+
describe('pet client apply', () => {
|
|
133
|
+
it('mounts the pet root container with the L2 data-dsh-plugin attribute (#506)', () => {
|
|
134
|
+
apply(fakeContext().ctx)
|
|
91
135
|
const root = document.body.querySelector('[data-dsh-pet-root]')
|
|
92
136
|
expect(root).not.toBeNull()
|
|
93
137
|
expect(root!.getAttribute('data-dsh-plugin')).toBe('pet')
|
|
94
138
|
})
|
|
139
|
+
|
|
140
|
+
it('keeps one global pet root when two client factories overlap (#785)', () => {
|
|
141
|
+
const first = fakeContext()
|
|
142
|
+
apply(first.ctx)
|
|
143
|
+
const firstContainer = document.body.querySelector('[data-dsh-pet-root]')
|
|
144
|
+
expect(firstContainer).not.toBeNull()
|
|
145
|
+
|
|
146
|
+
// A rebuilt bundle re-applies while the first fiber is still draining.
|
|
147
|
+
const second = fakeContext()
|
|
148
|
+
apply(second.ctx)
|
|
149
|
+
|
|
150
|
+
const roots = document.body.querySelectorAll('[data-dsh-pet-root]')
|
|
151
|
+
expect(roots).toHaveLength(1)
|
|
152
|
+
expect(roots[0]).not.toBe(firstContainer)
|
|
153
|
+
expect(firstContainer!.isConnected).toBe(false)
|
|
154
|
+
|
|
155
|
+
// The first instance must not resurrect its own root.
|
|
156
|
+
first.emitSettings()
|
|
157
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
158
|
+
|
|
159
|
+
// The first fiber draining later stays a no-op.
|
|
160
|
+
first.dispose()
|
|
161
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('tears down root, container, and settings subscription on fiber disposal (#785)', () => {
|
|
165
|
+
const lifecycle = fakeContext()
|
|
166
|
+
apply(lifecycle.ctx)
|
|
167
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
168
|
+
// The settings card controller subscribes to the same scope as the UI
|
|
169
|
+
// sync, so at least the sync listener is present while the fiber lives.
|
|
170
|
+
expect(lifecycle.settingsListenerCount()).toBeGreaterThan(0)
|
|
171
|
+
|
|
172
|
+
lifecycle.dispose()
|
|
173
|
+
|
|
174
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
|
|
175
|
+
expect(lifecycle.settingsListenerCount()).toBe(0)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('re-applies cleanly after disposal so a hot reload keeps one pet (#785)', () => {
|
|
179
|
+
const first = fakeContext()
|
|
180
|
+
apply(first.ctx)
|
|
181
|
+
first.dispose()
|
|
182
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(0)
|
|
183
|
+
|
|
184
|
+
const second = fakeContext()
|
|
185
|
+
apply(second.ctx)
|
|
186
|
+
expect(document.body.querySelectorAll('[data-dsh-pet-root]')).toHaveLength(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('sweeps stale containers left behind by instances without a teardown slot (#785)', () => {
|
|
190
|
+
// A container from a bundle build that predates the teardown registry:
|
|
191
|
+
// nothing registered a teardown, so only the mount-path sweep can clear it.
|
|
192
|
+
const stale = document.createElement('div')
|
|
193
|
+
stale.dataset.dshPetRoot = ''
|
|
194
|
+
document.body.appendChild(stale)
|
|
195
|
+
|
|
196
|
+
apply(fakeContext().ctx)
|
|
197
|
+
|
|
198
|
+
const roots = document.body.querySelectorAll('[data-dsh-pet-root]')
|
|
199
|
+
expect(roots).toHaveLength(1)
|
|
200
|
+
expect(stale.isConnected).toBe(false)
|
|
201
|
+
})
|
|
95
202
|
})
|
package/src/client/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
|
28
28
|
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
29
29
|
import { defaultPetRendererRegistry } from './renderers/registry.ts'
|
|
30
30
|
import { live2dRenderer } from './renderers/live2d.ts'
|
|
31
|
+
import { registerPetUiTeardown, takeoverPetUiTeardown } from './ui-teardown.ts'
|
|
31
32
|
import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
32
33
|
import { NS, en, zh, t } from './locales.ts'
|
|
33
34
|
|
|
@@ -139,9 +140,22 @@ export function apply(ctx: ClientContext): void {
|
|
|
139
140
|
|
|
140
141
|
// The global pet entry, its store, and the poll loop live while the plugin
|
|
141
142
|
// is enabled; toggling the setting off hides the pet and stops polling.
|
|
143
|
+
// 'uiDead' marks a terminal teardown (takeover by a later bundle instance
|
|
144
|
+
// or fiber disposal): a taken-over or disposed instance must never remount
|
|
145
|
+
// from a late settings callback (issue #785).
|
|
142
146
|
let disposeUi: (() => void) | undefined
|
|
147
|
+
let clearUiTeardown: (() => void) | undefined
|
|
148
|
+
let uiDead = false
|
|
149
|
+
const killUi = (): void => {
|
|
150
|
+
if (uiDead) return
|
|
151
|
+
uiDead = true
|
|
152
|
+
clearUiTeardown?.()
|
|
153
|
+
clearUiTeardown = undefined
|
|
154
|
+
disposeUi?.()
|
|
155
|
+
disposeUi = undefined
|
|
156
|
+
}
|
|
143
157
|
const syncUi = (): void => {
|
|
144
|
-
if (enabled() && disposeUi === undefined) {
|
|
158
|
+
if (!uiDead && enabled() && disposeUi === undefined) {
|
|
145
159
|
// ONE store instance for the whole app, owned by this apply body. The
|
|
146
160
|
// pet is host-global (state/display/interactions are /api/pet/*
|
|
147
161
|
// endpoints with no session dimension), so the slot system's per-session
|
|
@@ -296,6 +310,17 @@ export function apply(ctx: ClientContext): void {
|
|
|
296
310
|
// The entry therefore mounts straight onto document.body via a single
|
|
297
311
|
// React root for the page lifetime: PetSprite portals itself to body
|
|
298
312
|
// when visible, and the hidden-state summon button is fixed-positioned.
|
|
313
|
+
//
|
|
314
|
+
// Cross-instance single-mount guard (issue #785): take over the
|
|
315
|
+
// page-global slot first — the previous bundle instance's fiber may
|
|
316
|
+
// still be draining during a client reload, so unmount its React root
|
|
317
|
+
// and remove its container — then sweep containers left behind by
|
|
318
|
+
// instances that predate the teardown registry, so this mount is the
|
|
319
|
+
// page's only [data-dsh-pet-root].
|
|
320
|
+
takeoverPetUiTeardown()
|
|
321
|
+
for (const stale of Array.from(document.querySelectorAll('div[data-dsh-pet-root]'))) {
|
|
322
|
+
stale.remove()
|
|
323
|
+
}
|
|
299
324
|
const container = document.createElement('div')
|
|
300
325
|
container.dataset.dshPetRoot = ''
|
|
301
326
|
container.dataset.dshPlugin = 'pet'
|
|
@@ -303,17 +328,40 @@ export function apply(ctx: ClientContext): void {
|
|
|
303
328
|
const petRoot = createRoot(container)
|
|
304
329
|
petRoot.render(createElement(PetDockEntry, { ...injected(), t }))
|
|
305
330
|
|
|
331
|
+
let uiGone = false
|
|
306
332
|
disposeUi = () => {
|
|
333
|
+
if (uiGone) return
|
|
334
|
+
uiGone = true
|
|
335
|
+
clearUiTeardown?.()
|
|
336
|
+
clearUiTeardown = undefined
|
|
307
337
|
petRoot.unmount()
|
|
308
338
|
container.remove()
|
|
309
339
|
disposePoll()
|
|
310
340
|
disposeUi = undefined
|
|
311
341
|
}
|
|
312
|
-
|
|
342
|
+
// The slot teardown is the takeover hook a later apply body runs; it
|
|
343
|
+
// marks this instance terminal so a late settings callback from the
|
|
344
|
+
// still-draining instance cannot remount a second pet.
|
|
345
|
+
clearUiTeardown = registerPetUiTeardown(() => {
|
|
346
|
+
uiDead = true
|
|
347
|
+
disposeUi?.()
|
|
348
|
+
})
|
|
349
|
+
} else if (!uiDead && !enabled() && disposeUi !== undefined) {
|
|
313
350
|
disposeUi()
|
|
314
351
|
disposeUi = undefined
|
|
315
352
|
}
|
|
316
353
|
}
|
|
317
|
-
|
|
354
|
+
// The settings subscription and the pet UI lifetime follow the fiber
|
|
355
|
+
// (issue #785): disposal drops the subscription and tears the UI down
|
|
356
|
+
// (terminal), so a hot-reloaded or re-injected bundle never leaves the
|
|
357
|
+
// previous React root, container, or poll loop behind on document.body.
|
|
358
|
+
const unsubscribeSettings = settingsScope.subscribe(syncUi)
|
|
359
|
+
ctx.effect(
|
|
360
|
+
() => () => {
|
|
361
|
+
unsubscribeSettings()
|
|
362
|
+
killUi()
|
|
363
|
+
},
|
|
364
|
+
'pet: client lifecycle',
|
|
365
|
+
)
|
|
318
366
|
syncUi()
|
|
319
367
|
}
|