@linxin666/dsh-pet 0.2.7 → 0.2.8

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.
@@ -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 rAF loop and wraps when looping', () => {
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
- vi.spyOn(performance, 'now').mockReturnValue(0)
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
- // Both the sprite loop and the ornament loop schedule frames; step every
711
- // pending callback together (the sprite's idle track never moves).
712
- const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
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(322) })
740
+ act(() => { step(161) })
718
741
  expect(el.style.backgroundPosition).toBe('-48px 0px')
719
- act(() => { step(483) })
742
+ act(() => { step(161) })
720
743
  expect(el.style.backgroundPosition).toBe('-72px 0px')
721
- act(() => { step(644) })
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
- vi.spyOn(performance, 'now').mockReturnValue(0)
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 = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
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 loop remains pending).
752
- expect(frames).toHaveLength(1)
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 rAF loop may start, so only the sprite's idle loop is pending.
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(frames).toHaveLength(1)
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(frames).toHaveLength(1)
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
- vi.spyOn(performance, 'now').mockReturnValue(0)
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 = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
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
- vi.spyOn(performance, 'now').mockReturnValue(0)
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
- const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
972
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
843
973
  frames.push(callback)
844
974
  return frames.length
845
975
  })
846
- const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
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 = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
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 = rafSpy.mock.calls.length
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(322) })
1006
+ act(() => { step(161) })
865
1007
  expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
866
- expect(cancelSpy).not.toHaveBeenCalled()
867
- // One reschedule per loop tick; no effect restart added new schedules.
868
- expect(rafSpy.mock.calls.length).toBe(schedulesBefore + 2)
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
  })
@@ -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 raf = 0
101
+ let timer = 0
102
102
  let index = segment.from
103
103
  let elapsed = 0
104
104
  let last = performance.now()
105
- const tick = (ts: number): void => {
106
- const delta = ts - last
107
- last = ts
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] ?? 160
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
- elapsed = 0
112
- if (index < segment.to) index += 1
113
- else if (decoration.loop) index = segment.from
114
- // Only advance the background when the frame actually changes:
115
- // the segment's frame rate (duration ms, typically 90-160) is far
116
- // below the rAF cadence, so writing the same position every frame
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
- raf = requestAnimationFrame(tick)
129
+ timer = window.setTimeout(tick, Math.max(1, duration - elapsed))
124
130
  }
125
- raf = requestAnimationFrame(tick)
126
- return () => cancelAnimationFrame(raf)
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
- /** A minimal client root context: ready settings scope, no-op slot system. */
59
- function fakeContext(): ClientContext {
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
- return {
73
- effect: (fn: () => unknown) => {
91
+ const ctx = {
92
+ effect: (fn: () => unknown, _label?: string) => {
74
93
  const dispose = fn()
75
- return typeof dispose === 'function' ? dispose : () => {}
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
- inject: (_name: string, callback: () => () => void) => callback(),
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 L2 semantic attributes (#506)', () => {
89
- it('mounts the pet root container with data-dsh-plugin="pet"', () => {
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
  })
@@ -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
- } else if (!enabled() && disposeUi !== undefined) {
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
- settingsScope.subscribe(syncUi)
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
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Cross-bundle-instance teardown slot for the page-global pet root
3
+ * (issue #785).
4
+ *
5
+ * A client bundle swap (HMR rebuilt frame, plugin update, duplicate
6
+ * injection) runs a new apply body while the previous instance's fiber
7
+ * may still be draining. Module state does not survive the swap, so a
8
+ * closure guard only sees one apply body and the previous instance's
9
+ * container keeps sitting on document.body: the page shows two pets
10
+ * until a full refresh. The slot rides globalThis (which does survive)
11
+ * so a re-apply can find the previous instance and unmount its React
12
+ * root cleanly before mounting its own; the previous fiber's later
13
+ * disposal stays a no-op through the idempotent teardowns.
14
+ */
15
+
16
+ const SLOT = Symbol.for('dsh-pet.client-ui-teardown')
17
+
18
+ interface TeardownSlot {
19
+ [SLOT]?: (() => void) | undefined
20
+ }
21
+
22
+ /**
23
+ * Claim the page-global pet UI slot with the current instance's teardown
24
+ * (React root unmount + container removal + poll stop).
25
+ * @param teardown - what a later instance runs to take the slot over.
26
+ * @returns a disposer that clears the slot when the current instance's
27
+ * UI is torn down (settings toggle, takeover, or fiber disposal).
28
+ */
29
+ export function registerPetUiTeardown(teardown: () => void): () => void {
30
+ const slot = globalThis as TeardownSlot
31
+ slot[SLOT] = teardown
32
+ return () => {
33
+ if (slot[SLOT] === teardown) slot[SLOT] = undefined
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Run the previous instance's teardown if one is registered, so the
39
+ * re-applying instance becomes the sole owner of the page-global pet
40
+ * root. No-op when the previous fiber already tore down cleanly.
41
+ */
42
+ export function takeoverPetUiTeardown(): void {
43
+ const slot = globalThis as TeardownSlot
44
+ const teardown = slot[SLOT]
45
+ slot[SLOT] = undefined
46
+ teardown?.()
47
+ }