@invisibleloop/pulse 0.1.39 → 0.2.1

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.
@@ -254,6 +254,30 @@ test('constraints on nested paths', () => {
254
254
  assert(instance.getState().slider.value === 100, `Expected 100, got ${instance.getState().slider.value}`)
255
255
  })
256
256
 
257
+ test('render is skipped when mutation produces no state change', () => {
258
+ const el = new FakeElement()
259
+ let renderCount = 0
260
+ const spec = {
261
+ route: '/nochange',
262
+ state: { count: 10 },
263
+ constraints: { count: { min: 0, max: 10 } },
264
+ view: (s) => { renderCount++; return `<span>${s.count}</span>` },
265
+ mutations: {
266
+ increment: (state) => ({ count: state.count + 1 }), // clamped to 10
267
+ noop: () => ({}), // empty partial
268
+ }
269
+ }
270
+ const instance = mount(spec, el)
271
+ renderCount = 0 // reset after initial render
272
+
273
+ instance.dispatch('increment') // already at max — state unchanged
274
+ instance.dispatch('noop') // empty partial — state unchanged
275
+ assert(renderCount === 0, `Expected 0 renders for no-change dispatches, got ${renderCount}`)
276
+
277
+ instance.dispatch('increment') // state unchanged by constraint
278
+ assert(instance.getState().count === 10)
279
+ })
280
+
257
281
  // ---------------------------------------------------------------------------
258
282
 
259
283
  console.log('\nActions\n')
@@ -90,27 +90,38 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
90
90
  // Run async — don't await here, stream is returned immediately
