@invisibleloop/pulse 0.4.0 → 0.5.0

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/CLAUDE.md CHANGED
@@ -35,7 +35,7 @@ public/
35
35
 
36
36
  ## The Spec
37
37
 
38
- A spec is a plain JS object. Every property is optional except `route`, `state`, and `view`.
38
+ A spec is a plain JS object. Every property is optional except `route` and `view`. `state` defaults to `{}` — omit it on purely server-rendered pages.
39
39
 
40
40
  ```js
41
41
  export const mySpec = {
@@ -62,6 +62,7 @@ export const mySpec = {
62
62
  store: ['user', 'cart'],
63
63
 
64
64
  // Initial client state — deep cloned on mount, never mutated directly
65
+ // Optional — omit entirely on purely server-rendered pages (no mutations/actions/persist)
65
66
  state: { count: 0 },
66
67
 
67
68
  // Declarative min/max bounds — always enforced after mutations
@@ -25,7 +25,7 @@ export default {
25
25
  ['Field', 'Type', 'Required', 'Description'],
26
26
  [
27
27
  ['<code>route</code>', '<code>string</code>', 'Yes', 'URL pattern for this page. Supports <code>:param</code> segments.'],
28
- ['<code>state</code>', '<code>object</code>', 'Yes', 'Initial client-side state. Deep-cloned on mount.'],
28
+ ['<code>state</code>', '<code>object</code>', 'No', 'Initial client-side state. Deep-cloned on mount. Defaults to <code>{}</code> — omit on purely server-rendered pages.'],
29
29
  ['<code>view</code>', '<code>function</code>', 'Yes', 'Returns an HTML string. Receives <code>(state, serverState)</code>.'],
30
30
  ['<code>meta</code>', '<code>object</code>', 'No', 'Page metadata: title, description, styles, OG tags, schema.'],
31
31
  ['<code>hydrate</code>', '<code>string</code>', 'Auto', 'Auto-derived from the URL entry in <code>createServer</code>. Do not set manually.'],
@@ -53,7 +53,7 @@ route: '/blog/:year/:slug'`, 'js'))}
53
53
  <p>Dynamic segments are available in server data and actions via <code>ctx.params</code>. See <a href="/routing">Routing</a> for more.</p>
54
54
 
55
55
  ${section('state', 'state')}
56
- <p>The initial client-side state for the page. Always a plain object. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
56
+ <p>The initial client-side state for the page. Always a plain object. Defaults to <code>{}</code> — you can omit it entirely on purely server-rendered pages with no <code>mutations</code>, <code>actions</code>, or <code>persist</code>. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.</p>
57
57
  ${codeBlock(highlight(`state: {
58
58
  count: 0,
59
59
  user: { name: '', email: '' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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.4.0
1
+ 0.5.0
@@ -24,8 +24,8 @@ const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
24
24
  * @param {Object} [serverState] - Serialised server state from SSR
25
25
  */
26
26
  export function mount(spec, el, serverState = {}, options = {}) {
27
- if (!spec || spec.state === undefined || !spec.view) {
28
- throw new Error('[Pulse] mount: spec must have state and view')
27
+ if (!spec || !spec.view) {
28
+ throw new Error('[Pulse] mount: spec must have a view')
29
29
  }
30
30
  // Spec is validated server-side at startup — no need to re-validate in the browser
31
31
  // Initialise the client store from SSR data (no-op after the first page mount).
@@ -60,7 +60,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
60
60
  }
61
61
 
62
62
  // Deep clone initial state — never mutate the spec itself
63
- let state = deepClone(spec.state)
63
+ let state = deepClone(spec.state ?? {})
64
64
 
65
65
  // Restore persisted keys from localStorage
66
66
  const persistKey = spec.persist?.length ? `pulse:${spec.route || location.pathname}` : null
@@ -69,7 +69,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
69
69
  try {
70
70
  const saved = JSON.parse(localStorage.getItem(persistKey) || '{}')
71
71
  spec.persist.forEach(k => {
72
- if (saved[k] !== undefined && saved[k] !== spec.state[k]) {
72
+ if (saved[k] !== undefined && saved[k] !== (spec.state ?? {})[k]) {
73
73
  state[k] = saved[k]
74
74
  restoredFromPersist = true
75
75
  }
@@ -159,9 +159,7 @@ export function validateSpec(spec) {
159
159
  }
160
160
 
161
161
  // state
162
- if (spec.state === undefined) {
163
- errors.push('spec.state is required — use {} if there is no client state')
164
- } else if (typeof spec.state !== 'object' || Array.isArray(spec.state)) {
162
+ if (spec.state !== undefined && (typeof spec.state !== 'object' || Array.isArray(spec.state))) {
165
163
  errors.push('spec.state must be a plain object')
166
164
  }
167
165
 
@@ -54,8 +54,13 @@ test('rejects route that does not start with /', () => {
54
54
  assertErrors({ route: 'contact', state: {}, view: () => '' }, 'must start with "/"')
55
55
  })
56
56
 
57
- test('requires state', () => {
58
- assertErrors({ route: '/contact', view: () => '' }, 'spec.state is required')
57
+ test('state is optional — omitting it is valid', () => {
58
+ const { valid, errors } = validateSpec({ route: '/contact', view: () => '' })
59
+ if (!valid) throw new Error(`Expected valid but got: ${errors.join(', ')}`)
60
+ })
61
+
62
+ test('state must be a plain object when provided', () => {
63
+ assertErrors({ route: '/contact', state: 'bad', view: () => '' }, 'spec.state must be a plain object')
59
64
  })
60
65
 
61
66
  test('requires view', () => {