@invisibleloop/pulse 0.7.2 → 0.8.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.
@@ -1 +1 @@
1
- 0.7.0
1
+ 0.7.2
@@ -683,6 +683,7 @@ a:hover {
683
683
  font-weight: 700;
684
684
  letter-spacing: -0.02em;
685
685
  margin: 0.75rem 0 1.25rem;
686
+ text-align: center;
686
687
  }
687
688
 
688
689
  .ai-first-lead {
@@ -691,6 +692,9 @@ a:hover {
691
692
  line-height: 1.75;
692
693
  max-width: 72ch;
693
694
  margin-bottom: 3rem;
695
+ text-align: center;
696
+ margin-left: auto;
697
+ margin-right: auto;
694
698
  }
695
699
 
696
700
  .ai-cols {
@@ -751,10 +755,10 @@ a:hover {
751
755
  }
752
756
 
753
757
  .ai-col-list li::before {
754
- content: "–";
758
+ content: "✓";
755
759
  position: absolute;
756
760
  left: 0;
757
- color: var(--muted-2);
761
+ color: var(--accent);
758
762
  }
759
763
 
760
764
  .ai-col--pulse .ai-col-list li {
@@ -3,62 +3,63 @@
3
3
  * Loaded as an external script so it passes the CSP (no unsafe-inline needed).
4
4
  */
5
5
  (function () {
6
- // ── Sidebar ──────────────────────────────────────────────────────────────
7
- var btn = document.querySelector('.mobile-menu-btn')
8
- var sidebar = document.querySelector('.docs-sidebar')
9
- var overlay = document.querySelector('.sidebar-overlay')
6
+ // ── Scroll active nav item into view ─────────────────────────────────────
10
7
 
11
- if (btn && sidebar) {
12
- function open() {
13
- sidebar.classList.add('open')
14
- overlay && overlay.classList.add('visible')
15
- document.body.style.overflow = 'hidden'
16
- }
8
+ function centerNavLink(sidebar, link) {
9
+ if (!sidebar || !link) return
10
+ var navRect = sidebar.getBoundingClientRect()
11
+ var linkRect = link.getBoundingClientRect()
12
+ sidebar.scrollTop = sidebar.scrollTop + (linkRect.top - navRect.top) - sidebar.clientHeight / 2 + link.clientHeight / 2
13
+ }
17
14
 
18
- function close() {
19
- sidebar.classList.remove('open')
20
- overlay && overlay.classList.remove('visible')
21
- document.body.style.overflow = ''
22
- }
15
+ // ── Bind sidebar, mobile menu, and centre the active link ─────────────────
16
+ // Called on first load and after every client-side navigation, because
17
+ // #pulse-root is replaced on navigate — all element references go stale.
23
18
 
24
- btn.addEventListener('click', function () {
25
- sidebar.classList.contains('open') ? close() : open()
26
- })
19
+ function init() {
20
+ var sidebar = document.querySelector('.docs-sidebar')
21
+ var btn = document.querySelector('.mobile-menu-btn')
22
+ var overlay = document.querySelector('.sidebar-overlay')
27
23
 
28
- overlay && overlay.addEventListener('click', close)
24
+ // Mobile open/close
25
+ if (btn && sidebar) {
26
+ function open() {
27
+ sidebar.classList.add('open')
28
+ overlay && overlay.classList.add('visible')
29
+ document.body.style.overflow = 'hidden'
30
+ }
29
31
 
30
- sidebar.querySelectorAll('.nav-link').forEach(function (link) {
31
- link.addEventListener('click', close)
32
- })
33
- }
32
+ function close() {
33
+ sidebar.classList.remove('open')
34
+ overlay && overlay.classList.remove('visible')
35
+ document.body.style.overflow = ''
36
+ }
34
37
 
35
- // ── Scroll active nav item into view ─────────────────────────────────────
36
- var navSidebar = document.querySelector('.docs-sidebar')
38
+ btn.addEventListener('click', function () {
39
+ sidebar.classList.contains('open') ? close() : open()
40
+ })
37
41
 
38
- function centerNavLink(link) {
39
- if (!navSidebar || !link) return
40
- var navRect = navSidebar.getBoundingClientRect()
41
- var linkRect = link.getBoundingClientRect()
42
- navSidebar.scrollTop = navSidebar.scrollTop + (linkRect.top - navRect.top) - navSidebar.clientHeight / 2 + link.clientHeight / 2
43
- }
42
+ overlay && overlay.addEventListener('click', close)
44
43
 
45
- // On initial load — scroll to the currently active link
46
- var activeLink = navSidebar && navSidebar.querySelector('.nav-link.active')
47
- if (activeLink) {
48
- if (document.readyState === 'complete') {
49
- setTimeout(function () { centerNavLink(activeLink) }, 50)
50
- } else {
51
- window.addEventListener('load', function () { centerNavLink(activeLink) })
44
+ sidebar.querySelectorAll('.nav-link').forEach(function (link) {
45
+ link.addEventListener('click', close)
46
+ })
52
47
  }
48
+
49
+ // Centre the active link
50
+ var activeLink = sidebar && sidebar.querySelector('.nav-link.active')
51
+ if (activeLink) centerNavLink(sidebar, activeLink)
53
52
  }
54
53
 
55
- // After client-side navigation — #pulse-root is replaced, so the original
56
- // navSidebar reference is detached. Re-query for the fresh sidebar element.
57
- document.addEventListener('pulse:navigate', function () {
58
- navSidebar = document.querySelector('.docs-sidebar')
59
- var newActive = navSidebar && navSidebar.querySelector('.nav-link.active')
60
- centerNavLink(newActive)
61
- })
54
+ // On initial load — wait for layout paint before scrolling
55
+ if (document.readyState === 'complete') {
56
+ setTimeout(init, 50)
57
+ } else {
58
+ window.addEventListener('load', init)
59
+ }
60
+
61
+ // After client-side navigation — re-bind everything against the fresh DOM
62
+ document.addEventListener('pulse:navigate', init)
62
63
 
63
64
  // ── Slider fill ──────────────────────────────────────────────────────────
64
65
  function updateSliderFill(input) {
@@ -37,7 +37,7 @@ export default {
37
37
  route: '/',
38
38
  meta: {
39
39
  title: 'Pulse — The spec-first web framework',
40
- description: 'Pulse is a server-first web framework with streaming SSR, zero client JS by default, and Lighthouse 100 built into the architecture. One spec format. One way to build. Production quality by design.',
40
+ description: 'Pulse is a server-first Node.js framework with zero runtime dependencies. One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the architecture.',
41
41
  styles: ['/pulse-ui.css', '/docs.css'],
42
42
  },
43
43
  state: {},
@@ -67,9 +67,9 @@ export default {
67
67
  </svg>
68
68
  </div>
69
69
  <div class="hero-badge">v${version} — EARLY ACCESS</div>
70
- <p class="hero-kicker">A Node.js framework for building server-rendered web apps</p>
71
- <h1 class="hero-title">Describe the outcome. Pulse guarantees it.</h1>
72
- <p class="hero-subtitle">One spec object per page — server data, state, mutations, and view in plain JS. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration.<br><strong>Designed for AI agents.</strong></p>
70
+ <p class="hero-kicker">A server-first Node.js framework. Zero runtime dependencies.</p>
71
+ <h1 class="hero-title">One spec. One way to build. Production quality by design.</h1>
72
+ <p class="hero-subtitle">Each page is a single plain JS object — server data, client state, mutations, and view co-located in one place. Streaming SSR, security headers, and production caching are enforced by the framework, not left to configuration.<br><strong>Designed for AI agents. Simple enough to write yourself.</strong></p>
73
73
  <div class="hero-ctas">
74
74
  <a href="/getting-started" class="btn-primary">Get Started</a>
75
75
  <a href="/spec" class="btn-secondary">Read the Spec</a>
@@ -94,30 +94,30 @@ export default {
94
94
  <div class="home-stat-divider"></div>
95
95
  <div class="home-stat">
96
96
  <span class="home-stat-value">100</span>
97
- <span class="home-stat-label">Lighthouse score</span>
97
+ <span class="home-stat-label">Lighthouse score, by design</span>
98
98
  </div>
99
99
  </section>
100
100
 
101
101
  <section class="how">
102
102
  <div class="how-inner">
103
- <h2 class="section-label">How it works</h2>
103
+ <h2 class="section-label">The idea</h2>
104
104
  <div class="how-steps">
105
105
  <div class="how-step">
106
106
  <div class="how-step-num">1</div>
107
- <h3>Write a spec</h3>
108
- <p>Everything for a page lives in one object: server data, state, mutations, view. One format, no conventions to learn.</p>
107
+ <h3>The spec is the page</h3>
108
+ <p>Everything a page needs lives in one plain JS object — server data, client state, mutations, and view. One format, no split files, no hidden conventions.</p>
109
109
  </div>
110
110
  <div class="how-connector" aria-hidden="true"></div>
111
111
  <div class="how-step">
112
112
  <div class="how-step-num">2</div>
113
- <h3>Validate automatically</h3>
114
- <p>The schema enforces a single, correct structure. Either the spec is valid, or it’s rejected — no ambiguity, no runtime surprises.</p>
113
+ <h3>The schema is the contract</h3>
114
+ <p>Every spec is validated at startup. Either it's correct or it's rejected — no ambiguity, no misconfiguration that surfaces later in production.</p>
115
115
  </div>
116
116
  <div class="how-connector" aria-hidden="true"></div>
117
117
  <div class="how-step">
118
118
  <div class="how-step-num">3</div>
119
- <h3>Production quality, built in</h3>
120
- <p>Streaming SSR, security headers, and immutable caching come from the framework — not your config. Follow the spec, and the results follow.</p>
119
+ <h3>The framework is the guarantee</h3>
120
+ <p>Streaming SSR, security headers, and production caching come from the architecture. You write the product logic. The framework ships the quality.</p>
121
121
  </div>
122
122
  </div>
123
123
  </div>
@@ -126,8 +126,8 @@ export default {
126
126
  <section class="home-code">
127
127
  <div class="home-code-inner">
128
128
  <div class="home-code-header">
129
- <h2>Everything in one object</h2>
130
- <p>Server data, state, mutations, and view are co-located. No split files. No hidden conventions. The spec <strong>is</strong> the page.</p>
129
+ <h2>Everything a page needs. Nothing it doesn't.</h2>
130
+ <p>Server fetchers, client state, mutations, and view are co-located in one object. No split files. No magic exports. The spec <strong>is</strong> the page — readable, predictable, complete.</p>
131
131
  </div>
132
132
  <div class="home-code-block">
133
133
  ${codeWindow({ content: exampleSpec, filename: 'src/pages/dashboard.js', lang: 'JavaScript' })}
@@ -136,26 +136,26 @@ export default {
136
136
  </section>
137
137
 
138
138
  <section class="ai-first">
139
- <div class="section-label">Why Pulse + AI</div>
140
- <h2 class="ai-first-title">Designed for AI agents. Enforced by the framework.</h2>
141
- <p class="ai-first-lead"><strong>Traditional frameworks</strong> were built for humans — multiple valid patterns, optional decisions, enough surface area for output to drift. Pulse is different.</p>
139
+ <div class="section-label">AI + Pulse</div>
140
+ <h2 class="ai-first-title">The constraint is the point. For humans and agents&nbsp;alike.</h2>
141
+ <p class="ai-first-lead">Pulse was designed around a simple observation: when there is only one correct way to build a page, every page is built correctly — whether you wrote it or an AI did. The spec format is the advantage. The schema is the enforcement.</p>
142
142
  <div class="ai-cols">
143
143
  <div class="ai-col">
144
- <h3 class="ai-col-title ai-col-title--bad">AI + existing frameworks</h3>
144
+ <h3 class="ai-col-title">For developers</h3>
145
145
  <ul class="ai-col-list">
146
- <li>Multiple valid patterns per page — the agent picks one, the next picks another.</li>
147
- <li>Security headers, SSR config, and caching are optional decisions the agent can miss.</li>
148
- <li>Output drifts over time as different agents make different choices.</li>
149
- <li>Reviewing AI output requires knowing every pattern it could have used.</li>
146
+ <li>One format to learn — server, state, mutations, view in one place.</li>
147
+ <li>Production quality is the starting point, not a final audit.</li>
148
+ <li>Specs are short, self-contained, and easy to review.</li>
149
+ <li>Nothing to misconfigure — the framework doesn't give you the choice.</li>
150
150
  </ul>
151
151
  </div>
152
152
  <div class="ai-col ai-col--pulse">
153
- <h3 class="ai-col-title ai-col-title--good">Pulse + AI enforces structure</h3>
153
+ <h3 class="ai-col-title ai-col-title--good">For AI agents</h3>
154
154
  <ul class="ai-col-list">
155
- <li>One spec format per page.</li>
156
- <li>Architecture enforces SSR, security, and caching.</li>
157
- <li>Agents fill in the contract, never off-pattern.</li>
158
- <li>Reading AI output means reading one JS object — nothing hidden.</li>
155
+ <li>One valid structure per page — agents can't pick the wrong pattern.</li>
156
+ <li>The schema rejects bad output at startup, not in production.</li>
157
+ <li>Security, SSR, and caching can't be accidentally omitted.</li>
158
+ <li>Consistent output across agents, sessions, and team members.</li>
159
159
  </ul>
160
160
  </div>
161
161
  </div>
@@ -164,7 +164,7 @@ export default {
164
164
  <section class="versus">
165
165
  <div class="section-label">How Pulse compares</div>
166
166
  <h2 class="versus-title">Constraints enforced. Not recommended.</h2>
167
- <p class="versus-sub">Pulse enforces constraints and correctness out of the box; other frameworks leave it to the developer.</p>
167
+ <p class="versus-sub">Pulse enforces correctness out of the box. Other frameworks leave production quality to the developer.</p>
168
168
  <div class="versus-table-wrap table-sticky-col">
169
169
  <table class="versus-table">
170
170
  <thead>
@@ -195,13 +195,13 @@ export default {
195
195
  <tr>
196
196
  <th scope="row">SSR out of the box</th>
197
197
  <td class="v-yes">Streaming SSR, zero config</td>
198
- <td class="v-partial">Yes, but client hydration adds JS overhead on every page</td>
198
+ <td class="v-partial">Yes, but client hydration adds JS on every page</td>
199
199
  <td class="v-partial">Yes, but requires an adapter and client runtime on every page</td>
200
200
  </tr>
201
201
  <tr>
202
202
  <th scope="row">Client JS shipped</th>
203
- <td class="v-yes">~4 kB brotli (shared runtime, first visit)</td>
204
- <td class="v-no">50-200 kB+ depending on features</td>
203
+ <td class="v-yes">~4 kB brotli on first visit; 0 kB on static pages</td>
204
+ <td class="v-no">50–200 kB+ depending on features used</td>
205
205
  <td class="v-partial">~15 kB brotli</td>
206
206
  </tr>
207
207
  <tr>
@@ -224,7 +224,7 @@ export default {
224
224
  </tr>
225
225
  <tr>
226
226
  <th scope="row">Production build step</th>
227
- <td class="v-yes">None — <code>node server.js</code> is production</td>
227
+ <td class="v-yes">Server needs none — <code>node server.js</code> is production. Client bundles are optional.</td>
228
228
  <td class="v-no">Required — <code>next build</code></td>
229
229
  <td class="v-no">Required — <code>vite build</code></td>
230
230
  </tr>
@@ -243,24 +243,24 @@ export default {
243
243
  </svg>
244
244
  </div>
245
245
  <h2>Performance by design</h2>
246
- <p>Pulse does not offer performance as an option — it enforces it structurally. A high Lighthouse score is the baseline. There is nothing to configure because there is nothing to get wrong.</p>
246
+ <p>Performance is not a Pulse feature — it is a structural outcome. A high Lighthouse score is the baseline. There is nothing to configure because the architecture makes the right choices automatically.</p>
247
247
  </div>
248
248
  <ul class="usp-points">
249
249
  <li>
250
250
  <strong>Fast LCP by design.</strong>
251
- The shell renders and streams instantly. Deferred segments arrive as data resolves — no blocking, no flash.
251
+ The shell streams to the browser instantly. Deferred segments arrive as data resolves — no blocking, no flash, no placeholder juggling.
252
252
  </li>
253
253
  <li>
254
254
  <strong>~4 kB of JS on first visit.</strong>
255
- The shared runtime is brotli-compressed and cached across all navigations. Subsequent pages cost 0.4–0.9 kB.
255
+ The shared runtime is brotli-compressed and cached across all navigations. Subsequent pages cost 0.4–0.9 kB. Static pages ship zero JS.
256
256
  </li>
257
257
  <li>
258
258
  <strong>Zero CLS.</strong>
259
- The shell occupies the correct layout before data arrives. No placeholder juggling, no layout shift.
259
+ The shell occupies the correct layout before data arrives. No layout shift because there is no placeholder to replace.
260
260
  </li>
261
261
  <li>
262
262
  <strong>Immutable bundle caching.</strong>
263
- Production bundles are content-hashed and served with <code>immutable, max-age=31536000</code>. Browsers cache them forever — deploys are instant for returning visitors.
263
+ Production bundles are content-hashed and served with <code>immutable, max-age=31536000</code>. Returning visitors pay no JS cost on deploy.
264
264
  </li>
265
265
  </ul>
266
266
  </div>
@@ -274,20 +274,20 @@ export default {
274
274
  </svg>
275
275
  </div>
276
276
  <h2>Safe by design</h2>
277
- <p>Security is not a plugin or a checklist in Pulse — it is part of the response pipeline. Every page ships the headers most frameworks leave to the developer to remember.</p>
277
+ <p>Security is not a plugin in Pulse — it is part of the response pipeline. Every response ships the headers most frameworks leave to the developer to remember to add.</p>
278
278
  </div>
279
279
  <ul class="usp-points">
280
280
  <li>
281
281
  <strong>Security headers on every response.</strong>
282
- <code>X-Frame-Options</code>, <code>X-Content-Type-Options</code>, <code>Referrer-Policy</code>, <code>Permissions-Policy</code>, <code>Cross-Origin-Opener-Policy</code> — all set automatically, including on 404 and 500 pages.
282
+ <code>X-Frame-Options</code>, <code>X-Content-Type-Options</code>, <code>Referrer-Policy</code>, <code>Permissions-Policy</code>, <code>Cross-Origin-Opener-Policy</code> — set automatically, including on 404 and 500 pages.
283
283
  </li>
284
284
  <li>
285
285
  <strong>Declarative state constraints.</strong>
286
- <code>constraints</code> enforce min/max bounds on state after every mutation. The value can never go out of range regardless of what the client sends.
286
+ <code>constraints</code> enforce min/max bounds on state after every mutation. Values can never go out of range regardless of what the client sends.
287
287
  </li>
288
288
  <li>
289
289
  <strong>Co-located validation.</strong>
290
- Validation rules live next to the state they guard. The agent can see what is being validated and why, in one place.
290
+ Validation rules live next to the state they guard. Easy to read, easy to review, impossible to misplace.
291
291
  </li>
292
292
  <li>
293
293
  <strong>Guard before data.</strong>
@@ -314,16 +314,16 @@ export default {
314
314
  The server is pure Node.js HTTP. No Express, no Fastify, no React. Nothing to add to <code>package.json</code> to run a production server.
315
315
  </li>
316
316
  <li>
317
- <strong>No production build step.</strong>
318
- <code>node server.js</code> is production. The build step is only needed to generate content-hashed client bundles — the server runs without it in dev.
317
+ <strong>No server build step.</strong>
318
+ <code>node server.js</code> is production. The optional build step generates content-hashed client bundles — the server runs without it.
319
319
  </li>
320
320
  <li>
321
321
  <strong>esbuild only in development.</strong>
322
- The one dev dependency that compiles client bundles is esbuild. Fast, no plugins to configure, never part of the production runtime.
322
+ The one dev dependency that compiles client bundles is esbuild. Fast, zero plugins to configure, never part of the production runtime.
323
323
  </li>
324
324
  <li>
325
- <strong>No framework upgrades breaking your app.</strong>
326
- Because the spec is a plain JS object with no framework imports in page files, there is no framework API surface to break across versions.
325
+ <strong>No breaking upgrades.</strong>
326
+ Page files have no framework imports. The spec is a plain JS object. There is no framework API surface in your code to break across versions.
327
327
  </li>
328
328
  </ul>
329
329
  </div>
@@ -381,15 +381,15 @@ export default {
381
381
  </section>` : ''}
382
382
 
383
383
  <section class="home-cta">
384
- <h2>The spec is the contract.<br>Your agent fills it in.</h2>
384
+ <h2>One format. Every page. Production ready.</h2>
385
385
  <ul class="home-cta-checks">
386
- <li>Writes the spec</li>
387
- <li>Validates against the schema</li>
388
- <li>Checks Lighthouse — desktop and mobile</li>
389
- <li>Runs the tests</li>
390
- <li>Ships production quality</li>
386
+ <li>One spec object per page — always valid, always readable</li>
387
+ <li>Streaming SSR and security headers, zero configuration</li>
388
+ <li>100 Lighthouse built into the architecture</li>
389
+ <li>Works with AI agents — and without them</li>
390
+ <li>MIT licensed, zero runtime dependencies</li>
391
391
  </ul>
392
- <p>MIT licensed and available now. Production quality is not the goal. It is the starting point.</p>
392
+ <p>Pulse is in early access. The goal is not to compete on features — it is to eliminate the class of problems that come from having too many of them.</p>
393
393
  <div class="home-cta-actions">
394
394
  <a href="/getting-started" class="btn-primary">Get Started</a>
395
395
  <a href="/spec" class="btn-secondary">Read the Spec</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@
57
57
  "scripts": {
58
58
  "dev": "node --watch src/cli/dev.js",
59
59
  "build": "node scripts/build.js",
60
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
60
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js",
61
61
  "typecheck": "tsc --noEmit",
62
62
  "docs": "node --watch src/cli/dev.js --root docs --port 4000",
63
63
  "docs:start": "node docs/server.js"
@@ -1 +1 @@
1
- 0.7.2
1
+ 0.8.0
@@ -104,10 +104,13 @@ export function initNavigation(root, mountFn) {
104
104
  document.dispatchEvent(new CustomEvent('pulse:navigate'))
105
105
 
106
106
  if (hydratePath && mountFn) {
107
+ const { default: spec } = await import(/* @vite-ignore */ hydratePath)
108
+ // A newer navigation may have started while the import was in flight —
109
+ // bail out rather than destroying the already-mounted next page.
110
+ if (controller.signal.aborted) return
107
111
  currentMount?.destroy()
108
112
  root.dataset.pulseMounted = '1'
109
113
  window.__PULSE_SERVER__ = finalServerState
110
- const { default: spec } = await import(/* @vite-ignore */ hydratePath)
111
114
  if (spec) currentMount = mountFn(spec, root, finalServerState)
112
115
  }
113
116
 
@@ -130,10 +133,11 @@ export function initNavigation(root, mountFn) {
130
133
  if (push) history.pushState({ pulse: true, path }, '', path)
131
134
 
132
135
  if (hydrate && mountFn) {
136
+ const { default: spec } = await import(/* @vite-ignore */ hydrate)
137
+ if (controller.signal.aborted) return
133
138
  currentMount?.destroy()
134
139
  root.dataset.pulseMounted = '1'
135
140
  window.__PULSE_SERVER__ = serverState || {}
136
- const { default: spec } = await import(/* @vite-ignore */ hydrate)
137
141
  if (spec) currentMount = mountFn(spec, root, serverState || {})
138
142
  }
139
143
 
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Pulse — Navigate tests
3
+ * run: node src/runtime/navigate.test.js
4
+ */
5
+
6
+ import { initNavigation } from './navigate.js'
7
+ import assert from 'node:assert'
8
+
9
+ // ── Test runner ───────────────────────────────────────────────────────────────
10
+
11
+ let passed = 0
12
+ let failed = 0
13
+
14
+ async function test(label, fn) {
15
+ try {
16
+ await fn()
17
+ console.log(` ✓ ${label}`)
18
+ passed++
19
+ } catch (e) {
20
+ console.log(` ✗ ${label}`)
21
+ console.log(` ${e.message}`)
22
+ failed++
23
+ }
24
+ }
25
+
26
+ // ── Minimal browser shims ─────────────────────────────────────────────────────
27
+
28
+ function makeRoot() {
29
+ const root = {
30
+ innerHTML: '',
31
+ dataset: {},
32
+ querySelector: () => null,
33
+ querySelectorAll: () => [],
34
+ hasAttribute: (k) => root._attrs.has(k),
35
+ setAttribute: (k) => root._attrs.add(k),
36
+ removeAttribute: (k) => root._attrs.delete(k),
37
+ addEventListener: () => {},
38
+ focus: () => {},
39
+ _attrs: new Set(),
40
+ }
41
+ return root
42
+ }
43
+
44
+ /**
45
+ * Set up fresh browser globals and call initNavigation().
46
+ * Returns helpers to fire the registered click and popstate handlers.
47
+ */
48
+ function setup({ mountFn = null } = {}) {
49
+ let clickHandler = null
50
+ let popHandler = null
51
+
52
+ global.history = { replaceState: () => {}, pushState: () => {} }
53
+ global.location = { pathname: '/start', search: '', origin: 'http://localhost', href: '' }
54
+ global.window = {
55
+ scrollTo: () => {},
56
+ addEventListener: (e, fn) => { if (e === 'popstate') popHandler = fn },
57
+ }
58
+ global.document = {
59
+ title: 'Test',
60
+ addEventListener: (e, fn) => { if (e === 'click') clickHandler = fn },
61
+ dispatchEvent: () => {},
62
+ querySelectorAll: () => [],
63
+ createElement: () => ({
64
+ rel: '', href: '', src: '', textContent: '', type: '',
65
+ setAttribute: () => {},
66
+ getAttribute: () => null,
67
+ onload: null, onerror: null,
68
+ }),
69
+ head: { appendChild: () => {} },
70
+ }
71
+ global.CustomEvent = class { constructor(n) { this.type = n } }
72
+
73
+ const root = makeRoot()
74
+ initNavigation(root, mountFn)
75
+
76
+ return {
77
+ root,
78
+ click: (ev) => clickHandler(ev),
79
+ pop: (ev) => popHandler(ev),
80
+ }
81
+ }
82
+
83
+ /** Build a synthetic click event targeting an anchor. */
84
+ function clickEvent({ href, ctrlKey = false, metaKey = false, shiftKey = false, altKey = false, linkTarget = '' }) {
85
+ const anchor = {
86
+ getAttribute: (k) => k === 'href' ? (href ?? null) : null,
87
+ target: linkTarget,
88
+ }
89
+ return {
90
+ target: { closest: (sel) => sel === 'a' ? anchor : null },
91
+ ctrlKey, metaKey, shiftKey, altKey,
92
+ preventDefault: () => {},
93
+ }
94
+ }
95
+
96
+ /** Event where target has no ancestor <a>. */
97
+ function nonAnchorEvent() {
98
+ return {
99
+ target: { closest: () => null },
100
+ ctrlKey: false, metaKey: false, shiftKey: false, altKey: false,
101
+ preventDefault: () => {},
102
+ }
103
+ }
104
+
105
+ // ── Click handler — link filtering ───────────────────────────────────────────
106
+
107
+ console.log('\nClick handler — link filtering\n')
108
+
109
+ await test('does not intercept clicks with no ancestor anchor', async () => {
110
+ const { click } = setup()
111
+ let called = false
112
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
113
+ click(nonAnchorEvent())
114
+ assert(!called)
115
+ })
116
+
117
+ await test('does not intercept anchor with no href', async () => {
118
+ const { click } = setup()
119
+ let called = false
120
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
121
+ click(clickEvent({ href: null }))
122
+ assert(!called)
123
+ })
124
+
125
+ await test('does not intercept cross-origin links', async () => {
126
+ const { click } = setup()
127
+ let called = false
128
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
129
+ click(clickEvent({ href: 'https://example.com/page' }))
130
+ assert(!called)
131
+ })
132
+
133
+ await test('does not intercept ctrl+click', async () => {
134
+ const { click } = setup()
135
+ let called = false
136
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
137
+ click(clickEvent({ href: '/page', ctrlKey: true }))
138
+ assert(!called)
139
+ })
140
+
141
+ await test('does not intercept meta+click', async () => {
142
+ const { click } = setup()
143
+ let called = false
144
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
145
+ click(clickEvent({ href: '/page', metaKey: true }))
146
+ assert(!called)
147
+ })
148
+
149
+ await test('does not intercept shift+click', async () => {
150
+ const { click } = setup()
151
+ let called = false
152
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
153
+ click(clickEvent({ href: '/page', shiftKey: true }))
154
+ assert(!called)
155
+ })
156
+
157
+ await test('does not intercept target=_blank links', async () => {
158
+ const { click } = setup()
159
+ let called = false
160
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
161
+ click(clickEvent({ href: '/page', linkTarget: '_blank' }))
162
+ assert(!called)
163
+ })
164
+
165
+ await test('intercepts same-origin links', async () => {
166
+ const { click } = setup()
167
+ let called = false
168
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
169
+ click(clickEvent({ href: '/same-origin' }))
170
+ assert(called, 'fetch should be called for same-origin link')
171
+ })
172
+
173
+ // ── Navigation — fetch abort and cancellation ────────────────────────────────
174
+
175
+ console.log('\nNavigation — fetch abort and cancellation\n')
176
+
177
+ await test('aborts in-flight fetch when a new navigation starts', async () => {
178
+ const { click } = setup()
179
+ const signals = []
180
+ global.fetch = async (_url, opts) => {
181
+ signals.push(opts.signal)
182
+ return new Promise(() => {}) // never resolves
183
+ }
184
+
185
+ click(clickEvent({ href: '/page-a' }))
186
+ click(clickEvent({ href: '/page-b' }))
187
+
188
+ assert.equal(signals.length, 2, 'two fetches made')
189
+ assert(signals[0].aborted, 'first navigation signal should be aborted')
190
+ assert(!signals[1].aborted, 'second navigation signal should not be aborted')
191
+ })
192
+
193
+ await test('swallows AbortError — does not fall back to location.href', async () => {
194
+ const { click } = setup()
195
+ global.fetch = async () => {
196
+ const err = new Error('aborted')
197
+ err.name = 'AbortError'
198
+ throw err
199
+ }
200
+ let fallbackSet = false
201
+ Object.defineProperty(global.location, 'href', {
202
+ set: () => { fallbackSet = true },
203
+ get: () => '',
204
+ configurable: true,
205
+ })
206
+ click(clickEvent({ href: '/page' }))
207
+ await new Promise(r => setTimeout(r, 10))
208
+ assert(!fallbackSet, 'AbortError must not trigger location.href fallback')
209
+ })
210
+
211
+ await test('falls back to location.href on network error', async () => {
212
+ const { click } = setup()
213
+ global.fetch = async () => { throw new Error('network failure') }
214
+ let fallback = null
215
+ Object.defineProperty(global.location, 'href', {
216
+ set: (v) => { fallback = v },
217
+ get: () => '',
218
+ configurable: true,
219
+ })
220
+ click(clickEvent({ href: '/broken' }))
221
+ await new Promise(r => setTimeout(r, 10))
222
+ assert.equal(fallback, '/broken')
223
+ })
224
+
225
+ await test('falls back to location.href on non-ok response', async () => {
226
+ const { click } = setup()
227
+ global.fetch = async () => ({ ok: false })
228
+ let fallback = null
229
+ Object.defineProperty(global.location, 'href', {
230
+ set: (v) => { fallback = v },
231
+ get: () => '',
232
+ configurable: true,
233
+ })
234
+ click(clickEvent({ href: '/missing' }))
235
+ await new Promise(r => setTimeout(r, 10))
236
+ assert.equal(fallback, '/missing')
237
+ })
238
+
239
+ // ── Navigation — JSON response ───────────────────────────────────────────────
240
+
241
+ console.log('\nNavigation — JSON response\n')
242
+
243
+ function jsonFetch(payload) {
244
+ return async () => ({
245
+ ok: true,
246
+ headers: { get: () => 'application/json' },
247
+ json: async () => payload,
248
+ })
249
+ }
250
+
251
+ await test('sets root.innerHTML from JSON html field', async () => {
252
+ const { click, root } = setup()
253
+ global.fetch = jsonFetch({ html: '<p>Hello</p>', title: 'T', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
254
+ click(clickEvent({ href: '/new' }))
255
+ await new Promise(r => setTimeout(r, 10))
256
+ assert.equal(root.innerHTML, '<p>Hello</p>')
257
+ })
258
+
259
+ await test('updates document.title from JSON title field', async () => {
260
+ const { click } = setup()
261
+ global.fetch = jsonFetch({ html: '', title: 'New Title', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: null })
262
+ click(clickEvent({ href: '/titled' }))
263
+ await new Promise(r => setTimeout(r, 10))
264
+ assert.equal(global.document.title, 'New Title')
265
+ })
266
+
267
+ await test('updates store state when storeState is present', async () => {
268
+ const { click } = setup()
269
+ global.fetch = jsonFetch({ html: '', title: '', styles: [], scripts: [], hydrate: null, serverState: {}, storeState: { theme: 'dark' } })
270
+ let stored = null
271
+ global.window.__updatePulseStore__ = (s) => { stored = s }
272
+ click(clickEvent({ href: '/store-page' }))
273
+ await new Promise(r => setTimeout(r, 10))
274
+ assert.deepEqual(stored, { theme: 'dark' })
275
+ })
276
+
277
+ // ── Navigation — popstate ────────────────────────────────────────────────────
278
+
279
+ console.log('\nNavigation — popstate\n')
280
+
281
+ await test('navigates on pulse popstate', async () => {
282
+ const { pop } = setup()
283
+ let fetchedPath = null
284
+ global.fetch = async (path) => { fetchedPath = path; return new Promise(() => {}) }
285
+ global.location.pathname = '/history-page'
286
+ global.location.search = ''
287
+ pop({ state: { pulse: true } })
288
+ await new Promise(r => setTimeout(r, 0))
289
+ assert.equal(fetchedPath, '/history-page')
290
+ })
291
+
292
+ await test('ignores popstate without pulse state', async () => {
293
+ const { pop } = setup()
294
+ let called = false
295
+ global.fetch = async () => { called = true; return new Promise(() => {}) }
296
+ pop({ state: null })
297
+ pop({ state: { pulse: false } })
298
+ assert(!called, 'fetch should not be called for non-pulse popstate')
299
+ })
300
+
301
+ // ── Summary ───────────────────────────────────────────────────────────────────
302
+
303
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
304
+ if (failed > 0) process.exit(1)