notifier.ts 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
  2. /**
  3. * Batches structural updates in microtasks and stream updates by animation
  4. * frame. Reads may rebuild a dirty snapshot without consuming the pending
  5. * subscriber notification.
  6. */
  7. export class Notifier {
  8. private listeners = new Set<() => void>()
  9. private dirty = false
  10. private notifyPending = false
  11. private scheduled: 'none' | 'microtask' | 'frame' = 'none'
  12. private scheduleGeneration = 0
  13. /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
  14. constructor(private readonly rebuild: () => void) {}
  15. /**
  16. * uSES subscription entry.
  17. * @param listener - change callback.
  18. * @returns the unsubscribe function.
  19. */
  20. subscribe(listener: () => void): () => void {
  21. this.listeners.add(listener)
  22. return () => {
  23. this.listeners.delete(listener)
  24. }
  25. }
  26. /** Mark the snapshot dirty and notify in a microtask. */
  27. markDirty(): void {
  28. this.dirty = true
  29. this.notifyPending = true
  30. if (this.scheduled === 'microtask') return
  31. this.schedule('microtask')
  32. }
  33. /** Mark the snapshot dirty and publish cumulative state at most once per frame. */
  34. markFrameDirty(): void {
  35. this.dirty = true
  36. this.notifyPending = true
  37. if (this.scheduled !== 'none') return
  38. this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
  39. }
  40. /**
  41. * Synchronous flush: controlled-input writes must notify in the same tick as
  42. * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
  43. */
  44. notifyNow(): void {
  45. this.dirty = true
  46. this.notifyPending = true
  47. this.invalidateSchedule()
  48. this.flush()
  49. }
  50. /**
  51. * Pre-getSnapshot check: rebuild synchronously when dirty (read path
  52. * before first subscribe / while unobserved). Notification stays pending.
  53. */
  54. ensureFresh(): void {
  55. if (!this.dirty) return
  56. this.dirty = false
  57. this.rebuild()
  58. }
  59. private schedule(kind: 'microtask' | 'frame'): void {
  60. const generation = ++this.scheduleGeneration
  61. this.scheduled = kind
  62. const publish = () => {
  63. if (generation !== this.scheduleGeneration) return
  64. this.scheduled = 'none'
  65. this.flush()
  66. }
  67. if (kind === 'frame') {
  68. globalThis.requestAnimationFrame(publish)
  69. } else {
  70. queueMicrotask(publish)
  71. }
  72. }
  73. private invalidateSchedule(): void {
  74. this.scheduleGeneration++
  75. this.scheduled = 'none'
  76. }
  77. private flush(): void {
  78. if (!this.notifyPending) return
  79. if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
  80. this.notifyPending = false
  81. if (this.dirty) {
  82. this.dirty = false
  83. this.rebuild()
  84. }
  85. notifySubscribers(this.listeners, '[session-controller]')
  86. }
  87. }