@invisibleloop/pulse 0.20.9 → 0.20.10

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.
@@ -1719,14 +1719,14 @@ hr.ui-divider {
1719
1719
  .ui-code-window-filename {
1720
1720
  font-size: .75rem;
1721
1721
  font-family: var(--ui-mono);
1722
- color: #9898b2;
1722
+ color: var(--ui-muted);
1723
1723
  margin-left: .35rem;
1724
1724
  }
1725
1725
 
1726
1726
  .ui-code-window-lang {
1727
1727
  font-size: .7rem;
1728
1728
  font-family: var(--ui-mono);
1729
- color: #9898b2;
1729
+ color: var(--ui-muted);
1730
1730
  margin-left: auto;
1731
1731
  }
1732
1732
 
@@ -24,22 +24,22 @@
24
24
  --border-subtle: #ece7d9;
25
25
  --text: var(--brut-ink);
26
26
  --muted: var(--brut-muted);
27
- --muted-2: #8a8272;
28
- --accent: var(--brut-gold-deep);
27
+ --muted-2: #736b5c;
28
+ --accent: #8a6300;
29
29
  --accent-text: var(--brut-ink);
30
- --accent-hover: #a97f00;
30
+ --accent-hover: #7a5700;
31
31
  --accent-dim: rgba(201, 151, 0, 0.12);
32
32
  --green: #166534;
33
33
  --red: #b91c1c;
34
34
 
35
35
  /* Syntax tokens — dark-on-light, tuned for contrast on --surface-2 */
36
36
  --tok-kw: #7c3aed;
37
- --tok-str: #15803d;
38
- --tok-cmt: #78716c;
39
- --tok-num: #c2410c;
37
+ --tok-str: #166534;
38
+ --tok-cmt: #5c574f;
39
+ --tok-num: #9a3412;
40
40
  --tok-fn: #1d4ed8;
41
- --tok-prop: #a16207;
42
- --tok-op: #0891b2;
41
+ --tok-prop: #854d0e;
42
+ --tok-op: #0e6280;
43
43
  --tok-punct: #57534e;
44
44
 
45
45
  /* Sticky header background — replaces the hardcoded rgba(13,13,16,.9) */
@@ -13,20 +13,26 @@ function esc(s) {
13
13
  }
14
14
 
15
15
  function statPill(label, value) {
16
- return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);color:rgba(255,255,255,0.55)">
17
- <span style="color:#7c3aed">●</span> ${esc(label)}: <strong style="color:rgba(255,255,255,0.85);font-weight:600">${esc(String(value))}</strong>
16
+ return `<span style="display:inline-flex;align-items:center;gap:0.3rem;padding:0.2rem 0.6rem;border-radius:999px;font-size:0.72rem;font-weight:500;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.14);color:rgba(255,255,255,0.7)">
17
+ <span style="color:#a78bfa">●</span> ${esc(label)}: <strong style="color:#fff;font-weight:600">${esc(String(value))}</strong>
18
18
  </span>`
19
19
  }
20
20
 