91
91
  ;(async () => {
92
92
  try {
93
- // Resolve shell server data (only what shell segments need)
94
- const shellServerState = await resolveServerStateForSegments(spec, ctx, shell)
93
+ const timeout = ctx.fetcherTimeout ?? null
94
+
95
+ // Eagerly start ALL fetcher promises so shell and deferred run in parallel.
96
+ // Each fetcher runs at most once — deferred-scoped fetchers fire immediately
97
+ // rather than waiting for the shell to finish, and shared fetchers are
98
+ // never called twice even if referenced by multiple segments.
99
+ const fetcherCache = new Map()
100
+ if (spec.server) {
101
+ for (const [key, fn] of Object.entries(spec.server)) {
102
+ fetcherCache.set(key, withTimeout(fn(ctx), timeout, key))
103
+ }
104
+ }
95
105
 
96
- // Merge declared store keys store keys lose to page-level keys
106
+ // Await only the fetchers the shell segments need
107
+ const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
97
108
  const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
98
109
 
99
- // Write shell immediately
110
+ // Write shell immediately — deferred fetchers may still be in-flight
100
111
  const shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
101
112
  controller.enqueue(encode(shellHtml))
102
113
 
103
- // Write deferred segments as they resolve
104
114
  if (deferred.length > 0) {
105
- // Enqueue placeholder elements so the browser knows where to insert
115
+ // Placeholders so the browser knows where to insert deferred content
106
116
  const placeholders = deferred
107
117
  .map(key => `<pulse-deferred id="pd-${key}"></pulse-deferred>`)
108
118
  .join('')
109
119
  controller.enqueue(encode(placeholders))
110
120
 
111
- // Resolve and stream each deferred segment
121
+ // Stream each deferred segment as soon as its own fetchers resolve.
122
+ // Promise.all runs them concurrently — the fastest segment wins.
112
123
  await Promise.all(deferred.map(async (key) => {
113
- const segServerState = await resolveServerStateForSegments(spec, ctx, [key])
124
+ const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
114
125
  const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
115
126
  const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
116
127
 
@@ -129,14 +140,114 @@ export function renderToStream(spec, ctx = {}, nonce = '') {
129
140
  }))
130
141
  }
131
142
 
132
- // Inject server state so the client hydration can read it without
133
- // a second request mirrors what wrapDocument does for the string path.
134
- // Emit when there is page server data or store data to serialise.
135
- if (Object.keys(mergedShellState).length > 0) {
136
- const script = `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedShellState)};</script>`
137
- controller.enqueue(encode(script))
143
+ // Collect all resolved server state for client hydration.
144
+ // All promises are already settled by the time we reach here.
145
+ const allServerState = fetcherCache.size > 0
146
+ ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
147
+ : {}
148
+ const mergedAllState = mergeStoreKeys(spec, allServerState, ctx.store)
149
+ if (Object.keys(mergedAllState).length > 0) {
150
+ controller.enqueue(encode(
151
+ `<script nonce="${nonce}">window.__PULSE_SERVER__ = ${JSON.stringify(mergedAllState)};</script>`
152
+ ))
153
+ }
154
+
155
+ controller.close()
156
+ } catch (err) {
157
+ controller.error(err)
158
+ }
159
+ })()
160
+
161
+ return stream
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // renderToNavStream
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Render a spec to a stream of newline-delimited JSON (NDJSON) messages
170
+ * for use during client-side navigation. Sends four message types:
171
+ *
172
+ * { type: 'meta', title, styles, scripts, hydrate } — immediately
173
+ * { type: 'html', html, deferred } — shell ready
174
+ * { type: 'deferred', id, html } — each deferred segment
175
+ * { type: 'done', serverState, storeState } — all data resolved
176
+ *
177
+ * The client (navigate.js) reads lines as they arrive:
178
+ * - meta → updates title + stylesheets
179
+ * - html → swaps root.innerHTML (shell), finds <pulse-deferred> placeholders
180
+ * - deferred → replaces the matching placeholder with rendered HTML
181
+ * - done → stores serverState, mounts spec
182
+ *
183
+ * @param {import('../spec/schema.js').PulseSpec} spec
184
+ * @param {Object} [ctx]
185
+ * @param {Object} [resolvedMeta] - Pre-resolved meta (title, styles, scripts)
186
+ * @returns {ReadableStream}
187
+ */
188
+ export function renderToNavStream(spec, ctx = {}, resolvedMeta = {}) {
189
+ assertValidSpec(spec)
190
+
191
+ const { shell, deferred } = getStreamOrder(spec)
192
+ const clientState = { ...spec.state }
193
+
194
+ let controller
195
+ const stream = new ReadableStream({ start(c) { controller = c } })
196
+
197
+ ;(async () => {
198
+ try {
199
+ const timeout = ctx.fetcherTimeout ?? null
200
+ const writeLine = (obj) => controller.enqueue(encode(JSON.stringify(obj) + '\n'))
201
+
202
+ // Eagerly start ALL fetcher promises so shell and deferred run in parallel
203
+ const fetcherCache = new Map()
204
+ if (spec.server) {
205
+ for (const [key, fn] of Object.entries(spec.server)) {
206
+ fetcherCache.set(key, withTimeout(fn(ctx), timeout, key))
207
+ }
208
+ }
209
+
210
+ // 1. Send meta immediately — no data fetching needed
211
+ writeLine({
212
+ type: 'meta',
213
+ title: resolvedMeta.title || 'Pulse',
214
+ styles: resolvedMeta.styles || [],
215
+ scripts: resolvedMeta.scripts || [],
216
+ hydrate: spec.hydrate || null,
217
+ })
218
+
219
+ // 2. Await shell fetchers then send shell HTML
220
+ const shellServerState = await awaitFetchersForSegments(spec, shell, fetcherCache)
221
+ const mergedShellState = mergeStoreKeys(spec, shellServerState, ctx.store)
222
+ let shellHtml = renderNamedSegments(spec, shell, clientState, mergedShellState)
223
+
224
+ // Append <pulse-deferred> placeholders so the client knows where to inject deferred content
225
+ if (deferred.length > 0) {
226
+ shellHtml += deferred.map(key => `<pulse-deferred id="pd-${key}"></pulse-deferred>`).join('')
138
227
  }
139
228
 
229
+ writeLine({ type: 'html', html: shellHtml, deferred })
230
+
231
+ // 3. Stream each deferred segment as soon as its own fetchers resolve
232
+ await Promise.all(deferred.map(async (key) => {
233
+ const segServerState = await awaitFetchersForSegments(spec, [key], fetcherCache)
234
+ const mergedSegState = mergeStoreKeys(spec, segServerState, ctx.store)
235
+ const segHtml = renderNamedSegments(spec, [key], clientState, mergedSegState)
236
+ writeLine({ type: 'deferred', id: key, html: segHtml })
237
+ }))
238
+
239
+ // 4. Collect all resolved server state then send done
240
+ const allServerState = fetcherCache.size > 0
241
+ ? Object.fromEntries(await Promise.all([...fetcherCache.entries()].map(async ([k, p]) => [k, await p])))
242
+ : {}
243
+ const mergedAll = mergeStoreKeys(spec, allServerState, ctx.store)
244
+
245
+ writeLine({
246
+ type: 'done',
247
+ serverState: Object.keys(mergedAll).length > 0 ? mergedAll : undefined,
248
+ storeState: ctx.store && Object.keys(ctx.store).length > 0 ? ctx.store : undefined,
249
+ })
250
+
140
251
  controller.close()
141
252
  } catch (err) {
142
253
  controller.error(err)
@@ -327,20 +438,54 @@ export async function resolveServerState(spec, ctx) {
327
438
  }
328
439
 
329
440
  /**
330
- * Resolve server data for a specific set of segments only.
331
- * Used by the streaming renderer to avoid fetching deferred data
332
- * before the shell is sent.
441
+ * Await the subset of already-started fetcher promises that the given
442
+ * segment group needs. Uses spec.stream.scope when declared; falls back
443
+ * to all fetchers when a segment has no scope annotation.
333
444
  *
334
- * Currently resolves all server statesegment-level data dependencies
335
- * will be an opt-in annotation in a future iteration.
445
+ * Fetchers are never started herethey were all started eagerly at the
446
+ * top of renderToStream via the fetcherCache Map.
336
447
  *
337
448
  * @param {import('../spec/schema.js').PulseSpec} spec
338
- * @param {Object} ctx
339
- * @param {string[]} _segments - Segment keys (reserved for future scoping)
449
+ * @param {string[]} segmentKeys
450
+ * @param {Map<string, Promise>} fetcherCache
340
451
  * @returns {Promise<Object>}
341
452
  */
342
- async function resolveServerStateForSegments(spec, ctx, _segments) {
343
- return resolveServerState(spec, ctx)
453
+ async function awaitFetchersForSegments(spec, segmentKeys, fetcherCache) {
454
+ if (!spec.server || fetcherCache.size === 0) return {}
455
+
456
+ const neededKeys = getScopedFetcherKeys(spec, segmentKeys)
457
+
458
+ const entries = await Promise.all(
459
+ neededKeys
460
+ .filter(key => fetcherCache.has(key))
461
+ .map(async key => [key, await fetcherCache.get(key)])
462
+ )
463
+
464
+ return Object.fromEntries(entries)
465
+ }
466
+
467
+ /**
468
+ * Return the server fetcher keys needed by a set of segments.
469
+ * Uses spec.stream.scope when declared. Segments not listed in scope
470
+ * receive all server keys (safe default — same behaviour as before).
471
+ *
472
+ * @param {import('../spec/schema.js').PulseSpec} spec
473
+ * @param {string[]} segmentKeys
474
+ * @returns {string[]}
475
+ */
476
+ function getScopedFetcherKeys(spec, segmentKeys) {
477
+ if (!spec.server) return []
478
+
479
+ const scope = spec.stream?.scope
480
+ if (!scope) return Object.keys(spec.server) // no annotation → all fetchers
481
+
482
+ const needed = new Set()
483
+ for (const seg of segmentKeys) {
484
+ const scoped = scope[seg]
485
+ if (!scoped) return Object.keys(spec.server) // unscoped segment → all fetchers
486
+ for (const key of scoped) needed.add(key)
487
+ }
488
+ return [...needed]
344
489
  }
345
490
 
346
491
  // ---------------------------------------------------------------------------
@@ -3,7 +3,7 @@
3
3
  * run: node src/runtime/ssr.test.js
4
4
  */
5
5
 
6
- import { renderToString, renderToStream, wrapDocument, withTimeout } from './ssr.js'
6
+ import { renderToString, renderToStream, renderToNavStream, wrapDocument, withTimeout } from './ssr.js'
7
7
 
8
8
  // ---------------------------------------------------------------------------
9
9
  // Test runner
@@ -245,6 +245,97 @@ await test('stream does not inject __PULSE_SERVER__ when no server data', async
245
245
  assert(!html.includes('__PULSE_SERVER__'), `Should not include empty server state: ${html}`)
246
246
  })
247
247
 
248
+ await test('each server fetcher runs at most once across all segments', async () => {
249
+ let callCount = 0
250
+ const spec = {
251
+ route: '/dedup',
252
+ stream: {
253
+ shell: ['header'],
254
+ deferred: ['feed', 'sidebar'],
255
+ },
256
+ server: {
257
+ user: async () => { callCount++; return 'user-data' },
258
+ },
259
+ state: {},
260
+ view: {
261
+ header: (s, { user }) => `<header>${user}</header>`,
262
+ feed: (s, { user }) => `<feed>${user}</feed>`,
263
+ sidebar: (s, { user }) => `<aside>${user}</aside>`,
264
+ }
265
+ }
266
+
267
+ await streamToString(renderToStream(spec))
268
+ assert(callCount === 1, `Expected fetcher called once, got ${callCount}`)
269
+ })
270
+
271
+ await test('stream.scope: shell does not wait for deferred-only fetchers', async () => {
272
+ const spec = {
273
+ route: '/scoped',
274
+ stream: {
275
+ shell: ['header'],
276
+ deferred: ['feed'],
277
+ scope: {
278
+ header: ['user'], // fast (10ms)
279
+ feed: ['posts'], // slow (80ms)
280
+ }
281
+ },
282
+ server: {
283
+ user: async () => { await new Promise(r => setTimeout(r, 10)); return 'user-data' },
284
+ posts: async () => { await new Promise(r => setTimeout(r, 80)); return 'posts-data' },
285
+ },
286
+ state: {},
287
+ view: {
288
+ header: (s, { user }) => `<header>${user}</header>`,
289
+ feed: (s, { posts }) => `<main>${posts}</main>`,
290
+ }
291
+ }
292
+
293
+ const chunks = []
294
+ const stream = renderToStream(spec)
295
+ const reader = stream.getReader()
296
+ const decoder = new TextDecoder()
297
+ let shellTime = null
298
+ const t0 = Date.now()
299
+
300
+ while (true) {
301
+ const { done, value } = await reader.read()
302
+ if (done) break
303
+ const chunk = decoder.decode(value)
304
+ if (shellTime === null && chunk.includes('<header>')) shellTime = Date.now() - t0
305
+ chunks.push(chunk)
306
+ }
307
+
308
+ const fullHtml = chunks.join('')
309
+ assert(fullHtml.includes('user-data'), 'header should contain user-data')
310
+ assert(fullHtml.includes('posts-data'), 'feed should contain posts-data')
311
+ // Shell should arrive ~10ms (user fetcher), not ~80ms (posts fetcher)
312
+ assert(shellTime !== null && shellTime < 40, `Shell arrived too late: ${shellTime}ms (expected ~10ms)`)
313
+ })
314
+
315
+ await test('stream.scope: __PULSE_SERVER__ includes state from all scoped fetchers', async () => {
316
+ const spec = {
317
+ route: '/scoped-state',
318
+ stream: {
319
+ shell: ['header'],
320
+ deferred: ['feed'],
321
+ scope: { header: ['user'], feed: ['posts'] }
322
+ },
323
+ server: {
324
+ user: async () => ({ name: 'Andy' }),
325
+ posts: async () => [{ title: 'Hello' }],
326
+ },
327
+ state: {},
328
+ view: {
329
+ header: (s, { user }) => `<header>${user.name}</header>`,
330
+ feed: (s, { posts }) => `<main>${posts[0].title}</main>`,
331
+ }
332
+ }
333
+
334
+ const html = await streamToString(renderToStream(spec))
335
+ assert(html.includes('"name":"Andy"'), `Expected user state serialised: ${html}`)
336
+ assert(html.includes('"title":"Hello"'), `Expected posts state serialised: ${html}`)
337
+ })
338
+
248
339
  // ---------------------------------------------------------------------------
249
340
 
250
341
  console.log('\nwrapDocument\n')
@@ -415,6 +506,169 @@ await test('renderToString: resolves normally when fetcher completes within time
415
506
  assert(html.includes('quick'), `Expected rendered data, got: ${html}`)
416
507
  })
417
508
 
509
+ // ---------------------------------------------------------------------------
510
+ // renderToNavStream
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /**
514
+ * Collect all NDJSON messages from a renderToNavStream response.
515
+ * Returns them as an array of parsed objects in arrival order.
516
+ */
517
+ async function collectNavMessages(stream) {
518
+ const reader = stream.getReader()
519
+ const decoder = new TextDecoder()
520
+ let buf = ''
521
+ const messages = []
522
+
523
+ while (true) {
524
+ const { done, value } = await reader.read()
525
+ if (done) break
526
+ buf += decoder.decode(value, { stream: true })
527
+ const lines = buf.split('\n')
528
+ buf = lines.pop()
529
+ for (const line of lines) {
530
+ if (line.trim()) messages.push(JSON.parse(line))
531
+ }
532
+ }
533
+ if (buf.trim()) messages.push(JSON.parse(buf))
534
+ return messages
535
+ }
536
+
537
+ console.log('\nrenderToNavStream\n')
538
+
539
+ await test('sends meta as the first message with no data fetching', async () => {
540
+ const spec = {
541
+ route: '/nav',
542
+ state: {},
543
+ view: () => '<main id="main-content">hello</main>',
544
+ hydrate: '/nav.js',
545
+ }
546
+ const stream = renderToNavStream(spec, {}, { title: 'Nav Page', styles: ['/app.css'] })
547
+ const messages = await collectNavMessages(stream)
548
+ const meta = messages[0]
549
+ assert(meta.type === 'meta', `First message should be meta, got ${meta.type}`)
550
+ assert(meta.title === 'Nav Page', `Expected title 'Nav Page', got ${meta.title}`)
551
+ assert(meta.hydrate === '/nav.js', `Expected hydrate '/nav.js', got ${meta.hydrate}`)
552
+ assert(meta.styles[0] === '/app.css', 'styles should be forwarded')
553
+ })
554
+
555
+ await test('sends html message with shell content', async () => {
556
+ const spec = {
557
+ route: '/nav',
558
+ state: {},
559
+ view: () => '<main id="main-content">shell</main>',
560
+ }
561
+ const messages = await collectNavMessages(renderToNavStream(spec))
562
+ const html = messages.find(m => m.type === 'html')
563
+ assert(html, 'should have html message')
564
+ assert(html.html.includes('shell'), 'html should contain view output')
565
+ })
566
+
567
+ await test('sends done message with serverState', async () => {
568
+ const spec = {
569
+ route: '/nav',
570
+ state: {},
571
+ server: { items: async () => [1, 2, 3] },
572
+ view: (s, srv) => `<main id="main-content">${srv.items.length}</main>`,
573
+ }
574
+ const messages = await collectNavMessages(renderToNavStream(spec))
575
+ const done = messages.find(m => m.type === 'done')
576
+ assert(done, 'should have done message')
577
+ assert(Array.isArray(done.serverState?.items), 'serverState.items should be present')
578
+ assert(done.serverState.items.length === 3, 'serverState.items should have 3 entries')
579
+ })
580
+
581
+ await test('sends deferred messages for each deferred segment', async () => {
582
+ const spec = {
583
+ route: '/nav',
584
+ state: {},
585
+ server: { user: async () => ({ name: 'Alice' }), posts: async () => ['a', 'b'] },
586
+ stream: {
587
+ shell: ['header'],
588
+ deferred: ['feed'],
589
+ },
590
+ view: {
591
+ header: () => '<header>nav</header>',
592
+ feed: (s, srv) => `<aside>${srv.posts.length} posts</aside>`,
593
+ },
594
+ }
595
+ const messages = await collectNavMessages(renderToNavStream(spec))
596
+ const types = messages.map(m => m.type)
597
+ assert(types[0] === 'meta', `First should be meta, got ${types[0]}`)
598
+ assert(types.includes('html'), 'should include html')
599
+ assert(types.includes('deferred'), 'should include deferred')
600
+ assert(types[types.length - 1] === 'done', 'last should be done')
601
+
602
+ const deferred = messages.find(m => m.type === 'deferred')
603
+ assert(deferred.id === 'feed', `Expected deferred id 'feed', got ${deferred.id}`)
604
+ assert(deferred.html.includes('2 posts'), `Expected feed content, got: ${deferred.html}`)
605
+ })
606
+
607
+ await test('shell html includes <pulse-deferred> placeholders for deferred segments', async () => {
608
+ const spec = {
609
+ route: '/nav',
610
+ state: {},
611
+ server: { posts: async () => [] },
612
+ stream: { shell: ['header'], deferred: ['feed'] },
613
+ view: {
614
+ header: () => '<header>top</header>',
615
+ feed: () => '<aside>feed</aside>',
616
+ },
617
+ }
618
+ const messages = await collectNavMessages(renderToNavStream(spec))
619
+ const html = messages.find(m => m.type === 'html')
620
+ assert(html.html.includes('pd-feed'), 'shell html should include <pulse-deferred id="pd-feed"> placeholder')
621
+ assert(Array.isArray(html.deferred), 'html message should list deferred key names')
622
+ assert(html.deferred[0] === 'feed', `Expected deferred[0]='feed', got ${html.deferred[0]}`)
623
+ })
624
+
625
+ await test('stream.scope: meta arrives before slow deferred fetcher resolves', async () => {
626
+ const spec = {
627
+ route: '/nav',
628
+ state: {},
629
+ server: {
630
+ fast: async () => 'quick',
631
+ slow: async () => { await new Promise(r => setTimeout(r, 80)); return 'late' },
632
+ },
633
+ stream: {
634
+ shell: ['header'],
635
+ deferred: ['feed'],
636
+ scope: { header: ['fast'], feed: ['slow'] },
637
+ },
638
+ view: {
639
+ header: (s, srv) => `<header>${srv.fast}</header>`,
640
+ feed: (s, srv) => `<aside>${srv.slow}</aside>`,
641
+ },
642
+ }
643
+ const t0 = Date.now()
644
+ const stream = renderToNavStream(spec)
645
+ const reader = stream.getReader()
646
+ const decoder = new TextDecoder()
647
+ let buf = ''
648
+ let metaTime = null
649
+ let shellTime = null
650
+
651
+ outer: while (true) {
652
+ const { done, value } = await reader.read()
653
+ if (done) break
654
+ buf += decoder.decode(value, { stream: true })
655
+ const lines = buf.split('\n')
656
+ buf = lines.pop()
657
+ for (const line of lines) {
658
+ if (!line.trim()) continue
659
+ const msg = JSON.parse(line)
660
+ if (msg.type === 'meta') metaTime = Date.now() - t0
661
+ if (msg.type === 'html') { shellTime = Date.now() - t0; break outer }
662
+ }
663
+ }
664
+ reader.cancel()
665
+
666
+ assert(metaTime !== null, 'meta should have arrived')
667
+ assert(shellTime !== null, 'shell should have arrived')
668
+ assert(metaTime < 20, `meta should arrive <20ms (fast), got ${metaTime}ms`)
669
+ assert(shellTime < 40, `shell should arrive <40ms (only awaits 'fast'), got ${shellTime}ms`)
670
+ })
671
+
418
672
  // ---------------------------------------------------------------------------
419
673
 
420
674
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)