@invisibleloop/pulse 0.20.11 → 0.20.20
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 +6 -5
- package/public/.pulse-ui-version +1 -1
- package/scripts/build.js +26 -0
- package/scripts/release-version.js +17 -8
- package/scripts/strip-server.test.js +60 -0
- package/src/agent/checklist.md +8 -12
- package/src/agent/commands/pulse-start.md +2 -0
- package/src/agent/commands/verify.md +30 -8
- package/src/agent/guide-components.md +7 -25
- package/src/agent/guide-routing.md +0 -12
- package/src/agent/guide-server.md +102 -1
- package/src/agent/guide-spec.md +8 -1
- package/src/agent/guide-styles.md +23 -0
- package/src/agent/identity.md +9 -0
- package/src/agent/workflow.md +4 -23
- package/src/cli/check-bundles.js +113 -0
- package/src/cli/check-bundles.test.js +137 -0
- package/src/cli/cli.test.js +105 -0
- package/src/cli/dev.js +48 -3
- package/src/cli/diagnose.js +105 -0
- package/src/cli/diagnose.test.js +124 -0
- package/src/cli/discover.js +9 -2
- package/src/cli/index.js +171 -9
- package/src/cli/process-utils.js +57 -0
- package/src/cli/process-utils.test.js +114 -0
- package/src/cli/review.js +391 -0
- package/src/cli/review.test.js +143 -0
- package/src/cli/scaffold.js +9 -4
- package/src/cli/stamp.js +102 -0
- package/src/cli/stamp.test.js +149 -0
- package/src/cli/start.js +15 -2
- package/src/cli/validate.js +184 -0
- package/src/cli/validate.test.js +153 -0
- package/src/mcp/mcp.test.js +261 -30
- package/src/mcp/server.js +207 -515
- package/src/mcp/validate-worker.js +18 -1
- package/src/runtime/index.js +144 -11
- package/src/runtime/runtime.test.js +199 -0
- package/src/runtime/ssr.js +13 -6
- package/src/runtime/ssr.test.js +31 -0
- package/src/server/index.js +162 -9
- package/src/server/server.test.js +225 -2
- package/src/spec/schema.js +1 -1
- package/src/ui/index.js +0 -5
- package/src/ui/ui.test.js +0 -75
- package/types/ui.d.ts +0 -21
- package/.claude/commands/build-page.md +0 -63
- package/.claude/commands/new-doc-page.md +0 -45
- package/.claude/commands/pulse-start.md +0 -11
- package/.claude/commands/pulse-stop.md +0 -5
- package/.claude/commands/verify.md +0 -118
- package/.claude/pulse-checklist.md +0 -189
- package/.github/workflows/ci.yml +0 -25
- package/.github/workflows/publish.yml +0 -45
- package/.pulse/load-reports/home/1773432711417.json +0 -22
- package/.pulse-verified +0 -1
- package/AGENTS.md +0 -697
- package/CLAUDE.md +0 -697
- package/README.md +0 -95
- package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
- package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
- package/benchmark/public/dist/manifest.json +0 -5
- package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
- package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
- package/benchmark/results/2026-03-27-09-20.json +0 -188
- package/benchmark/results/2026-03-27-09-21.json +0 -188
- package/benchmark/results/2026-03-27-09-29.json +0 -188
- package/benchmark/results/2026-03-27-10-14.json +0 -188
- package/benchmark/results/2026-03-27-10-18.json +0 -232
- package/benchmark/src/pages/counter.js +0 -37
- package/benchmark/src/pages/home.js +0 -21
- package/docs/.claude/commands/pulse-build.md +0 -7
- package/docs/.claude/commands/pulse-dev.md +0 -7
- package/docs/.claude/commands/pulse-start.md +0 -9
- package/docs/.claude/commands/pulse-stop.md +0 -5
- package/docs/.claude/commands/verify.md +0 -84
- package/docs/.claude/coverage-check.js +0 -196
- package/docs/.claude/pulse-checklist.md +0 -189
- package/docs/public/.pulse-ui-version +0 -1
- package/docs/public/andypixel.css +0 -237
- package/docs/public/bookworms.css +0 -422
- package/docs/public/coutumes.css +0 -561
- package/docs/public/docs.css +0 -2388
- package/docs/public/favicon.svg +0 -4
- package/docs/public/home-brut.css +0 -982
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/img/heic1501a.jpg +0 -0
- package/docs/public/img/placeholder-landscape.svg +0 -18
- package/docs/public/img/placeholder-square.svg +0 -14
- package/docs/public/img/placeholder-tall.svg +0 -22
- package/docs/public/journey.css +0 -528
- package/docs/public/menu.js +0 -327
- package/docs/public/pulse-ui.css +0 -3028
- package/docs/public/pulse-ui.js +0 -257
- package/docs/public/retrowheels.css +0 -425
- package/docs/public/shop.css +0 -269
- package/docs/public/sparkys.css +0 -382
- package/docs/public/suzukibento.css +0 -517
- package/docs/public/swish.css +0 -394
- package/docs/public/theme.css +0 -52
- package/docs/public/themes/lumio.css +0 -50
- package/docs/public/themes/observatory.css +0 -104
- package/docs/server.js +0 -161
- package/docs/src/lib/component-page.js +0 -49
- package/docs/src/lib/highlight.js +0 -2
- package/docs/src/lib/layout.js +0 -190
- package/docs/src/lib/metrics-store.js +0 -6
- package/docs/src/lib/nav.js +0 -171
- package/docs/src/lib/stats.js +0 -81
- package/docs/src/pages/accessibility.js +0 -157
- package/docs/src/pages/actions.js +0 -194
- package/docs/src/pages/auth.js +0 -176
- package/docs/src/pages/caching.js +0 -95
- package/docs/src/pages/components/accordion.js +0 -48
- package/docs/src/pages/components/alert.js +0 -35
- package/docs/src/pages/components/app-badge.js +0 -41
- package/docs/src/pages/components/avatar.js +0 -35
- package/docs/src/pages/components/badge.js +0 -36
- package/docs/src/pages/components/breadcrumbs.js +0 -94
- package/docs/src/pages/components/button.js +0 -84
- package/docs/src/pages/components/card.js +0 -225
- package/docs/src/pages/components/charts.js +0 -283
- package/docs/src/pages/components/checkbox.js +0 -129
- package/docs/src/pages/components/cluster.js +0 -47
- package/docs/src/pages/components/container.js +0 -40
- package/docs/src/pages/components/cta.js +0 -54
- package/docs/src/pages/components/divider.js +0 -37
- package/docs/src/pages/components/empty.js +0 -36
- package/docs/src/pages/components/feature.js +0 -60
- package/docs/src/pages/components/fieldset.js +0 -65
- package/docs/src/pages/components/fileupload.js +0 -128
- package/docs/src/pages/components/footer.js +0 -58
- package/docs/src/pages/components/grid.js +0 -169
- package/docs/src/pages/components/heading.js +0 -107
- package/docs/src/pages/components/hero.js +0 -123
- package/docs/src/pages/components/icons.js +0 -410
- package/docs/src/pages/components/image.js +0 -71
- package/docs/src/pages/components/input.js +0 -51
- package/docs/src/pages/components/media.js +0 -51
- package/docs/src/pages/components/modal.js +0 -111
- package/docs/src/pages/components/nav.js +0 -136
- package/docs/src/pages/components/phone-frame.js +0 -74
- package/docs/src/pages/components/progress.js +0 -102
- package/docs/src/pages/components/prose.js +0 -111
- package/docs/src/pages/components/pullquote.js +0 -71
- package/docs/src/pages/components/radio.js +0 -194
- package/docs/src/pages/components/rating.js +0 -106
- package/docs/src/pages/components/search.js +0 -61
- package/docs/src/pages/components/section.js +0 -98
- package/docs/src/pages/components/segmented.js +0 -121
- package/docs/src/pages/components/select.js +0 -45
- package/docs/src/pages/components/slider.js +0 -115
- package/docs/src/pages/components/spinner.js +0 -73
- package/docs/src/pages/components/stack.js +0 -48
- package/docs/src/pages/components/stat.js +0 -55
- package/docs/src/pages/components/stepper.js +0 -66
- package/docs/src/pages/components/table.js +0 -45
- package/docs/src/pages/components/textarea.js +0 -31
- package/docs/src/pages/components/toggle.js +0 -84
- package/docs/src/pages/components/tooltip.js +0 -48
- package/docs/src/pages/components.js +0 -200
- package/docs/src/pages/config.js +0 -193
- package/docs/src/pages/constraints.js +0 -100
- package/docs/src/pages/deploy.js +0 -233
- package/docs/src/pages/error-pages.js +0 -99
- package/docs/src/pages/extending.js +0 -198
- package/docs/src/pages/faq.js +0 -99
- package/docs/src/pages/forms.js +0 -129
- package/docs/src/pages/getting-started.js +0 -147
- package/docs/src/pages/guard.js +0 -120
- package/docs/src/pages/home.js +0 -350
- package/docs/src/pages/how-it-works.js +0 -244
- package/docs/src/pages/hydration.js +0 -108
- package/docs/src/pages/images.js +0 -120
- package/docs/src/pages/markdown.js +0 -156
- package/docs/src/pages/meta.js +0 -193
- package/docs/src/pages/mutations.js +0 -107
- package/docs/src/pages/navigation.js +0 -85
- package/docs/src/pages/performance.js +0 -157
- package/docs/src/pages/persist.js +0 -89
- package/docs/src/pages/project-structure.js +0 -91
- package/docs/src/pages/prompt-examples.js +0 -186
- package/docs/src/pages/raw-responses.js +0 -122
- package/docs/src/pages/routing.js +0 -120
- package/docs/src/pages/server-api.js +0 -291
- package/docs/src/pages/server-data.js +0 -203
- package/docs/src/pages/slash-commands.js +0 -55
- package/docs/src/pages/spec.js +0 -207
- package/docs/src/pages/state.js +0 -102
- package/docs/src/pages/store.js +0 -203
- package/docs/src/pages/streaming.js +0 -107
- package/docs/src/pages/stripe.js +0 -194
- package/docs/src/pages/supabase.js +0 -324
- package/docs/src/pages/templates/andypixel.js +0 -260
- package/docs/src/pages/templates/blog-post.js +0 -226
- package/docs/src/pages/templates/bookworms.js +0 -227
- package/docs/src/pages/templates/coutumes.js +0 -237
- package/docs/src/pages/templates/journey.js +0 -186
- package/docs/src/pages/templates/mobile-app.js +0 -371
- package/docs/src/pages/templates/retrowheels.js +0 -355
- package/docs/src/pages/templates/shop.js +0 -268
- package/docs/src/pages/templates/sparkys.js +0 -264
- package/docs/src/pages/templates/suzukibento.js +0 -200
- package/docs/src/pages/templates/swish.js +0 -313
- package/docs/src/pages/templates.js +0 -534
- package/docs/src/pages/testing.js +0 -199
- package/docs/src/pages/validation.js +0 -138
- package/examples/.claude/commands/pulse-build.md +0 -7
- package/examples/.claude/commands/pulse-dev.md +0 -7
- package/examples/.claude/commands/pulse-start.md +0 -9
- package/examples/.claude/commands/pulse-stop.md +0 -5
- package/examples/.claude/commands/verify.md +0 -84
- package/examples/.claude/coverage-check.js +0 -196
- package/examples/.claude/pulse-checklist.md +0 -189
- package/examples/andypixel.css +0 -237
- package/examples/andypixel.js +0 -258
- package/examples/bookworms.css +0 -422
- package/examples/bookworms.js +0 -225
- package/examples/contact.js +0 -166
- package/examples/counter.js +0 -94
- package/examples/dev.server.js +0 -110
- package/examples/examples.test.js +0 -394
- package/examples/journey.css +0 -528
- package/examples/journey.js +0 -186
- package/examples/pricing.js +0 -262
- package/examples/products.js +0 -190
- package/examples/public/.pulse-ui-version +0 -1
- package/examples/public/pulse-ui.css +0 -3028
- package/examples/public/pulse-ui.js +0 -257
- package/examples/quiz.js +0 -208
- package/examples/retrowheels.css +0 -425
- package/examples/retrowheels.js +0 -353
- package/examples/shared.js +0 -84
- package/examples/suzukibento.css +0 -513
- package/examples/suzukibento.js +0 -200
- package/examples/swish.css +0 -394
- package/examples/swish.js +0 -311
- package/examples/todos.js +0 -162
- package/src/ui/fieldset.js +0 -37
- package/src/ui/phone-frame.js +0 -40
- package/src/ui/search.js +0 -78
- package/src/ui/segmented.js +0 -50
- package/src/ui/tooltip.js +0 -28
|
@@ -12,9 +12,25 @@
|
|
|
12
12
|
import { validateSpec } from '../spec/schema.js'
|
|
13
13
|
|
|
14
14
|
import { existsSync } from 'fs'
|
|
15
|
+
import { isAbsolute } from 'path'
|
|
15
16
|
|
|
16
17
|
const tmpFile = process.argv[2]
|
|
17
18
|
if (!tmpFile) { process.stdout.write('Invalid: no file path provided'); process.exit(0) }
|
|
19
|
+
// A relative path here resolves against THIS FILE's location (src/mcp/), not the
|
|
20
|
+
// caller's cwd — a path like "src/pages/home.js" then looks to Node's ESM resolver
|
|
21
|
+
// like a bare package specifier ("Cannot find package 'src'"), which reads as an
|
|
22
|
+
// import-resolution bug in the spec rather than what it actually is: this worker
|
|
23
|
+
// was invoked with the wrong kind of path. Catch it here with a precise message
|
|
24
|
+
// instead of letting it fall through to the generic import-failure branch below.
|
|
25
|
+
if (!isAbsolute(tmpFile)) {
|
|
26
|
+
process.stdout.write(
|
|
27
|
+
`Invalid: validate-worker.js requires an absolute path, got a relative one — ${tmpFile}\n` +
|
|
28
|
+
` A relative path resolves against this worker's own location, not your cwd, and often ` +
|
|
29
|
+
`fails with a confusing "Cannot find package" error that looks like a bug in the spec's ` +
|
|
30
|
+
`imports. Pass an absolute path instead.`
|
|
31
|
+
)
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
18
34
|
if (!existsSync(tmpFile)) { process.stdout.write(`Invalid: file not found — ${tmpFile}`); process.exit(0) }
|
|
19
35
|
|
|
20
36
|
let mod
|
|
@@ -155,7 +171,8 @@ try {
|
|
|
155
171
|
|
|
156
172
|
// Inline <script> blocks in view — blocked by Pulse CSP nonce policy
|
|
157
173
|
// meta.scripts is the correct way to add JS: it receives the server nonce automatically.
|
|
158
|
-
|
|
174
|
+
// External scripts (<script src="…">) are allowed by script-src 'self', so only flag tags without a src.
|
|
175
|
+
if (/<script(?=[\s>])(?![^>]*\ssrc\s*=)/i.test(html)) {
|
|
159
176
|
warnings.push('Inline <script> block detected in view — Pulse\'s CSP (script-src with nonces) will block these scripts at runtime. Move JavaScript to a file in public/ and reference it via meta.scripts: [\'/my-script.js\'] in the spec instead.')
|
|
160
177
|
}
|
|
161
178
|
|
package/src/runtime/index.js
CHANGED
|
@@ -29,6 +29,21 @@ const loadStore = () => _storeModule
|
|
|
29
29
|
const dispatchStoreMutationLazy = (name, payload) =>
|
|
30
30
|
loadStore().then(({ dispatchStoreMutation }) => dispatchStoreMutation(name, payload))
|
|
31
31
|
|
|
32
|
+
// Best-effort error reporting to the dev-only error journal. The endpoint
|
|
33
|
+
// only exists when the server is running in dev mode — in production this
|
|
34
|
+
// POSTs to a route that 404s, so there's no dev-detection needed here and
|
|
35
|
+
// no risk to production behaviour. Never throws, never blocks the UI.
|
|
36
|
+
function reportError({ phase, message, stack }) {
|
|
37
|
+
try {
|
|
38
|
+
fetch('/__pulse/error', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ route: location.pathname, phase, message, stack }),
|
|
42
|
+
keepalive: true,
|
|
43
|
+
}).catch(() => {})
|
|
44
|
+
} catch { /* fetch unavailable or blocked — drop it */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
// ---------------------------------------------------------------------------
|
|
33
48
|
// Mount
|
|
34
49
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +189,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
174
189
|
html = resolveView(spec, state, getEffectiveServerState())
|
|
175
190
|
} catch (err) {
|
|
176
191
|
console.error('[Pulse] view error:', err)
|
|
192
|
+
reportError({ phase: 'view', message: err.message, stack: err.stack })
|
|
177
193
|
const serverState = getEffectiveServerState()
|
|
178
194
|
html = spec.onViewError
|
|
179
195
|
? spec.onViewError(err, state, serverState)
|
|
@@ -250,6 +266,7 @@ export function mount(spec, el, serverState = {}, options = {}) {
|
|
|
250
266
|
applyResult(action.onSuccess(state, result))
|
|
251
267
|
} catch (error) {
|
|
252
268
|
console.error(`[Pulse] action "${name}" failed:`, error)
|
|
269
|
+
reportError({ phase: 'action', message: `action "${name}" failed: ${error.message}`, stack: error.stack })
|
|
253
270
|
applyResult(action.onError(state, error))
|
|
254
271
|
}
|
|
255
272
|
|
|
@@ -323,10 +340,19 @@ function resolveView(spec, state, serverState) {
|
|
|
323
340
|
* Uses event delegation — one listener per event type per element.
|
|
324
341
|
*
|
|
325
342
|
* Attribute format:
|
|
326
|
-
* data-event="mutationName"
|
|
327
|
-
* data-event="change:mutationName"
|
|
328
|
-
* data-event="input:mutationName"
|
|
329
|
-
* data-
|
|
343
|
+
* data-event="mutationName" → fires on click
|
|
344
|
+
* data-event="change:mutationName" → fires on change
|
|
345
|
+
* data-event="input:mutationName" → fires on input
|
|
346
|
+
* data-event="pointerdown:mutationName" → fires on pointerdown, captures the pointer
|
|
347
|
+
* data-event="pointermove:mutationName" → fires on pointermove (while captured)
|
|
348
|
+
* data-event="pointerup:mutationName" → fires on pointerup or pointercancel
|
|
349
|
+
* data-event="keydown:mutationName" → fires on keydown
|
|
350
|
+
* data-action="actionName" → fires on form submit
|
|
351
|
+
*
|
|
352
|
+
* data-event accepts multiple space-separated bindings on one element —
|
|
353
|
+
* needed for pointer drag, where pointerdown/pointermove/pointerup must all
|
|
354
|
+
* resolve on the same captured element:
|
|
355
|
+
* data-event="pointerdown:start pointermove:drag pointerup:end"
|
|
330
356
|
*
|
|
331
357
|
* @param {HTMLElement} el
|
|
332
358
|
* @param {function} dispatch
|
|
@@ -376,8 +402,8 @@ function bindEvents(el, dispatch, signal) {
|
|
|
376
402
|
// Spec events
|
|
377
403
|
const target = e.target?.closest?.('[data-event]')
|
|
378
404
|
if (!target) return
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
405
|
+
const name = findEventBinding(target, 'click')
|
|
406
|
+
if (!name) return
|
|
381
407
|
e.preventDefault()
|
|
382
408
|
dispatch(name, e)
|
|
383
409
|
}, opts)
|
|
@@ -391,8 +417,8 @@ function bindEvents(el, dispatch, signal) {
|
|
|
391
417
|
}
|
|
392
418
|
const target = e.target?.closest?.('[data-event]')
|
|
393
419
|
if (!target) return
|
|
394
|
-
const
|
|
395
|
-
if (
|
|
420
|
+
const name = findEventBinding(target, 'change')
|
|
421
|
+
if (!name) return
|
|
396
422
|
dispatchTimed(target, name, e, dispatch)
|
|
397
423
|
}, opts)
|
|
398
424
|
|
|
@@ -405,8 +431,8 @@ function bindEvents(el, dispatch, signal) {
|
|
|
405
431
|
}
|
|
406
432
|
const target = e.target?.closest?.('[data-event]')
|
|
407
433
|
if (!target) return
|
|
408
|
-
const
|
|
409
|
-
if (
|
|
434
|
+
const name = findEventBinding(target, 'input')
|
|
435
|
+
if (!name) return
|
|
410
436
|
dispatchTimed(target, name, e, dispatch)
|
|
411
437
|
}, opts)
|
|
412
438
|
|
|
@@ -417,6 +443,57 @@ function bindEvents(el, dispatch, signal) {
|
|
|
417
443
|
dispatch(target.dataset.action, new FormData(target))
|
|
418
444
|
if (target.hasAttribute('data-reset')) target.reset()
|
|
419
445
|
}, opts)
|
|
446
|
+
|
|
447
|
+
// Pointer events — click/input/change only cover discrete interactions.
|
|
448
|
+
// Continuous drag (grab a vertex, drag a slider handle, resize a panel) needs
|
|
449
|
+
// the raw pointer stream. pointerdown sets pointer capture on the target so
|
|
450
|
+
// pointermove/pointerup keep firing on it even once the cursor leaves the
|
|
451
|
+
// element's bounds — without capture, dragging fast loses the element under
|
|
452
|
+
// the cursor and the drag appears to "let go".
|
|
453
|
+
el.addEventListener('pointerdown', e => {
|
|
454
|
+
const target = e.target?.closest?.('[data-event]')
|
|
455
|
+
if (!target) return
|
|
456
|
+
const name = findEventBinding(target, 'pointerdown')
|
|
457
|
+
if (!name) return
|
|
458
|
+
target.setPointerCapture?.(e.pointerId)
|
|
459
|
+
dispatch(name, e)
|
|
460
|
+
}, opts)
|
|
461
|
+
|
|
462
|
+
el.addEventListener('pointermove', e => {
|
|
463
|
+
// Pointer capture routes the event straight to the captured target, so no
|
|
464
|
+
// closest() lookup is needed (and none would work once the cursor is
|
|
465
|
+
// outside the element's bounds).
|
|
466
|
+
const target = e.target
|
|
467
|
+
if (!target?.dataset?.event) return
|
|
468
|
+
const name = findEventBinding(target, 'pointermove')
|
|
469
|
+
if (!name) return
|
|
470
|
+
dispatchTimed(target, name, e, dispatch)
|
|
471
|
+
}, opts)
|
|
472
|
+
|
|
473
|
+
el.addEventListener('pointerup', e => {
|
|
474
|
+
const target = e.target
|
|
475
|
+
const name = target?.dataset?.event ? findEventBinding(target, 'pointerup') : null
|
|
476
|
+
if (name) dispatch(name, e)
|
|
477
|
+
target?.releasePointerCapture?.(e.pointerId)
|
|
478
|
+
}, opts)
|
|
479
|
+
|
|
480
|
+
// pointercancel fires instead of pointerup for interrupted gestures (browser
|
|
481
|
+
// gesture takeover, tab switch mid-drag, etc). Routed to the same handler
|
|
482
|
+
// as pointerup so a drag never gets stuck "in progress".
|
|
483
|
+
el.addEventListener('pointercancel', e => {
|
|
484
|
+
const target = e.target
|
|
485
|
+
const name = target?.dataset?.event ? findEventBinding(target, 'pointerup') : null
|
|
486
|
+
if (name) dispatch(name, e)
|
|
487
|
+
target?.releasePointerCapture?.(e.pointerId)
|
|
488
|
+
}, opts)
|
|
489
|
+
|
|
490
|
+
el.addEventListener('keydown', e => {
|
|
491
|
+
const target = e.target?.closest?.('[data-event]')
|
|
492
|
+
if (!target) return
|
|
493
|
+
const name = findEventBinding(target, 'keydown')
|
|
494
|
+
if (!name) return
|
|
495
|
+
dispatch(name, e)
|
|
496
|
+
}, opts)
|
|
420
497
|
}
|
|
421
498
|
|
|
422
499
|
/**
|
|
@@ -535,10 +612,44 @@ function morphAttrs(cur, nxt) {
|
|
|
535
612
|
if (cur.tagName === 'DIALOG' && name === 'open') continue
|
|
536
613
|
if (!nxt.hasAttribute(name)) cur.removeAttribute(name)
|
|
537
614
|
}
|
|
615
|
+
syncFormProperty(cur, nxt)
|
|
538
616
|
}
|
|
539
617
|
|
|
540
618
|
/**
|
|
541
|
-
*
|
|
619
|
+
* setAttribute('value', …) / setAttribute('checked', …) above only touch the
|
|
620
|
+
* *default* value/checkedness a form control resets to — not the live
|
|
621
|
+
* `.value`/`.checked` property the browser actually renders. Once a user has
|
|
622
|
+
* focused+edited a field (or any code has assigned `.value`/`.checked`
|
|
623
|
+
* directly), the attribute and the property permanently diverge — this is
|
|
624
|
+
* standard DOM behaviour, not a Pulse bug in the attribute-sync loop above.
|
|
625
|
+
* Re-rendering after that point (e.g. a `change:`-committed input, a value
|
|
626
|
+
* driven by a drag elsewhere on the page) would otherwise leave the field
|
|
627
|
+
* showing a stale value even though the attribute updated correctly. Sync
|
|
628
|
+
* the live property directly for the form-control elements where this
|
|
629
|
+
* matters, skipping the element the user is actively typing in — the
|
|
630
|
+
* common "don't destroy focus for a live input:-bound field" case already
|
|
631
|
+
* has its own guidance (use FormData, not a controlled value) but this
|
|
632
|
+
* covers every other legitimate re-render source.
|
|
633
|
+
*/
|
|
634
|
+
function syncFormProperty(cur, nxt) {
|
|
635
|
+
const tag = cur.tagName
|
|
636
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
637
|
+
const type = cur.type
|
|
638
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
639
|
+
const next = nxt.hasAttribute('checked')
|
|
640
|
+
if (cur.checked !== next) cur.checked = next
|
|
641
|
+
} else if (document.activeElement !== cur) {
|
|
642
|
+
const next = nxt.getAttribute('value') ?? ''
|
|
643
|
+
if (cur.value !== next) cur.value = next
|
|
644
|
+
}
|
|
645
|
+
} else if (tag === 'SELECT' && document.activeElement !== cur) {
|
|
646
|
+
const next = nxt.getAttribute('value')
|
|
647
|
+
if (next !== null && cur.value !== next) cur.value = next
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Parse a single data-event binding.
|
|
542
653
|
* "click:increment" → ['click', 'increment']
|
|
543
654
|
* "increment" → ['click', 'increment'] (click is default)
|
|
544
655
|
*
|
|
@@ -551,6 +662,28 @@ function parseEventAttr(value) {
|
|
|
551
662
|
return [parts[0], parts[1]]
|
|
552
663
|
}
|
|
553
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Look up the mutation/action name bound to `eventType` on `target`'s
|
|
667
|
+
* data-event attribute. Supports multiple space-separated bindings on one
|
|
668
|
+
* element — needed for pointer drag, where pointerdown/pointermove/pointerup
|
|
669
|
+
* must all resolve on the same captured element:
|
|
670
|
+
* data-event="pointerdown:start pointermove:drag pointerup:end"
|
|
671
|
+
* A plain single binding ("increment" or "change:setName") still works
|
|
672
|
+
* unchanged — split() on a string with no spaces returns a one-element array.
|
|
673
|
+
*
|
|
674
|
+
* @param {HTMLElement} target
|
|
675
|
+
* @param {string} eventType
|
|
676
|
+
* @returns {string|null}
|
|
677
|
+
*/
|
|
678
|
+
function findEventBinding(target, eventType) {
|
|
679
|
+
for (const binding of target.dataset.event.split(' ')) {
|
|
680
|
+
if (!binding) continue
|
|
681
|
+
const [type, name] = parseEventAttr(binding)
|
|
682
|
+
if (type === eventType) return name
|
|
683
|
+
}
|
|
684
|
+
return null
|
|
685
|
+
}
|
|
686
|
+
|
|
554
687
|
/**
|
|
555
688
|
* Dispatch a mutation, applying debounce or throttle if declared on the element.
|
|
556
689
|
* data-debounce="300" — waits 300ms after the last event before firing
|
|
@@ -54,6 +54,53 @@ function parseElements(html, selector) {
|
|
|
54
54
|
return matches
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A more realistic element for exercising bindEvents() delegation directly —
|
|
59
|
+
* unlike FakeElement above (which stubs addEventListener as a no-op and is
|
|
60
|
+
* only used to assert on rendered innerHTML), this one wires real listeners
|
|
61
|
+
* via the native EventTarget and supports closest()/dataset/pointer capture,
|
|
62
|
+
* so pointerdown/pointermove/pointerup/keydown routing can be verified.
|
|
63
|
+
*/
|
|
64
|
+
class DelegatingElement extends EventTarget {
|
|
65
|
+
constructor() {
|
|
66
|
+
super()
|
|
67
|
+
this.innerHTML = ''
|
|
68
|
+
this._captured = new Map()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class ChildElement extends EventTarget {
|
|
73
|
+
constructor(attrs = {}, parent = null) {
|
|
74
|
+
super()
|
|
75
|
+
this.dataset = {}
|
|
76
|
+
for (const [k, v] of Object.entries(attrs)) this.dataset[k] = v
|
|
77
|
+
this._parent = parent
|
|
78
|
+
}
|
|
79
|
+
closest(selector) {
|
|
80
|
+
const attr = selector.match(/\[([^\]]+)\]/)?.[1]
|
|
81
|
+
const key = attr === 'data-event' ? 'event'
|
|
82
|
+
: attr === 'data-action' ? 'action'
|
|
83
|
+
: attr === 'data-store-event' ? 'storeEvent'
|
|
84
|
+
: attr === 'data-dialog-open' ? 'dialogOpen'
|
|
85
|
+
: attr === 'data-dialog-close' ? 'dialogClose'
|
|
86
|
+
: null
|
|
87
|
+
let node = this
|
|
88
|
+
while (node) {
|
|
89
|
+
if (key && node.dataset && node.dataset[key] !== undefined) return node
|
|
90
|
+
node = node._parent
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
setPointerCapture(id) { this._root?._captured.set(id, this) }
|
|
95
|
+
releasePointerCapture(id) { this._root?._captured.delete(id) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fireOn(root, target, type, props = {}) {
|
|
99
|
+
const event = Object.assign(new Event(type, { bubbles: true }), props)
|
|
100
|
+
Object.defineProperty(event, 'target', { value: target, configurable: true })
|
|
101
|
+
root.dispatchEvent(event)
|
|
102
|
+
}
|
|
103
|
+
|
|
57
104
|
// ---------------------------------------------------------------------------
|
|
58
105
|
// Test runner
|
|
59
106
|
// ---------------------------------------------------------------------------
|
|
@@ -866,5 +913,157 @@ await testAsync('_toast in onError is stripped from spec state', async () => {
|
|
|
866
913
|
|
|
867
914
|
// ---------------------------------------------------------------------------
|
|
868
915
|
|
|
916
|
+
console.log('\nPointer / keyboard event binding\n')
|
|
917
|
+
|
|
918
|
+
test('pointerdown dispatches mutation and captures the pointer', () => {
|
|
919
|
+
const el = new DelegatingElement()
|
|
920
|
+
const dragSpec = {
|
|
921
|
+
route: '/drag',
|
|
922
|
+
state: { dragging: false },
|
|
923
|
+
view: (state) => `<div data-event="pointerdown:startDrag">${state.dragging}</div>`,
|
|
924
|
+
mutations: { startDrag: (state) => ({ dragging: true }) },
|
|
925
|
+
}
|
|
926
|
+
mount(dragSpec, el)
|
|
927
|
+
|
|
928
|
+
const child = new ChildElement({ event: 'pointerdown:startDrag' })
|
|
929
|
+
child._root = el
|
|
930
|
+
let captured = null
|
|
931
|
+
child.setPointerCapture = (id) => { captured = id }
|
|
932
|
+
fireOn(el, child, 'pointerdown', { pointerId: 1 })
|
|
933
|
+
|
|
934
|
+
assert(el.innerHTML.includes('true'), `Expected dragging=true, got: ${el.innerHTML}`)
|
|
935
|
+
assert(captured === 1, `Expected pointer capture with id 1, got ${captured}`)
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
test('pointermove dispatches mutation while captured, ignores elements without data-event', () => {
|
|
939
|
+
const el = new DelegatingElement()
|
|
940
|
+
const dragSpec = {
|
|
941
|
+
route: '/drag-move',
|
|
942
|
+
state: { x: 0 },
|
|
943
|
+
view: (state) => `<div data-event="pointermove:move">${state.x}</div>`,
|
|
944
|
+
mutations: { move: (state, e) => ({ x: e.clientX }) },
|
|
945
|
+
}
|
|
946
|
+
mount(dragSpec, el)
|
|
947
|
+
|
|
948
|
+
const child = new ChildElement({ event: 'pointermove:move' })
|
|
949
|
+
child._root = el
|
|
950
|
+
fireOn(el, child, 'pointermove', { clientX: 42 })
|
|
951
|
+
assert(el.innerHTML.includes('42'), `Expected x=42, got: ${el.innerHTML}`)
|
|
952
|
+
|
|
953
|
+
const plainChild = new ChildElement({})
|
|
954
|
+
plainChild._root = el
|
|
955
|
+
fireOn(el, plainChild, 'pointermove', { clientX: 999 })
|
|
956
|
+
assert(el.innerHTML.includes('42'), `Element without data-event should not dispatch: ${el.innerHTML}`)
|
|
957
|
+
})
|
|
958
|
+
|
|
959
|
+
test('pointerup dispatches mutation and releases capture', () => {
|
|
960
|
+
const el = new DelegatingElement()
|
|
961
|
+
const dragSpec = {
|
|
962
|
+
route: '/drag-up',
|
|
963
|
+
state: { dragging: true },
|
|
964
|
+
view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
|
|
965
|
+
mutations: { endDrag: () => ({ dragging: false }) },
|
|
966
|
+
}
|
|
967
|
+
mount(dragSpec, el)
|
|
968
|
+
|
|
969
|
+
const child = new ChildElement({ event: 'pointerup:endDrag' })
|
|
970
|
+
child._root = el
|
|
971
|
+
let released = null
|
|
972
|
+
child.releasePointerCapture = (id) => { released = id }
|
|
973
|
+
fireOn(el, child, 'pointerup', { pointerId: 7 })
|
|
974
|
+
|
|
975
|
+
assert(el.innerHTML.includes('false'), `Expected dragging=false, got: ${el.innerHTML}`)
|
|
976
|
+
assert(released === 7, `Expected pointer capture released with id 7, got ${released}`)
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
test('pointercancel dispatches the same mutation as pointerup and releases capture', () => {
|
|
980
|
+
const el = new DelegatingElement()
|
|
981
|
+
const dragSpec = {
|
|
982
|
+
route: '/drag-cancel',
|
|
983
|
+
state: { dragging: true },
|
|
984
|
+
view: (state) => `<div data-event="pointerup:endDrag">${state.dragging}</div>`,
|
|
985
|
+
mutations: { endDrag: () => ({ dragging: false }) },
|
|
986
|
+
}
|
|
987
|
+
mount(dragSpec, el)
|
|
988
|
+
|
|
989
|
+
const child = new ChildElement({ event: 'pointerup:endDrag' })
|
|
990
|
+
child._root = el
|
|
991
|
+
let released = null
|
|
992
|
+
child.releasePointerCapture = (id) => { released = id }
|
|
993
|
+
fireOn(el, child, 'pointercancel', { pointerId: 3 })
|
|
994
|
+
|
|
995
|
+
assert(el.innerHTML.includes('false'), `Expected dragging=false after cancel, got: ${el.innerHTML}`)
|
|
996
|
+
assert(released === 3, `Expected pointer capture released on cancel, got ${released}`)
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
test('keydown dispatches mutation with the key', () => {
|
|
1000
|
+
const el = new DelegatingElement()
|
|
1001
|
+
const keySpec = {
|
|
1002
|
+
route: '/key',
|
|
1003
|
+
state: { key: '' },
|
|
1004
|
+
view: (state) => `<div data-event="keydown:onKey">${state.key}</div>`,
|
|
1005
|
+
mutations: { onKey: (state, e) => ({ key: e.key }) },
|
|
1006
|
+
}
|
|
1007
|
+
mount(keySpec, el)
|
|
1008
|
+
|
|
1009
|
+
const child = new ChildElement({ event: 'keydown:onKey' })
|
|
1010
|
+
child._root = el
|
|
1011
|
+
fireOn(el, child, 'keydown', { key: 'ArrowLeft' })
|
|
1012
|
+
|
|
1013
|
+
assert(el.innerHTML.includes('ArrowLeft'), `Expected key=ArrowLeft, got: ${el.innerHTML}`)
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
test('data-event supports multiple space-separated bindings on one element', () => {
|
|
1017
|
+
const el = new DelegatingElement()
|
|
1018
|
+
const dragSpec = {
|
|
1019
|
+
route: '/drag-multi',
|
|
1020
|
+
state: { dragging: false, x: 0 },
|
|
1021
|
+
view: (state) => `<div data-event="pointerdown:startDrag pointermove:dragMove pointerup:endDrag">${state.dragging}:${state.x}</div>`,
|
|
1022
|
+
mutations: {
|
|
1023
|
+
startDrag: () => ({ dragging: true }),
|
|
1024
|
+
dragMove: (state, e) => state.dragging ? { x: e.clientX } : {},
|
|
1025
|
+
endDrag: () => ({ dragging: false }),
|
|
1026
|
+
},
|
|
1027
|
+
}
|
|
1028
|
+
mount(dragSpec, el)
|
|
1029
|
+
|
|
1030
|
+
const child = new ChildElement({ event: 'pointerdown:startDrag pointermove:dragMove pointerup:endDrag' })
|
|
1031
|
+
child._root = el
|
|
1032
|
+
|
|
1033
|
+
fireOn(el, child, 'pointerdown', { pointerId: 1 })
|
|
1034
|
+
assert(el.innerHTML.includes('true:0'), `Expected dragging=true, got: ${el.innerHTML}`)
|
|
1035
|
+
|
|
1036
|
+
fireOn(el, child, 'pointermove', { clientX: 55 })
|
|
1037
|
+
assert(el.innerHTML.includes('true:55'), `Expected x=55 while dragging, got: ${el.innerHTML}`)
|
|
1038
|
+
|
|
1039
|
+
fireOn(el, child, 'pointerup', { pointerId: 1 })
|
|
1040
|
+
assert(el.innerHTML.includes('false:55'), `Expected dragging=false, got: ${el.innerHTML}`)
|
|
1041
|
+
|
|
1042
|
+
// pointermove after release is a no-op because the mutation gates on `dragging`
|
|
1043
|
+
fireOn(el, child, 'pointermove', { clientX: 999 })
|
|
1044
|
+
assert(el.innerHTML.includes('false:55'), `Expected no change after release, got: ${el.innerHTML}`)
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
test('pointerdown/pointerup ignore wrong event type on the same element', () => {
|
|
1048
|
+
const el = new DelegatingElement()
|
|
1049
|
+
const spec = {
|
|
1050
|
+
route: '/only-down',
|
|
1051
|
+
state: { count: 0 },
|
|
1052
|
+
view: (state) => `<div data-event="pointerdown:bump">${state.count}</div>`,
|
|
1053
|
+
mutations: { bump: (state) => ({ count: state.count + 1 }) },
|
|
1054
|
+
}
|
|
1055
|
+
mount(spec, el)
|
|
1056
|
+
|
|
1057
|
+
const child = new ChildElement({ event: 'pointerdown:bump' })
|
|
1058
|
+
child._root = el
|
|
1059
|
+
fireOn(el, child, 'pointerup', { pointerId: 1 }) // wrong type — should not fire
|
|
1060
|
+
assert(el.innerHTML.includes('>0<'), `Should not have dispatched on pointerup: ${el.innerHTML}`)
|
|
1061
|
+
|
|
1062
|
+
fireOn(el, child, 'pointerdown', { pointerId: 1 }) // correct type — should fire
|
|
1063
|
+
assert(el.innerHTML.includes('>1<'), `Expected count 1, got: ${el.innerHTML}`)
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
// ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
869
1068
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
|
870
1069
|
if (failed > 0) process.exit(1)
|
package/src/runtime/ssr.js
CHANGED
|
@@ -365,7 +365,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
365
365
|
: ''
|
|
366
366
|
|
|
367
367
|
const styleLinks = (meta.styles || [])
|
|
368
|
-
.map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i
|
|
368
|
+
.map((href, i) => `<link rel="stylesheet" href="${esc(href)}"${i < 2 ? ' fetchpriority="high"' : ''}>`)
|
|
369
369
|
.join('\n ')
|
|
370
370
|
|
|
371
371
|
// Deferred styles — injected via a nonce'd script so CSP is respected.
|
|
@@ -461,23 +461,30 @@ function mergeStoreKeys(spec, serverState, storeState) {
|
|
|
461
461
|
/**
|
|
462
462
|
* Merge per-request extras set by the server into the view's server state:
|
|
463
463
|
*
|
|
464
|
-
* ctx._form → serverState.form
|
|
464
|
+
* ctx._form → serverState.form — the return value of spec.submit() on a
|
|
465
465
|
* POST re-render (validation errors, submitted values, etc.)
|
|
466
|
-
* ctx._csrfField → serverState.csrf
|
|
466
|
+
* ctx._csrfField → serverState.csrf — ready-to-embed hidden <input> for CSRF;
|
|
467
467
|
* views include it inside <form method="POST"> as ${server.csrf}
|
|
468
|
+
* ctx.nonce → serverState.nonce — the per-request CSP nonce, for views that
|
|
469
|
+
* need to hand-write a <script nonce="${server.nonce}"> tag (e.g.
|
|
470
|
+
* third-party scripts like AdSense/GTM that require an inline
|
|
471
|
+
* snippet the framework itself doesn't generate). ctx.nonce is
|
|
472
|
+
* always set (buildContext defaults it to '' if none was passed),
|
|
473
|
+
* so this key is always present — unlike form/csrf, which are
|
|
474
|
+
* conditional on spec.submit.
|
|
468
475
|
*
|
|
469
|
-
*
|
|
470
|
-
* neither key. Explicit server fetcher keys with the same names win.
|
|
476
|
+
* Explicit server fetcher keys with the same names win over all of the above.
|
|
471
477
|
*
|
|
472
478
|
* @param {Object} serverState
|
|
473
479
|
* @param {Object} ctx
|
|
474
480
|
* @returns {Object}
|
|
475
481
|
*/
|
|
476
482
|
function applyCtxExtras(serverState, ctx) {
|
|
477
|
-
if (ctx?._form === undefined && ctx?._csrfField === undefined) return serverState
|
|
483
|
+
if (ctx?._form === undefined && ctx?._csrfField === undefined && !ctx?.nonce) return serverState
|
|
478
484
|
const extras = {}
|
|
479
485
|
if (ctx._form !== undefined) extras.form = ctx._form
|
|
480
486
|
if (ctx._csrfField !== undefined) extras.csrf = ctx._csrfField
|
|
487
|
+
if (ctx.nonce) extras.nonce = ctx.nonce
|
|
481
488
|
return { ...extras, ...serverState }
|
|
482
489
|
}
|
|
483
490
|
|
package/src/runtime/ssr.test.js
CHANGED
|
@@ -139,6 +139,37 @@ await test('passes ctx to server data fetchers', async () => {
|
|
|
139
139
|
assert(html.includes('Hello Andy'), `Got: ${html}`)
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
+
await test('exposes ctx.nonce to the view as server.nonce', async () => {
|
|
143
|
+
// Regression: ctx.nonce was already available to guard/server fetchers
|
|
144
|
+
// (buildContext sets it directly on ctx), but never reached the view
|
|
145
|
+
// itself — there was no documented way for a hand-written <script> tag
|
|
146
|
+
// (e.g. third-party scripts like AdSense that require an inline snippet
|
|
147
|
+
// the framework doesn't generate) to receive the per-request CSP nonce.
|
|
148
|
+
const spec = {
|
|
149
|
+
route: '/ads',
|
|
150
|
+
state: {},
|
|
151
|
+
view: (s, server) => `<script nonce="${server.nonce}">/* ad init */</script>`,
|
|
152
|
+
}
|
|
153
|
+
const { html } = await renderToString(spec, { nonce: 'abc123' })
|
|
154
|
+
assert(html.includes('nonce="abc123"'), `Got: ${html}`)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
await test('server.nonce is absent (not empty string) when ctx.nonce is unset', async () => {
|
|
158
|
+
const { serverState } = await renderToString(helloSpec, {})
|
|
159
|
+
assert(serverState.nonce === undefined, `Expected no nonce key, got: ${serverState.nonce}`)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
await test('an explicit server.nonce fetcher wins over ctx.nonce', async () => {
|
|
163
|
+
const spec = {
|
|
164
|
+
route: '/custom-nonce',
|
|
165
|
+
state: {},
|
|
166
|
+
server: { nonce: async () => 'explicit-value' },
|
|
167
|
+
view: (s, server) => `<p>${server.nonce}</p>`,
|
|
168
|
+
}
|
|
169
|
+
const { serverState } = await renderToString(spec, { nonce: 'from-ctx' })
|
|
170
|
+
assert(serverState.nonce === 'explicit-value', `Got: ${serverState.nonce}`)
|
|
171
|
+
})
|
|
172
|
+
|
|
142
173
|
await test('returns serverState from resolved fetchers', async () => {
|
|
143
174
|
const { serverState } = await renderToString(serverDataSpec)
|
|
144
175
|
assert(Array.isArray(serverState.products), 'Expected products array')
|