21
21
  function templateCard({ name, route, description, tags = [], stats = {}, preview = '' }) {
22
22
  const tagHtml = tags.map(t =>
23
- `<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.12);border-radius:4px;font-size:0.72rem;font-weight:500;color:rgba(255,255,255,0.55)">${esc(t)}</span>`
23
+ `<span style="display:inline-block;padding:0.2rem 0.6rem;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.16);border-radius:4px;font-size:0.72rem;font-weight:500;color:rgba(255,255,255,0.7)">${esc(t)}</span>`
24
24
  ).join('')
25
25
 
26
26
  const statHtml = Object.entries(stats).map(([k, v]) => statPill(k, v)).join('')
27
27
 
28
+ // This card is a deliberately dark, self-contained surface (macOS-style
29
+ // preview window + dark body) regardless of the page's own theme — it
30
+ // mirrors the code-window pattern elsewhere. Uses a real dark background
31
+ // (not a translucent white tint over the page background), so it reads
32
+ // correctly on both the light and dark docs themes and keeps the
33
+ // white-tinted text/borders it was designed with at proper contrast.
28
34
  return `
29
- <div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background:rgba(255,255,255,0.03)">
35
+ <div style="border:1px solid rgba(255,255,255,0.1);border-radius:0.875rem;overflow:hidden;display:flex;flex-direction:column;background:#171512">
30
36
 
31
37
  <a href="${esc(route)}" aria-label="Open ${esc(name)} template" style="display:block;background:#0d0d12;border-bottom:1px solid rgba(255,255,255,0.08);text-decoration:none;overflow:hidden">
32
38
  <div style="padding:0.5rem 0.75rem;background:#16161e;display:flex;align-items:center;gap:0.375rem;border-bottom:1px solid rgba(255,255,255,0.06)">
@@ -40,13 +46,13 @@ function templateCard({ name, route, description, tags = [], stats = {}, preview
40
46
  </div>
41
47
  </a>
42
48
 
43
- <div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem">
49
+ <div style="padding:1.25rem 1.25rem 1rem;flex:1;display:flex;flex-direction:column;gap:0.875rem;color:#fff">
44
50
  <div>
45
51
  <div style="display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;margin-bottom:0.5rem">
46
- <h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3">${esc(name)}</h2>
52
+ <h2 style="margin:0;font-size:1rem;font-weight:700;line-height:1.3;color:#fff">${esc(name)}</h2>
47
53
  ${tagHtml}
48
54
  </div>
49
- <p style="margin:0;font-size:0.85rem;line-height:1.6;opacity:0.65">${esc(description)}</p>
55
+ <p style="margin:0;font-size:0.85rem;line-height:1.6;color:rgba(255,255,255,0.7)">${esc(description)}</p>
50
56
  </div>
51
57
  <div style="display:flex;flex-wrap:wrap;gap:0.3rem;padding-top:0.625rem;border-top:1px solid rgba(255,255,255,0.08)">${statHtml}</div>
52
58
  </div>
@@ -1719,14 +1719,14 @@ hr.ui-divider {
1719
1719
  .ui-code-window-filename {
1720
1720
  font-size: .75rem;
1721
1721
  font-family: var(--ui-mono);
1722
- color: #9898b2;
1722
+ color: var(--ui-muted);
1723
1723
  margin-left: .35rem;
1724
1724
  }
1725
1725
 
1726
1726
  .ui-code-window-lang {
1727
1727
  font-size: .7rem;
1728
1728
  font-family: var(--ui-mono);
1729
- color: #9898b2;
1729
+ color: var(--ui-muted);
1730
1730
  margin-left: auto;
1731
1731
  }
1732
1732
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.9",
3
+ "version": "0.20.10",
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.20.9
1
+ 0.20.10
@@ -1719,14 +1719,14 @@ hr.ui-divider {
1719
1719
  .ui-code-window-filename {
1720
1720
  font-size: .75rem;
1721
1721
  font-family: var(--ui-mono);
1722
- color: #9898b2;
1722
+ color: var(--ui-muted);
1723
1723
  margin-left: .35rem;
1724
1724
  }
1725
1725
 
1726
1726
  .ui-code-window-lang {
1727
1727
  font-size: .7rem;
1728
1728
  font-family: var(--ui-mono);
1729
- color: #9898b2;
1729
+ color: var(--ui-muted);
1730
1730
  margin-left: auto;
1731
1731
  }
1732
1732
 
package/scripts/build.js CHANGED
@@ -502,7 +502,7 @@ const result = await esbuild.build({
502
502
  platform: 'browser',
503
503
  outdir: OUT_DIR,
504
504
  entryNames: '[name]-[hash]',
505
- chunkNames: 'runtime-[hash]',
505
+ chunkNames: 'chunk-[hash]',
506
506
  splitting: true,
507
507
  minify: true,
508
508
  metafile: true,
@@ -520,13 +520,25 @@ const result = await esbuild.build({
520
520
 
521
521
  const manifest = {}
522
522
 
523
+ // Identify the real Pulse framework runtime chunk (the one containing mount())
524
+ // by which auto-split chunks actually import RUNTIME_PATH — not by filename
525
+ // prefix, since chunkNames now applies to every generic shared chunk esbuild
526
+ // splits out (app-level shared code included), not just the framework runtime.
527
+ const RUNTIME_ABS = path.resolve(RUNTIME_PATH)
528
+ const realRuntimeChunk = Object.entries(result.metafile.outputs).find(([outFile, meta]) => {
529
+ if (meta.entryPoint) return false
530
+ // meta.inputs keys are relative to process.cwd() — resolve to compare reliably
531
+ return Object.keys(meta.inputs).some(input => path.resolve(input) === RUNTIME_ABS)
532
+ })?.[0]
533
+
523
534
  for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
524
535
  // outFile is relative to process.cwd(), not ROOT — use path.resolve to get the
525
536
  // true absolute path before computing the URL-relative bundle path.
526
537
  const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.resolve(outFile))
527
538
 
528
- // Shared runtime chunk — esbuild generates this via splitting, no entryPoint
529
- if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
539
+ // Shared runtime chunk — esbuild generates this via splitting, no entryPoint.
540
+ // Identified by containing the actual runtime/index.js source, not by name.
541
+ if (!meta.entryPoint && outFile === realRuntimeChunk) {
530
542
  manifest['_runtime'] = bundlePath
531
543
  continue
532
544
  }
@@ -7,12 +7,28 @@
7
7
  * No framework. No virtual DOM. No dependencies.
8
8
  */
9
9
 
10
- import { initClientStore, getStoreState, subscribe, updateStore, registerStoreMutations, dispatchStoreMutation, initLiveStore } from './store.js'
11
10
  import { trustedHTML, trustedScriptURL } from './tt.js'
12
11
 
13
12
  // Toast is lazy-loaded on first use — pages that never use _toast pay zero bytes
14
13
  const showToast = (opts) => import('./toast.js').then(m => m.showToast(opts))
15
14
 
15
+ // store.js is lazy-loaded only by pages that declare spec.store — pages that
16
+ // never subscribe to the global store pay zero bytes for it (same pattern as
17
+ // showToast above). updateStore is needed synchronously inside runAction()
18
+ // for _storeUpdate (any page can push to the store from an action, even one
19
+ // that doesn't itself subscribe), so it is loaded the same lazy way on first
20
+ // use and cached in _storeModule for subsequent calls.
21
+ let _storeModule = null
22
+ const loadStore = () => _storeModule
23
+ ? Promise.resolve(_storeModule)
24
+ : import('./store.js').then(m => { _storeModule = m; return m })
25
+
26
+ // data-store-event is wired via event delegation at bindEvents() time, before
27
+ // spec.store is known to be involved (a page can carry a data-store-event
28
+ // button without declaring spec.store itself) — load store.js on first fire.
29
+ const dispatchStoreMutationLazy = (name, payload) =>
30
+ loadStore().then(({ dispatchStoreMutation }) => dispatchStoreMutation(name, payload))
31
+
16
32
  // ---------------------------------------------------------------------------
17
33
  // Mount
18
34
  // ---------------------------------------------------------------------------
@@ -29,37 +45,85 @@ export function mount(spec, el, serverState = {}, options = {}) {
29
45
  throw new Error('[Pulse] mount: spec must have a view')
30
46
  }
31
47
  // Spec is validated server-side at startup — no need to re-validate in the browser
32
- // Initialise the client store from SSR data (no-op after the first page mount).
33
- // window.__PULSE_STORE__ is serialised by the server when a store is registered.
34
- if (typeof window !== 'undefined') {
35
- initClientStore(window.__PULSE_STORE__ || {})
36
- // Register store mutations so data-store-event bindings can dispatch them.
37
- // No-op on subsequent mounts — mutations persist across client navigations.
38
- if (options.store?.mutations) {
39
- registerStoreMutations(options.store.mutations)
40
- }
41
- }
42
-
43
48
  // Separate page-level server state from store keys so they can be tracked
44
49
  // independently — store values come from the live singleton, not the snapshot.
45
50
  const _storeKeys = new Set(spec.store || [])
46
51
 
47
- // Live store push — connect to the server's SSE channel, but only when this
48
- // page actually subscribes to store keys. Singleton: survives navigations,
49
- // repeat mounts are no-ops. Pages without store keys never open a connection.
50
- if (typeof window !== 'undefined' && window.__PULSE_LIVE__ && _storeKeys.size > 0) {
51
- initLiveStore(window.__PULSE_LIVE__)
52
+ // store.js (initClientStore, registerStoreMutations, subscribe, initLiveStore,
53
+ // getStoreState, updateStore, dispatchStoreMutation) is lazy-loaded — only pages
54
+ // that actually touch the store pay for it. Two independent triggers load it:
55
+ // 1. spec.store is non-empty (this page reads store data / needs live reactivity)
56
+ // 2. a data-store-event fires, or an action returns _storeUpdate (this page
57
+ // pushes to the store without necessarily subscribing to it)
58
+ // Both paths funnel through loadStore(), which caches the resolved module so a
59
+ // second trigger on the same page is a no-op re-await, not a second fetch.
60
+ //
61
+ // initClientStore is safe to defer: it is only ever read via getStoreState(),
62
+ // which is only called from getEffectiveServerState() below, itself gated on
63
+ // _storeKeys.size — so a page with no spec.store never needs the store singleton
64
+ // initialised at all. window.__PULSE_STORE__ is also re-serialised fresh by the
65
+ // server on every navigation (see ssr.js buildStoreScript / navigate.js's
66
+ // window.__updatePulseStore__), so there is no cross-navigation dependency on an
67
+ // eager initClientStore call from a page that didn't declare spec.store.
68
+ // Populated once store.js resolves and this page subscribes — destroy() reads
69
+ // it at call time, so a destroy() that races the import still unsubscribes
70
+ // correctly (it just runs the .then() below first, since both are microtasks
71
+ // queued off the same import; destroy() itself is only ever called later, from
72
+ // user code or client navigation, well after mount() has returned).
73
+ let _unsubStore = null
74
+ let _destroyed = false
75
+
76
+ // NOTE: subscribe() below is intentionally not gated on `typeof window` — this
77
+ // matches the pre-lazy behaviour, where store subscription worked in any
78
+ // environment (including Node test environments with no `window` global) as
79
+ // long as spec.store was non-empty. Only the window-derived inputs
80
+ // (window.__PULSE_STORE__, window.__PULSE_LIVE__) are individually guarded.
81
+ if (_storeKeys.size > 0) {
82
+ loadStore().then(({ initClientStore, registerStoreMutations, initLiveStore, subscribe }) => {
83
+ if (_destroyed) return
84
+ if (typeof window !== 'undefined') {
85
+ // window.__PULSE_STORE__ is serialised by the server when a store is registered.
86
+ initClientStore(window.__PULSE_STORE__ || {})
87
+ // Register store mutations so data-store-event bindings can dispatch them.
88
+ // No-op on subsequent mounts — mutations persist across client navigations.
89
+ if (options.store?.mutations) {
90
+ registerStoreMutations(options.store.mutations)
91
+ }
92
+ // Live store push — connect to the server's SSE channel, but only when this
93
+ // page actually subscribes to store keys. Singleton: survives navigations,
94
+ // repeat mounts are no-ops. Pages without store keys never open a connection.
95
+ if (window.__PULSE_LIVE__) {
96
+ initLiveStore(window.__PULSE_LIVE__)
97
+ }
98
+ }
99
+ // Subscribe to global store — re-render whenever store keys this page uses
100
+ // change. Wired here (after the async import resolves) rather than
101
+ // synchronously in mount() — see the module-level comment on loadStore.
102
+ _unsubStore = subscribe(() => render())
103
+ })
52
104
  }
53
105
  const _pageServerState = {}
54
106
  for (const [k, v] of Object.entries(serverState)) {
55
107
  if (!_storeKeys.has(k)) _pageServerState[k] = v
56
108
  }
57
109
 
110
+ // Snapshot of the store keys as SSR handed them to mount() — used as a
111
+ // fallback for renders that happen before store.js's lazy import resolves
112
+ // (initial render, and any dispatch that fires in that window). This keeps
113
+ // first paint identical to the pre-lazy behaviour: the SSR-seeded values are
114
+ // available immediately. Once store.js loads, getEffectiveServerState()
115
+ // switches over to the live singleton (getStoreState()), which is required
116
+ // for correctness after any store mutation.
117
+ const _storeSnapshot = {}
118
+ for (const k of _storeKeys) {
119
+ if (serverState[k] !== undefined) _storeSnapshot[k] = serverState[k]
120
+ }
121
+
58
122
  // Build the server state the view sees: fresh store slice + page-level data.
59
123
  // Page-level keys always win over store keys with the same name.
60
124
  function getEffectiveServerState() {
61
125
  if (!_storeKeys.size) return _pageServerState
62
- const storeState = getStoreState()
126
+ const storeState = _storeModule ? _storeModule.getStoreState() : _storeSnapshot
63
127
  const slice = {}
64
128
  for (const key of _storeKeys) {
65
129
  if (storeState[key] !== undefined) slice[key] = storeState[key]
@@ -109,7 +173,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
109
173
  try {
110
174
  html = resolveView(spec, state, getEffectiveServerState())
111
175
  } catch (err) {
112
- console.error('[Pulse] View error:', err)
176
+ console.error('[Pulse] view error:', err)
113
177
  const serverState = getEffectiveServerState()
114
178
  html = spec.onViewError
115
179
  ? spec.onViewError(err, state, serverState)
@@ -120,6 +184,24 @@ export function mount(spec, el, serverState = {}, options = {}) {
120
184
  morph(el, html)
121
185
  }
122
186
 
187
+ // ---------------------------------------------------------------------------
188
+ // Result application — shared by every mutation/action lifecycle step.
189
+ // Handles the side-channel keys (_toast, _storeUpdate), merges the rest of
190
+ // `raw` into state via constraints, and re-renders only if state actually
191
+ // changed. Used by dispatch() and every stage of runAction().
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function applyResult(raw) {
195
+ const { _toast, _storeUpdate, ...partial } = raw ?? {}
196
+ // store.js may not be loaded yet on a page that doesn't itself declare
197
+ // spec.store but still pushes via _storeUpdate — load it on demand.
198
+ if (_storeUpdate) loadStore().then(({ updateStore }) => updateStore(_storeUpdate))
199
+ if (_toast) showToast(_toast)
200
+ const prev = state
201
+ state = applyConstraints({ ...state, ...partial }, spec.constraints)
202
+ if (shallowChanged(prev, state)) render()
203
+ }
204
+
123
205
  // ---------------------------------------------------------------------------
124
206
  // Dispatch — the single entry point for all state changes
125
207
  // ---------------------------------------------------------------------------
@@ -128,12 +210,8 @@ export function mount(spec, el, serverState = {}, options = {}) {
128
210
  // Mutation
129
211
  if (spec.mutations?.[type]) {
130
212
  const raw = spec.mutations[type](state, payload)
131
- if (raw?._toast) showToast(raw._toast)
132
- const { _toast, ...partial } = raw ?? {}
133
- const prev = state
134
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
213
+ applyResult(raw)
135
214
  persist()
136
- if (shallowChanged(prev, state)) render()
137
215
  return
138
216
  }
139
217
 
@@ -143,7 +221,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
143
221
  return
144
222
  }
145
223
 
146
- console.warn(`[Pulse] No mutation or action found for "${type}"`)
224
+ console.warn(`[Pulse] no mutation/action "${type}"`)
147
225
  }
148
226
 
149
227
  // ---------------------------------------------------------------------------
@@ -153,25 +231,15 @@ export function mount(spec, el, serverState = {}, options = {}) {
153
231
  async function runAction(name, action, currentState, payload) {
154
232
  // Optimistic update before async work
155
233
  if (action.onStart) {
156
- const raw = action.onStart(currentState, payload)
157
- if (raw?._toast) showToast(raw._toast)
158
- const { _toast, ...partial } = raw ?? {}
159
- const prev = state
160
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
161
- if (shallowChanged(prev, state)) render()
234
+ applyResult(action.onStart(currentState, payload))
162
235
  }
163
236
 
164
237
  // Validate before running if requested
165
238
  if (action.validate) {
166
239
  const errors = validateFields(state, spec.validation)
167
240
  if (errors.length > 0) {
168
- console.warn(`[Pulse] Validation failed for action "${name}":`, errors)
169
- const raw = action.onError?.(state, { validation: errors }) ?? {}
170
- if (raw._toast) showToast(raw._toast)
171
- const { _toast, ...partial } = raw
172
- const prev = state
173
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
174
- if (shallowChanged(prev, state)) render()
241
+ console.warn(`[Pulse] Validation failed: "${name}"`, errors)
242
+ applyResult(action.onError?.(state, { validation: errors }))
175
243
  return
176
244
  }
177
245
  }
@@ -179,23 +247,10 @@ export function mount(spec, el, serverState = {}, options = {}) {
179
247
  try {
180
248
  // Pass fresh effective server state (includes live store values)
181
249
  const result = await action.run(state, getEffectiveServerState(), payload)
182
- const raw = action.onSuccess(state, result) ?? {}
183
-
184
- // _storeUpdate — push changes to the global store and notify all subscribers
185
- if (raw._storeUpdate) updateStore(raw._storeUpdate)
186
- if (raw._toast) showToast(raw._toast)
187
- const { _storeUpdate: _su, _toast: _t, ...partial } = raw
188
- const prev = state
189
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
190
- if (shallowChanged(prev, state)) render()
250
+ applyResult(action.onSuccess(state, result))
191
251
  } catch (error) {
192
- console.error(`[Pulse] Action "${name}" failed:`, error)
193
- const raw = action.onError(state, error) ?? {}
194
- if (raw._toast) showToast(raw._toast)
195
- const { _toast, ...partial } = raw
196
- const prev = state
197
- state = applyConstraints({ ...state, ...partial }, spec.constraints)
198
- if (shallowChanged(prev, state)) render()
252
+ console.error(`[Pulse] action "${name}" failed:`, error)
253
+ applyResult(action.onError(state, error))
199
254
  }
200
255
 
201
256
  persist()
@@ -210,9 +265,6 @@ export function mount(spec, el, serverState = {}, options = {}) {
210
265
  const _eventAbort = new AbortController()
211
266
  bindEvents(el, dispatch, _eventAbort.signal)
212
267
 
213
- // Subscribe to global store — re-render whenever store keys this page uses change.
214
- const _unsubStore = _storeKeys.size > 0 ? subscribe(() => render()) : null
215
-
216
268
  // If the element was server-rendered, skip the initial render to avoid
217
269
  // touching existing DOM — this preserves the early LCP paint from SSR.
218
270
  // Morph on first mutation will diff from whatever SSR left in the DOM.
@@ -235,7 +287,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
235
287
  refresh: render,
236
288
 
237
289
  /** Tear down — remove event listeners, unsubscribe from store, clear element */
238
- destroy: () => { _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
290
+ destroy: () => { _destroyed = true; _unsubStore?.(); _eventAbort.abort(); el.innerHTML = trustedHTML('') }
239
291
  }
240
292
  }
241
293
 
@@ -291,7 +343,7 @@ function bindEvents(el, dispatch, signal) {
291
343
  const storeTarget = e.target?.closest?.('[data-store-event]')
292
344
  if (storeTarget) {
293
345
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
294
- if (sType === 'click') { e.preventDefault(); dispatchStoreMutation(sName, e) }
346
+ if (sType === 'click') { e.preventDefault(); dispatchStoreMutationLazy(sName, e) }
295
347
  return
296
348
  }
297
349
 
@@ -334,7 +386,7 @@ function bindEvents(el, dispatch, signal) {
334
386
  const storeTarget = e.target?.closest?.('[data-store-event]')
335
387
  if (storeTarget) {
336
388
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
337
- if (sType === 'change') dispatchStoreMutation(sName, e)
389
+ if (sType === 'change') dispatchStoreMutationLazy(sName, e)
338
390
  return
339
391
  }
340
392
  const target = e.target?.closest?.('[data-event]')
@@ -348,7 +400,7 @@ function bindEvents(el, dispatch, signal) {
348
400
  const storeTarget = e.target?.closest?.('[data-store-event]')
349
401
  if (storeTarget) {
350
402
  const [sType, sName] = parseEventAttr(storeTarget.dataset.storeEvent)
351
- if (sType === 'input') dispatchStoreMutation(sName, e)
403
+ if (sType === 'input') dispatchStoreMutationLazy(sName, e)
352
404
  return
353
405
  }
354
406
  const target = e.target?.closest?.('[data-event]')
@@ -598,7 +598,7 @@ test('dispatchStoreMutation warns on unknown mutation', () => {
598
598
  assert(warnings.some(w => w.includes('doesNotExist')), 'Expected warning for unknown store mutation')
599
599
  })
600
600
 
601
- test('dispatchStoreMutation notifies subscribed pages', () => {
601
+ await testAsync('dispatchStoreMutation notifies subscribed pages', async () => {
602
602
  updateStore({ counter: 0 })
603
603
  const el = new FakeElement()
604
604
  const spec = {
@@ -608,6 +608,12 @@ test('dispatchStoreMutation notifies subscribed pages', () => {
608
608
  view: (state, server) => `<span>${server.counter ?? 0}</span>`,
609
609
  }
610
610
  mount(spec, el, { counter: 0 })
611
+ // store.js is lazy-loaded by mount() when spec.store is non-empty — its
612
+ // subscription is wired up after the dynamic import() resolves. Resolving
613
+ // a dynamic import is scheduled as a macrotask even for an already-cached
614
+ // module (verified empirically — microtask-only yields are not enough),
615
+ // so a setTimeout(0) tick-yield is required here, not just Promise.resolve().
616
+ await new Promise(r => setTimeout(r, 0))
611
617
  const before = el.innerHTML
612
618
  dispatchStoreMutation('inc')
613
619
  assert(el.innerHTML !== before, `Page should re-render after store mutation`)
@@ -623,7 +629,7 @@ test('registerStoreMutations is a no-op after first call', () => {
623
629
 
624
630
  console.log('\nLive store push (client)\n')
625
631
 
626
- test('initLiveStore connects once, merges SSE store events, survives bad frames', () => {
632
+ await testAsync('initLiveStore connects once, merges SSE store events, survives bad frames', async () => {
627
633
  const instances = []
628
634
  globalThis.EventSource = class {
629
635
  constructor(url) { this.url = url; this._listeners = {}; instances.push(this) }
@@ -638,6 +644,9 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
638
644
  // A store event merges into the client store and notifies subscribers
639
645
  const el = new FakeElement()
640
646
  mount({ route: '/stock', store: ['stock'], state: {}, view: (s, server) => `<b>${server.stock ?? '—'}</b>` }, el, {})
647
+ // store.js is lazy-loaded when spec.store is non-empty — wait for the dynamic
648
+ // import to resolve before the subscription (wired inside it) is live.
649
+ await new Promise(r => setTimeout(r, 0))
641
650
  instances[0]._listeners.store({ data: JSON.stringify({ stock: 7 }) })
642
651
  assert(getStoreState().stock === 7, `Expected stock 7, got ${getStoreState().stock}`)
643
652
  assert(el.innerHTML.includes('7'), `Subscribed page should re-render: ${el.innerHTML}`)
@@ -650,6 +659,41 @@ test('initLiveStore connects once, merges SSE store events, survives bad frames'
650
659
  delete globalThis.EventSource
651
660
  })
652
661
 
662
+ console.log('\nLazy store loading (through mount())\n')
663
+
664
+ await testAsync('mount() with spec.store lazy-loads store.js and wires reactivity end to end', async () => {
665
+ updateStore({ counter: 100 })
666
+ const el = new FakeElement()
667
+ const spec = {
668
+ route: '/lazy-store',
669
+ store: ['counter'],
670
+ state: {},
671
+ view: (state, server) => `<span>${server.counter ?? 0}</span>`,
672
+ }
673
+
674
+ const instance = mount(spec, el, { counter: 100 })
675
+
676
+ // mount() returns synchronously, before store.js's dynamic import has resolved —
677
+ // the view renders with the page-level serverState snapshot passed to mount()
678
+ // (not yet the live store slice), and store-driven reactivity is not wired yet.
679
+ assert(el.innerHTML.includes('100'), `Expected initial render to use the snapshot: ${el.innerHTML}`)
680
+
681
+ // Yield a macrotask so the lazy import('./store.js') resolves. This is the
682
+ // "one microtask/tick later" cost documented for the lazy-loading change —
683
+ // after this point store subscription, registerStoreMutations, and (when
684
+ // window.__PULSE_LIVE__ is set) initLiveStore have all been wired up.
685
+ await new Promise(r => setTimeout(r, 0))
686
+
687
+ // Store-driven re-render now works correctly end to end through mount():
688
+ // a mutation dispatched on the singleton reaches this page's subscription.
689
+ const before = el.innerHTML
690
+ dispatchStoreMutation('inc')
691
+ assert(el.innerHTML !== before, 'Page should re-render once store.js has loaded and subscribed')
692
+ assert(el.innerHTML.includes('101'), `Expected counter 101 in HTML, got: ${el.innerHTML}`)
693
+
694
+ instance.destroy()
695
+ })
696
+
653
697
  // ---------------------------------------------------------------------------
654
698
 
655
699
  console.log('\nDebounce / throttle\n')
@@ -313,6 +313,32 @@ export function buildHydrateScript(spec, storeDef, nonce) {
313
313
  * @param {Object} [options.timing] - Server-Timing values
314
314
  * @returns {string}
315
315
  */
316
+ // Third-party stylesheets (e.g. Google Fonts) pay a DNS+TLS round trip before
317
+ // the request can even start — preconnect warms that connection up in
318
+ // parallel with the document request instead of after the browser parses the
319
+ // stylesheet link. Relative URLs (same-origin) don't need this — the
320
+ // connection to the page's own origin is already open. Shared by wrapDocument
321
+ // (non-streaming render) and the streaming response head in server/index.js,
322
+ // which builds its <head> independently rather than calling wrapDocument.
323
+ export function buildStylePreconnects(styles = []) {
324
+ const styleOrigins = [...new Set(
325
+ styles
326
+ .map(href => { try { return new URL(href).origin } catch { return null } })
327
+ .filter(Boolean)
328
+ )]
329
+ return styleOrigins
330
+ .flatMap(origin => {
331
+ const links = [`<link rel="preconnect" href="${esc(origin)}">`]
332
+ // Google Fonts serves the CSS from fonts.googleapis.com but the actual
333
+ // font files from fonts.gstatic.com — preconnect to both up front.
334
+ if (origin === 'https://fonts.googleapis.com') {
335
+ links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>')
336
+ }
337
+ return links
338
+ })
339
+ .join('\n ')
340
+ }
341
+
316
342
  export function wrapDocument({ content, spec = {}, serverState = {}, storeState = null, storeDef = null, timing = {}, extraBody = '', extraHead = '', nonce = '', runtimeBundle = '', faviconHref = '', livePath = '' }) {
317
343
  const meta = spec.meta || {}
318
344
  const title = meta.title || 'Pulse'
@@ -328,6 +354,8 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
328
354
  meta.ogImage ? `<meta property="og:image" content="${esc(meta.ogImage)}">` : '',
329
355
  ].filter(Boolean).join('\n ')
330
356
 
357
+ const stylePreconnects = buildStylePreconnects(meta.styles || [])
358
+
331
359
  const stylePreloads = (meta.styles || [])
332
360
  .map(href => `<link rel="preload" as="style" href="${esc(href)}">`)
333
361
  .join('\n ')
@@ -381,6 +409,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
381
409
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
382
410
  <link rel="icon" href="${faviconHref || 'data:,'}">
383
411
  <title>${esc(title)}</title>
412
+ ${stylePreconnects}
384
413
  ${stylePreloads}
385
414
  ${runtimePreload}
386
415
  ${extraHead}
@@ -21,7 +21,7 @@ import { createRequire } from 'module'
21
21
 
22
22
  const _require = createRequire(import.meta.url)
23
23
  export const version = _require('../../package.json').version
24
- import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript } from '../runtime/ssr.js'
24
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, resolveServerState, buildStoreScript, buildHydrateScript, buildStylePreconnects } from '../runtime/ssr.js'
25
25
  import { validateSpec } from '../spec/schema.js'
26
26
  import { validateStore, resolveStoreState } from '../store/index.js'
27
27
 
@@ -1322,6 +1322,8 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1322
1322
  try { res.writeEarlyHints({ link: earlyLinks }) } catch {}
1323
1323
  }
1324
1324
 
1325
+ const stylePreconnects = buildStylePreconnects(meta.styles || [])
1326
+
1325
1327
  const stylePreloads = (meta.styles || [])
1326
1328
  .map(href => ` <link rel="preload" as="style" href="${escHtml(href)}">`)
1327
1329
  .join('\n')
@@ -1358,7 +1360,7 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
1358
1360
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1359
1361
  <link rel="icon" href="${faviconPath || 'data:,'}">
1360
1362
  <title>${escHtml(title)}</title>
1361
- ${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
1363
+ ${stylePreconnects ? stylePreconnects + '\n' : ''}${stylePreloads ? stylePreloads + '\n' : ''}${runtimePreload ? runtimePreload + '\n' : ''}${dev ? devImportMap(nonce) + '\n' : ''}${metaTags}
1362
1364
  </head>
1363
1365
  <body${bodyAttr}>
1364
1366
  <div id="pulse-root">`