@linxin666/dsh-pet 0.2.6 → 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.
Files changed (66) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/assets/whale/pet.json +54 -54
  5. package/assets/whale-refined/pet.json +96 -1
  6. package/lib/client.js +84 -14
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +62 -58
  9. package/lib/types/access.js +1 -1
  10. package/lib/types/affinity.d.ts +18 -18
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +1 -1
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/client/PetDockEntry.js +2 -2
  15. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  16. package/lib/types/client/PetSettingsCard.js +2 -2
  17. package/lib/types/client/PetSprite.d.ts.map +1 -1
  18. package/lib/types/client/PetSprite.js +27 -21
  19. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  20. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +56 -9
  23. package/lib/types/client/locales.d.ts +100 -100
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/renderers/PetRendererSwitch.js +2 -2
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +2 -2
  27. package/lib/types/client/renderers/live2d.js +2 -2
  28. package/lib/types/client/renderers/registry.d.ts.map +1 -1
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/spritesheet.js +1 -1
  31. package/lib/types/client/ui-teardown.d.ts +29 -0
  32. package/lib/types/client/ui-teardown.d.ts.map +1 -0
  33. package/lib/types/client/ui-teardown.js +41 -0
  34. package/lib/types/contracts/status-decoration.d.ts +1 -1
  35. package/lib/types/decoration.d.ts +1 -1
  36. package/lib/types/decoration.d.ts.map +1 -1
  37. package/lib/types/decoration.js +1 -1
  38. package/lib/types/event-projection.js +1 -1
  39. package/lib/types/index.js +13 -13
  40. package/lib/types/invariant.js +2 -2
  41. package/lib/types/ledger.d.ts.map +1 -1
  42. package/lib/types/ledger.js +3 -3
  43. package/lib/types/manifest-v2.d.ts +1 -1
  44. package/lib/types/manifest-v2.d.ts.map +1 -1
  45. package/lib/types/model3.js +1 -1
  46. package/lib/types/persist.js +3 -3
  47. package/lib/types/registry.d.ts +5 -1
  48. package/lib/types/registry.d.ts.map +1 -1
  49. package/lib/types/registry.js +22 -18
  50. package/lib/types/remarks.d.ts.map +1 -1
  51. package/lib/types/routes.d.ts.map +1 -1
  52. package/lib/types/routes.js +4 -4
  53. package/lib/types/service.d.ts.map +1 -1
  54. package/lib/types/service.js +7 -7
  55. package/lib/types/state.d.ts.map +1 -1
  56. package/lib/types/voice-pack.d.ts +3 -3
  57. package/lib/types/voice-pack.d.ts.map +1 -1
  58. package/lib/types/voice-pack.js +1 -1
  59. package/package.json +12 -9
  60. package/src/client/PetSprite.test.tsx +168 -26
  61. package/src/client/PetSprite.tsx +21 -15
  62. package/src/client/index.test.tsx +118 -11
  63. package/src/client/index.ts +51 -3
  64. package/src/client/ui-teardown.ts +47 -0
  65. package/src/registry.test.ts +14 -0
  66. package/src/registry.ts +14 -10
@@ -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
+ }
@@ -5,6 +5,8 @@ import { join } from 'node:path'
5
5
  import {
6
6
  DEFAULT_FRAME_COUNTS,
7
7
  DEFAULT_PET_CELL,
8
+ DEFAULT_TRACK_PATTERNS,
9
+ PET_ROW_ORDER,
8
10
  PET_SCAN_JSON_CAP,
9
11
  codexPetsDir,
10
12
  loadPetRegistry,
@@ -168,6 +170,18 @@ describe('loadPetRegistry', () => {
168
170
  readFileSync(petAtlasFile(registry.byId('whale-girl-refined')!)),
169
171
  )).toBe(false)
170
172
  expect(registry.defaultEntry().id).toBe('whale-girl')
173
+ // Both built-in whales and every override-free pet share the one slow
174
+ // global rhythm (user request: all pets were too fast at the legacy
175
+ // hatch-pet contract pace).
176
+ for (const track of PET_ROW_ORDER) {
177
+ expect(registry.byId('whale-girl')!.tracks[track].durations)
178
+ .toEqual(DEFAULT_TRACK_PATTERNS[track].durations)
179
+ expect(registry.byId('whale-girl-refined')!.tracks[track].durations)
180
+ .toEqual(DEFAULT_TRACK_PATTERNS[track].durations)
181
+ }
182
+ expect(DEFAULT_TRACK_PATTERNS.idle.durations[0]!).toBeGreaterThanOrEqual(500)
183
+ expect(DEFAULT_TRACK_PATTERNS['running-right'].durations[0]!).toBeGreaterThanOrEqual(300)
184
+ expect(DEFAULT_TRACK_PATTERNS.waving.durations[0]!).toBeGreaterThanOrEqual(450)
171
185
  })
172
186
 
173
187
  it('scans built-in assets, the custom pets dir, and composed extras with precedence', () => {
package/src/registry.ts CHANGED
@@ -114,21 +114,25 @@ export interface PetTrackDef {
114
114
  fallback?: PetAnimation
115
115
  }
116
116
 
117
- /** Default per-track rhythm (hatch-pet contract table). */
117
+ /**
118
+ * Default per-track rhythm — the shared slow baseline every sprite2d pet
119
+ * plays unless its manifest overrides a track (user request: all pets were
120
+ * too fast at the legacy hatch-pet contract pace).
121
+ */
118
122
  export const DEFAULT_TRACK_PATTERNS: Record<PetAnimation, {
119
123
  durations: number[]
120
124
  loop: boolean
121
125
  fallback?: PetAnimation
122
126
  }> = {
123
- idle: { durations: [280, 110, 110, 140, 140, 320], loop: true },
124
- 'running-right': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
125
- 'running-left': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
126
- waving: { durations: [140, 140, 140, 280], loop: true },
127
- jumping: { durations: [140, 140, 140, 140, 280], loop: false, fallback: 'idle' },
128
- failed: { durations: [140, 140, 140, 140, 140, 140, 140, 240], loop: false, fallback: 'idle' },
129
- waiting: { durations: [150, 150, 150, 150, 150, 260], loop: true },
130
- running: { durations: [120, 120, 120, 120, 120, 220], loop: true },
131
- review: { durations: [150, 150, 150, 150, 150, 280], loop: true },
127
+ idle: { durations: [500, 500, 600, 500, 500, 600], loop: true },
128
+ 'running-right': { durations: [300, 300, 300, 300, 300, 300, 300, 400], loop: true },
129
+ 'running-left': { durations: [300, 300, 300, 300, 300, 300, 300, 400], loop: true },
130
+ waving: { durations: [450, 450, 450, 450], loop: true },
131
+ jumping: { durations: [400, 400, 400, 450, 450], loop: false, fallback: 'idle' },
132
+ failed: { durations: [550, 550, 550, 600, 650, 700, 550, 550], loop: false, fallback: 'idle' },
133
+ waiting: { durations: [550, 550, 600, 550, 550, 600], loop: true },
134
+ running: { durations: [330, 330, 330, 330, 330, 400], loop: true },
135
+ review: { durations: [650, 650, 650, 650, 650, 650], loop: true },
132
136
  }
133
137
 
134
138
  /** Manifest shape a pet directory (or 'PetConfig.pets' entry) declares. */