@invisibleloop/pulse 0.21.3 → 0.21.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.21.3",
3
+ "version": "0.21.4",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.21.3
1
+ 0.21.4
@@ -169,7 +169,9 @@ export function mount(spec, el, serverState = {}, options = {}) {
169
169
  // ---------------------------------------------------------------------------
170
170
 
171
171
  function persist() {
172
- if (!persistKey) return
172
+ // The key is per route, not per instance: once destroyed, a late write (an action resolving
173
+ // after navigation) could overwrite what the next page, on the same route, has persisted.
174
+ if (!persistKey || _destroyed) return
173
175
  try {
174
176
  const snapshot = {}
175
177
  spec.persist.forEach(k => { snapshot[k] = state[k] })
@@ -184,6 +186,11 @@ export function mount(spec, el, serverState = {}, options = {}) {
184
186
  let lastHtml = null
185
187
 
186
188
  function render() {
189
+ // A destroyed instance never touches the DOM again. el now belongs to the page that replaced
190
+ // it, and an action that resolves late or a debounce timer that fires late would otherwise
191
+ // morph this page's view into that one (#172). State updates, toasts and store pushes from
192
+ // late work still go through; only the render (and persist(), below) is dropped.
193
+ if (_destroyed) return
187
194
  let html
188
195
  try {
189
196
  html = resolveView(spec, state, getEffectiveServerState())
@@ -303,8 +310,9 @@ export function mount(spec, el, serverState = {}, options = {}) {
303
310
  /** Force a re-render — useful after external state changes */
304
311
  refresh: render,
305
312
 
306
- /** Tear down — remove event listeners, unsubscribe from store, clear element */
307
- destroy: () => { _destroyed = true; _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
313
+ /** Tear down — remove event listeners, unsubscribe from store, clear element
314
+ * (pass { keepDom: true } to leave the element's content in place) */
315
+ destroy: (opts) => { _destroyed = true; _unsubStore?.(); _eventAbort.abort(); if (!opts?.keepDom) el.innerHTML = trustedHTML('') }
308
316
  }
309
317
  }
310
318
 
@@ -26,6 +26,17 @@ export function initNavigation(root, mountFn) {
26
26
  let currentMount = null
27
27
  let currentNavController = null
28
28
 
29
+ // The target page has no client bundle (a purely server-rendered page), so nothing will mount
30
+ // over the previous page's instance. Release it, or its listeners keep firing on the new DOM
31
+ // and a store change re-renders the old view into it (#171). Call it immediately before the
32
+ // new HTML replaces the old (never earlier: if the response is cut off first, the visitor
33
+ // keeps a working page). keepDom: the caller swaps the root's content itself.
34
+ function releaseCurrentMount() {
35
+ const mount = currentMount
36
+ currentMount = null // cleared first, so a throwing destroy() cannot leave a stale reference
37
+ mount?.destroy({ keepDom: true })
38
+ }
39
+
29
40
  async function navigate(path, push) {
30
41
  // Cancel any in-flight navigation before starting a new one
31
42
  currentNavController?.abort()
@@ -63,6 +74,10 @@ export function initNavigation(root, mountFn) {
63
74
  await applyScripts(msg.scripts)
64
75
 
65
76
  } else if (msg.type === 'html') {
77
+ if (!hydratePath) {
78
+ if (controller.signal.aborted) return // superseded: leave the old page alone
79
+ releaseCurrentMount()
80
+ }
66
81
  root.innerHTML = trustedHTML(msg.html)
67
82
  // Index <pulse-deferred> placeholders so deferred chunks land in the right spot
68
83
  for (const id of (msg.deferred || [])) {
@@ -126,6 +141,7 @@ export function initNavigation(root, mountFn) {
126
141
  window.__updatePulseStore__(storeState)
127
142
  }
128
143
 
144
+ if (!hydrate) releaseCurrentMount()
129
145
  root.innerHTML = trustedHTML(html)
130
146
  document.title = title || document.title
131
147
  applyStyles(styles)
@@ -27,8 +27,9 @@ export interface MountOptions {
27
27
  }
28
28
 
29
29
  export interface MountResult {
30
- /** Destroy this mount, remove event listeners, and unsubscribe from the store. */
31
- destroy(): void
30
+ /** Remove listeners and unsubscribe from the store, then clear the element.
31
+ * Pass `{ keepDom: true }` to leave the element's content in place. */
32
+ destroy(options?: { keepDom?: boolean }): void
32
33
  }
33
34
 
34
35
  /**