@invisibleloop/pulse 0.21.1 → 0.21.3

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.1",
3
+ "version": "0.21.3",
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"
@@ -1 +1 @@
1
- 0.21.1
1
+ 0.21.3
@@ -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
  })
@@ -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
- initNavigation(root, mount)
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
 
@@ -124,17 +124,18 @@
124
124
 
125
125
  /**
126
126
  * Validate a spec object against the schema.
127
- * Returns { valid: true } or { valid: false, errors: string[] }
127
+ * Returns { valid, errors, warnings } — `warnings` is always an array, including on the early
128
+ * returns (non-object spec, raw-response spec), because callers spread it.
128
129
  *
129
130
  * @param {unknown} spec
130
- * @returns {{ valid: boolean, errors: string[] }}
131
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
131
132
  */
132
133
  export function validateSpec(spec) {
133
134
  const errors = []
134
135
  const warnings = []
135
136
 
136
137
  if (!spec || typeof spec !== 'object') {
137
- return { valid: false, errors: ['Spec must be a plain object'] }
138
+ return { valid: false, errors: ['Spec must be a plain object'], warnings }
138
139
  }
139
140
 
140
141
  // route — '*' is the custom not-found page, rendered with status 404 when no route matches
@@ -191,7 +192,7 @@ export function validateSpec(spec) {
191
192
  errors.push('spec.render is required when spec.contentType is set — (ctx, server) => string')
192
193
  }
193
194
  // Raw specs don't need state or view — skip those checks
194
- return { valid: errors.length === 0, errors }
195
+ return { valid: errors.length === 0, errors, warnings }
195
196
  }
196
197
 
197
198
  // state
@@ -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 m = mount(spec, root, window.__PULSE_SERVER__ ?? {}, { ssr: true })
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
- ): void
40
+ ): {
41
+ /** Register the first page's mount so the first navigation destroys it. */
42
+ setMount(instance: MountResult | null | undefined): void
43
+ }