@invisibleloop/pulse 0.1.39 → 0.2.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/.github/workflows/publish.yml +3 -2
- package/CLAUDE.md +21 -1
- package/benchmark/results/2026-03-27-09-20.json +188 -0
- package/benchmark/results/2026-03-27-09-21.json +188 -0
- package/benchmark/results/2026-03-27-09-29.json +188 -0
- package/benchmark/results/2026-03-27-10-14.json +188 -0
- package/benchmark/results/2026-03-27-10-18.json +232 -0
- package/docs/public/.pulse-ui-version +1 -1
- package/package.json +2 -2
- package/public/.pulse-ui-version +1 -1
- package/scripts/bench.js +483 -0
- package/scripts/release-version.js +56 -0
- package/src/cli/index.js +30 -0
- package/src/runtime/index.js +89 -7
- package/src/runtime/morph.test.js +317 -0
- package/src/runtime/navigate.js +132 -52
- package/src/runtime/runtime.test.js +24 -0
- package/src/runtime/ssr.js +168 -23
- package/src/runtime/ssr.test.js +255 -1
- package/src/server/index.js +88 -6
- package/src/server/server.test.js +104 -0
- package/src/spec/schema.js +32 -1
- package/src/spec/schema.test.js +55 -0
|
@@ -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')
|
package/src/runtime/ssr.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
-
*
|
|
335
|
-
*
|
|
445
|
+
* Fetchers are never started here — they 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 {
|
|
339
|
-
* @param {string
|
|
449
|
+
* @param {string[]} segmentKeys
|
|
450
|
+
* @param {Map<string, Promise>} fetcherCache
|
|
340
451
|
* @returns {Promise<Object>}
|
|
341
452
|
*/
|
|
342
|
-
async function
|
|
343
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/runtime/ssr.test.js
CHANGED
|
@@ -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`)
|