@invisibleloop/pulse 0.21.2 → 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 +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/bootstrap.js +25 -0
- package/scripts/build.js +2 -14
- package/src/runtime/index.js +11 -3
- package/src/runtime/navigate.js +16 -0
- package/src/runtime/ssr.js +3 -2
- package/types/navigate.d.ts +9 -4
- package/types/runtime.d.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@invisibleloop/pulse",
|
|
3
|
-
"version": "0.21.
|
|
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",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"scripts": {
|
|
77
77
|
"dev": "node --watch src/cli/dev.js",
|
|
78
78
|
"build": "node scripts/build.js",
|
|
79
|
-
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/cli/dev-host.test.js && node src/cli/dev-reload.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node scripts/release-version.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
|
|
79
|
+
"test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/cli/dev-host.test.js && node src/cli/dev-reload.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node scripts/bootstrap.test.js && node scripts/release-version.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
|
|
80
80
|
"typecheck": "tsc --noEmit",
|
|
81
81
|
"docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
|
|
82
82
|
"docs:start": "node ../../docs/server.js"
|
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.21.
|
|
1
|
+
0.21.4
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source of the production boot bundle entry generated for each hydrated page by
|
|
3
|
+
* scripts/build.js. Its own module (build.js runs the whole build when imported) so the
|
|
4
|
+
* generated code can be unit-tested.
|
|
5
|
+
*
|
|
6
|
+
* The first page's mount() instance is handed to initNavigation so the first client
|
|
7
|
+
* navigation destroys it; without that its listeners outlive the page (#168).
|
|
8
|
+
*/
|
|
9
|
+
export function buildBootstrapSource({ relSpec, relRuntime, relNavigate, storeImport = '', mountOpts = '{ ssr: true }' }) {
|
|
10
|
+
return `\
|
|
11
|
+
import spec from '${relSpec}'
|
|
12
|
+
import { mount } from '${relRuntime}'
|
|
13
|
+
import { initNavigation } from '${relNavigate}'${storeImport}
|
|
14
|
+
|
|
15
|
+
const root = document.getElementById('pulse-root')
|
|
16
|
+
if (root && !root.dataset.pulseMounted) {
|
|
17
|
+
root.dataset.pulseMounted = '1'
|
|
18
|
+
const instance = mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
|
|
19
|
+
// Register the first page's instance so the first client navigation destroys it
|
|
20
|
+
initNavigation(root, mount).setMount(instance)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default spec
|
|
24
|
+
`
|
|
25
|
+
}
|
package/scripts/build.js
CHANGED
|
@@ -21,6 +21,7 @@ import { discoverPages } from '../src/cli/discover.js'
|
|
|
21
21
|
import { renderToString } from '../src/runtime/ssr.js'
|
|
22
22
|
import { c, header, table, ok, fail, info, warn, elapsed, icon } from '../src/cli/fmt.js'
|
|
23
23
|
import { extractUsedClasses, purgeCss, minifyCss } from './css-purge.js'
|
|
24
|
+
import { buildBootstrapSource } from './bootstrap.js'
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// Server-only property stripping
|
|
@@ -499,20 +500,7 @@ const bootstrapFiles = pages.flatMap(({ filePath }) => {
|
|
|
499
500
|
const storeImport = STORE_PATH ? `\nimport store from '${path.relative(TMP_DIR, STORE_PATH)}'` : ''
|
|
500
501
|
const mountOpts = STORE_PATH ? `{ ssr: true, store }` : `{ ssr: true }`
|
|
501
502
|
|
|
502
|
-
fs.writeFileSync(bootstrapPath,
|
|
503
|
-
import spec from '${relSpec}'
|
|
504
|
-
import { mount } from '${relRuntime}'
|
|
505
|
-
import { initNavigation } from '${relNavigate}'${storeImport}
|
|
506
|
-
|
|
507
|
-
const root = document.getElementById('pulse-root')
|
|
508
|
-
if (root && !root.dataset.pulseMounted) {
|
|
509
|
-
root.dataset.pulseMounted = '1'
|
|
510
|
-
mount(spec, root, window.__PULSE_SERVER__ || {}, ${mountOpts})
|
|
511
|
-
initNavigation(root, mount)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export default spec
|
|
515
|
-
`)
|
|
503
|
+
fs.writeFileSync(bootstrapPath, buildBootstrapSource({ relSpec, relRuntime, relNavigate, storeImport, mountOpts }))
|
|
516
504
|
|
|
517
505
|
return [{ filePath, bootstrapPath, name }]
|
|
518
506
|
})
|
package/src/runtime/index.js
CHANGED
|
@@ -169,7 +169,9 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
169
169
|
// ---------------------------------------------------------------------------
|
|
170
170
|
|
|
171
171
|
function persist() {
|
|
172
|
-
|
|
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
|
-
|
|
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
|
|
package/src/runtime/navigate.js
CHANGED
|
@@ -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)
|
package/src/runtime/ssr.js
CHANGED
|
@@ -298,8 +298,9 @@ export function buildHydrateScript(spec, storeDef, nonce) {
|
|
|
298
298
|
import { mount } from '/@pulse/runtime/index.js'
|
|
299
299
|
import { initNavigation } from '/@pulse/runtime/navigate.js'${storeImport}
|
|
300
300
|
const root = document.getElementById('pulse-root')
|
|
301
|
-
mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
|
|
302
|
-
|
|
301
|
+
const instance = mount(spec, root, window.__PULSE_SERVER__ || {}${storeArg})
|
|
302
|
+
// Register the first page's instance so the first client navigation destroys it
|
|
303
|
+
initNavigation(root, mount).setMount(instance)
|
|
303
304
|
</script>`
|
|
304
305
|
}
|
|
305
306
|
|
package/types/navigate.d.ts
CHANGED
|
@@ -14,7 +14,9 @@ import type { PulseSpec } from './schema.js'
|
|
|
14
14
|
* swaps #pulse-root, and re-mounts the new spec without a full page reload.
|
|
15
15
|
* Falls back to location.href on any fetch or import error.
|
|
16
16
|
*
|
|
17
|
-
* Call once after the initial mount()
|
|
17
|
+
* Call once after the initial mount(), and pass that instance to the returned
|
|
18
|
+
* `setMount` so the first navigation destroys it. Without it the first page's
|
|
19
|
+
* listeners stay alive and every handler present on both pages runs twice.
|
|
18
20
|
*
|
|
19
21
|
* @param root The #pulse-root element
|
|
20
22
|
* @param mountFn The mount() function from @invisibleloop/pulse/runtime
|
|
@@ -24,8 +26,8 @@ import type { PulseSpec } from './schema.js'
|
|
|
24
26
|
* import { initNavigation } from '@invisibleloop/pulse/navigate'
|
|
25
27
|
*
|
|
26
28
|
* const root = document.getElementById('pulse-root')
|
|
27
|
-
* const
|
|
28
|
-
* initNavigation(root, mount)
|
|
29
|
+
* const instance = mount(spec, root, window.__PULSE_SERVER__ ?? {}, { ssr: true })
|
|
30
|
+
* initNavigation(root, mount).setMount(instance)
|
|
29
31
|
*/
|
|
30
32
|
export function initNavigation(
|
|
31
33
|
root: HTMLElement,
|
|
@@ -35,4 +37,7 @@ export function initNavigation(
|
|
|
35
37
|
serverState?: Record<string, unknown>,
|
|
36
38
|
options?: { ssr?: boolean; store?: unknown }
|
|
37
39
|
) => MountResult
|
|
38
|
-
):
|
|
40
|
+
): {
|
|
41
|
+
/** Register the first page's mount so the first navigation destroys it. */
|
|
42
|
+
setMount(instance: MountResult | null | undefined): void
|
|
43
|
+
}
|
package/types/runtime.d.ts
CHANGED
|
@@ -27,8 +27,9 @@ export interface MountOptions {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface MountResult {
|
|
30
|
-
/**
|
|
31
|
-
|
|
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
|
/**
|