@invisibleloop/pulse 0.7.2 → 0.7.3

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
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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.7.3
@@ -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)