fix(web): keep MR form drafts and the live stream across refreshes
A comment form whose radios, inputs or selects differ from their defaults is a draft, and Cancel resets it. The replacement live stream resumes after the last event id through an after= parameter, and the expected AbortError of the old stream's teardown is no longer reported.
- commit
- e688ec5af2b64e91699cffee434676248d2dc2ff
- author
- brice · · 2026-10-20 06:09 UTC
- parent
- cff892b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +145 -16
M internal/adapters/web/events.go +8 -2
| @@ -44,7 +44,9 @@ import ( | |||
| 44 | 44 | // lists first: the banner counts the commits between that head and the new | |
| 45 | 45 | // tip, so it stays right however many pushes came in and however often the | |
| 46 | 46 | // stream reconnected. A Last-Event-ID header resumes the stream | |
| 47 | - | // (resume_after). | |
| 47 | + | // (resume_after). With no header, after=<event id> supplies the resume cursor | |
| 48 | + | // for a replacement stream. The header wins. Both use Watch's parseID | |
| 49 | + | // validation: an invalid or expired cursor produces a gap. | |
| 48 | 50 | // | |
| 49 | 51 | // Events, each with the id of the Watch event it comes from: | |
| 50 | 52 | // | |
| @@ -188,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 188 | 190 | var startOnce sync.Once | |
| 189 | 191 | start := func() { startOnce.Do(func() { close(started) }) } | |
| 190 | 192 | finished := make(chan struct{}) | |
| 193 | + | resume := r.Header.Get("Last-Event-ID") | |
| 194 | + | if resume == "" { | |
| 195 | + | resume = q.Get("after") | |
| 196 | + | } | |
| 191 | 197 | params := ports.WatchParams{ | |
| 192 | - | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | |
| 198 | + | Topics: []string{topic}, ResumeAfter: resume, | |
| 193 | 199 | // The visitor is the client of the watch, for the limit per client. | |
| 194 | 200 | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | |
| 195 | 201 | Ready: start, | |
| @@ -44,7 +44,9 @@ import ( | |||||
| 44 | // lists first: the banner counts the commits between that head and the new | 44 | // lists first: the banner counts the commits between that head and the new | ||
| 45 | // tip, so it stays right however many pushes came in and however often the | 45 | // tip, so it stays right however many pushes came in and however often the | ||
| 46 | // stream reconnected. A Last-Event-ID header resumes the stream | 46 | // stream reconnected. A Last-Event-ID header resumes the stream | ||
| 47 | - | // (resume_after). | 47 | + | // (resume_after). With no header, after=<event id> supplies the resume cursor |
| 48 | + | // for a replacement stream. The header wins. Both use Watch's parseID | |||
| 49 | + | // validation: an invalid or expired cursor produces a gap. | |||
| 48 | // | 50 | // | ||
| 49 | // Events, each with the id of the Watch event it comes from: | 51 | // Events, each with the id of the Watch event it comes from: | ||
| 50 | // | 52 | // | ||
| @@ -188,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 188 | var startOnce sync.Once | 190 | var startOnce sync.Once | ||
| 189 | start := func() { startOnce.Do(func() { close(started) }) } | 191 | start := func() { startOnce.Do(func() { close(started) }) } | ||
| 190 | finished := make(chan struct{}) | 192 | finished := make(chan struct{}) | ||
| 193 | + | resume := r.Header.Get("Last-Event-ID") | |||
| 194 | + | if resume == "" { | |||
| 195 | + | resume = q.Get("after") | |||
| 196 | + | } | |||
| 191 | params := ports.WatchParams{ | 197 | params := ports.WatchParams{ | ||
| 192 | - | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | 198 | + | Topics: []string{topic}, ResumeAfter: resume, |
| 193 | // The visitor is the client of the watch, for the limit per client. | 199 | // The visitor is the client of the watch, for the limit per client. | ||
| 194 | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | 200 | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | ||
| 195 | Ready: start, | 201 | Ready: start, | ||
M internal/adapters/web/jstest/live.test.mjs +39 -2
| @@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = ')); | |||
| 12 | 12 | function setup(t) { | |
| 13 | 13 | t.mock.timers.enable({ apis: ['setTimeout'] }); | |
| 14 | 14 | const hint = { dataset: { state: 'connecting' } }; | |
| 15 | - | globalThis.window = {}; | |
| 15 | + | const listeners = {}; | |
| 16 | + | globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } }; | |
| 16 | 17 | globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) }; | |
| 17 | 18 | new Function(live)(); | |
| 18 | - | return { live: globalThis.window.ermiteLive, hint }; | |
| 19 | + | return { live: globalThis.window.ermiteLive, hint, listeners }; | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | test('a drop shorter than the delay says nothing', (t) => { | |
| @@ -38,3 +39,39 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => { | |||
| 38 | 39 | live.back(); | |
| 39 | 40 | assert.equal(hint.dataset.state, 'live'); | |
| 40 | 41 | }); | |
| 42 | + | ||
| 43 | + | test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => { | |
| 44 | + | const { live, listeners } = setup(t); | |
| 45 | + | const rejection = (name) => { | |
| 46 | + | const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } }; | |
| 47 | + | listeners.unhandledrejection(event); | |
| 48 | + | return event.prevented; | |
| 49 | + | }; | |
| 50 | + | assert.equal(rejection('AbortError'), false); | |
| 51 | + | await live.teardown(async () => { | |
| 52 | + | assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap'); | |
| 53 | + | assert.equal(rejection('TypeError'), false); | |
| 54 | + | await Promise.resolve(); | |
| 55 | + | }); | |
| 56 | + | assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered'); | |
| 57 | + | t.mock.timers.tick(1); | |
| 58 | + | assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible'); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test('replacement stream URL carries the last connection id, including id-only events', (t) => { | |
| 62 | + | const { live, listeners } = setup(t); | |
| 63 | + | const connection = { lastEventId: 'boot-7' }; | |
| 64 | + | listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } }); | |
| 65 | + | connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events. | |
| 66 | + | const saved = globalThis.location; | |
| 67 | + | globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' }; | |
| 68 | + | const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; }, | |
| 69 | + | setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } }; | |
| 70 | + | try { | |
| 71 | + | live.resumeStream(element); | |
| 72 | + | const url = new URL(element.url, location.href); | |
| 73 | + | assert.equal(url.searchParams.get('after'), 'boot-8'); | |
| 74 | + | assert.equal(url.searchParams.get('head'), 'new'); | |
| 75 | + | assert.equal(url.searchParams.get('topic'), 'repo:demo'); | |
| 76 | + | } finally { globalThis.location = saved; } | |
| 77 | + | }); | |
| @@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = ')); | |||||
| 12 | function setup(t) { | 12 | function setup(t) { | ||
| 13 | t.mock.timers.enable({ apis: ['setTimeout'] }); | 13 | t.mock.timers.enable({ apis: ['setTimeout'] }); | ||
| 14 | const hint = { dataset: { state: 'connecting' } }; | 14 | const hint = { dataset: { state: 'connecting' } }; | ||
| 15 | - | globalThis.window = {}; | 15 | + | const listeners = {}; |
| 16 | + | globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } }; | |||
| 16 | globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) }; | 17 | globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) }; | ||
| 17 | new Function(live)(); | 18 | new Function(live)(); | ||
| 18 | - | return { live: globalThis.window.ermiteLive, hint }; | 19 | + | return { live: globalThis.window.ermiteLive, hint, listeners }; |
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | test('a drop shorter than the delay says nothing', (t) => { | 22 | test('a drop shorter than the delay says nothing', (t) => { | ||
| @@ -38,3 +39,39 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => { | |||||
| 38 | live.back(); | 39 | live.back(); | ||
| 39 | assert.equal(hint.dataset.state, 'live'); | 40 | assert.equal(hint.dataset.state, 'live'); | ||
| 40 | }); | 41 | }); | ||
| 42 | + | ||||
| 43 | + | test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => { | |||
| 44 | + | const { live, listeners } = setup(t); | |||
| 45 | + | const rejection = (name) => { | |||
| 46 | + | const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } }; | |||
| 47 | + | listeners.unhandledrejection(event); | |||
| 48 | + | return event.prevented; | |||
| 49 | + | }; | |||
| 50 | + | assert.equal(rejection('AbortError'), false); | |||
| 51 | + | await live.teardown(async () => { | |||
| 52 | + | assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap'); | |||
| 53 | + | assert.equal(rejection('TypeError'), false); | |||
| 54 | + | await Promise.resolve(); | |||
| 55 | + | }); | |||
| 56 | + | assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered'); | |||
| 57 | + | t.mock.timers.tick(1); | |||
| 58 | + | assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible'); | |||
| 59 | + | }); | |||
| 60 | + | ||||
| 61 | + | test('replacement stream URL carries the last connection id, including id-only events', (t) => { | |||
| 62 | + | const { live, listeners } = setup(t); | |||
| 63 | + | const connection = { lastEventId: 'boot-7' }; | |||
| 64 | + | listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } }); | |||
| 65 | + | connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events. | |||
| 66 | + | const saved = globalThis.location; | |||
| 67 | + | globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' }; | |||
| 68 | + | const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; }, | |||
| 69 | + | setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } }; | |||
| 70 | + | try { | |||
| 71 | + | live.resumeStream(element); | |||
| 72 | + | const url = new URL(element.url, location.href); | |||
| 73 | + | assert.equal(url.searchParams.get('after'), 'boot-8'); | |||
| 74 | + | assert.equal(url.searchParams.get('head'), 'new'); | |||
| 75 | + | assert.equal(url.searchParams.get('topic'), 'repo:demo'); | |||
| 76 | + | } finally { globalThis.location = saved; } | |||
| 77 | + | }); | |||
M internal/adapters/web/jstest/mr.test.mjs +48 -4
| @@ -346,15 +346,17 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in | |||
| 346 | 346 | } finally { c.restore(); } | |
| 347 | 347 | }); | |
| 348 | 348 | ||
| 349 | - | test('typing during fetch defers even manual refresh until the draft ends', async () => { | |
| 350 | - | const c = clock(); let blocked = false, applied = 0, resolve; const notices = []; | |
| 349 | + | test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => { | |
| 350 | + | const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = []; | |
| 351 | 351 | const r = window.ermiteMR.refresher({ blocked: () => blocked, | |
| 352 | - | fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; }, | |
| 352 | + | fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; }, | |
| 353 | 353 | notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') }); | |
| 354 | 354 | try { | |
| 355 | 355 | r.request(); const first = c.tick(); blocked = true; resolve('page'); await first; | |
| 356 | 356 | assert.equal(applied, 0); assert.deepEqual(notices, [true]); | |
| 357 | - | r.request(true); await c.tick(); assert.equal(applied, 0); | |
| 357 | + | r.request(true); const manual = c.tick(); | |
| 358 | + | assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft'); | |
| 359 | + | await manual; assert.equal(applied, 0); | |
| 358 | 360 | blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second; | |
| 359 | 361 | assert.equal(applied, 1); assert.equal(notices.at(-1), false); | |
| 360 | 362 | } finally { c.restore(); } | |
| @@ -453,3 +455,45 @@ test('anchor restoration retains the server encoding of tilde and star paths', ( | |||
| 453 | 455 | } | |
| 454 | 456 | } finally { Object.assign(globalThis, saved); } | |
| 455 | 457 | }); | |
| 458 | + | ||
| 459 | + | for (const control of [ | |
| 460 | + | { type: 'radio', checked: true, defaultChecked: false }, | |
| 461 | + | { type: 'checkbox', checked: false, defaultChecked: true }, | |
| 462 | + | { type: 'number', name: 'start_line', value: '10', defaultValue: '12' }, | |
| 463 | + | { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] }, | |
| 464 | + | ]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => { | |
| 465 | + | const saved = document; | |
| 466 | + | try { | |
| 467 | + | globalThis.document = { activeElement: null, querySelectorAll(sel) { | |
| 468 | + | assert.equal(sel, ':is(form.cform, .cmt form) :is(textarea, input, select)'); | |
| 469 | + | return [control]; | |
| 470 | + | } }; | |
| 471 | + | assert.equal(window.ermiteMR.hasDraft(), true); | |
| 472 | + | } finally { globalThis.document = saved; } | |
| 473 | + | }); | |
| 474 | + | ||
| 475 | + | test('intercepted Cancel resets the entire comment form', () => { | |
| 476 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]])); | |
| 477 | + | const c = clock(), handlers = {}; | |
| 478 | + | const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } }; | |
| 479 | + | const radio = { type: 'radio', checked: true, defaultChecked: false }; | |
| 480 | + | const line = { type: 'number', value: '2', defaultValue: '4' }; | |
| 481 | + | let reset = 0, prevented = false; | |
| 482 | + | const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } }; | |
| 483 | + | const cancel = { closest: () => form }; | |
| 484 | + | globalThis.document = { activeElement: null, | |
| 485 | + | querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null, | |
| 486 | + | querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [], | |
| 487 | + | addEventListener: (type, fn) => { handlers[type] = fn; } }; | |
| 488 | + | globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} }; | |
| 489 | + | globalThis.addEventListener = () => {}; | |
| 490 | + | globalThis.matchMedia = () => ({ matches: true, addEventListener() {} }); | |
| 491 | + | try { | |
| 492 | + | window.ermiteMR.start(); | |
| 493 | + | window.ermiteMR.headChanged(); | |
| 494 | + | handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } }); | |
| 495 | + | assert.equal(prevented, true); | |
| 496 | + | assert.equal(reset, 1); | |
| 497 | + | assert.equal(window.ermiteMR.hasDraft(), false); | |
| 498 | + | } finally { Object.assign(globalThis, saved); c.restore(); } | |
| 499 | + | }); | |
| @@ -346,15 +346,17 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in | |||||
| 346 | } finally { c.restore(); } | 346 | } finally { c.restore(); } | ||
| 347 | }); | 347 | }); | ||
| 348 | 348 | ||||
| 349 | - | test('typing during fetch defers even manual refresh until the draft ends', async () => { | 349 | + | test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => { |
| 350 | - | const c = clock(); let blocked = false, applied = 0, resolve; const notices = []; | 350 | + | const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = []; |
| 351 | const r = window.ermiteMR.refresher({ blocked: () => blocked, | 351 | const r = window.ermiteMR.refresher({ blocked: () => blocked, | ||
| 352 | - | fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; }, | 352 | + | fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; }, |
| 353 | notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') }); | 353 | notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') }); | ||
| 354 | try { | 354 | try { | ||
| 355 | r.request(); const first = c.tick(); blocked = true; resolve('page'); await first; | 355 | r.request(); const first = c.tick(); blocked = true; resolve('page'); await first; | ||
| 356 | assert.equal(applied, 0); assert.deepEqual(notices, [true]); | 356 | assert.equal(applied, 0); assert.deepEqual(notices, [true]); | ||
| 357 | - | r.request(true); await c.tick(); assert.equal(applied, 0); | 357 | + | r.request(true); const manual = c.tick(); |
| 358 | + | assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft'); | |||
| 359 | + | await manual; assert.equal(applied, 0); | |||
| 358 | blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second; | 360 | blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second; | ||
| 359 | assert.equal(applied, 1); assert.equal(notices.at(-1), false); | 361 | assert.equal(applied, 1); assert.equal(notices.at(-1), false); | ||
| 360 | } finally { c.restore(); } | 362 | } finally { c.restore(); } | ||
| @@ -453,3 +455,45 @@ test('anchor restoration retains the server encoding of tilde and star paths', ( | |||||
| 453 | } | 455 | } | ||
| 454 | } finally { Object.assign(globalThis, saved); } | 456 | } finally { Object.assign(globalThis, saved); } | ||
| 455 | }); | 457 | }); | ||
| 458 | + | ||||
| 459 | + | for (const control of [ | |||
| 460 | + | { type: 'radio', checked: true, defaultChecked: false }, | |||
| 461 | + | { type: 'checkbox', checked: false, defaultChecked: true }, | |||
| 462 | + | { type: 'number', name: 'start_line', value: '10', defaultValue: '12' }, | |||
| 463 | + | { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] }, | |||
| 464 | + | ]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => { | |||
| 465 | + | const saved = document; | |||
| 466 | + | try { | |||
| 467 | + | globalThis.document = { activeElement: null, querySelectorAll(sel) { | |||
| 468 | + | assert.equal(sel, ':is(form.cform, .cmt form) :is(textarea, input, select)'); | |||
| 469 | + | return [control]; | |||
| 470 | + | } }; | |||
| 471 | + | assert.equal(window.ermiteMR.hasDraft(), true); | |||
| 472 | + | } finally { globalThis.document = saved; } | |||
| 473 | + | }); | |||
| 474 | + | ||||
| 475 | + | test('intercepted Cancel resets the entire comment form', () => { | |||
| 476 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]])); | |||
| 477 | + | const c = clock(), handlers = {}; | |||
| 478 | + | const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } }; | |||
| 479 | + | const radio = { type: 'radio', checked: true, defaultChecked: false }; | |||
| 480 | + | const line = { type: 'number', value: '2', defaultValue: '4' }; | |||
| 481 | + | let reset = 0, prevented = false; | |||
| 482 | + | const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } }; | |||
| 483 | + | const cancel = { closest: () => form }; | |||
| 484 | + | globalThis.document = { activeElement: null, | |||
| 485 | + | querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null, | |||
| 486 | + | querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [], | |||
| 487 | + | addEventListener: (type, fn) => { handlers[type] = fn; } }; | |||
| 488 | + | globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} }; | |||
| 489 | + | globalThis.addEventListener = () => {}; | |||
| 490 | + | globalThis.matchMedia = () => ({ matches: true, addEventListener() {} }); | |||
| 491 | + | try { | |||
| 492 | + | window.ermiteMR.start(); | |||
| 493 | + | window.ermiteMR.headChanged(); | |||
| 494 | + | handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } }); | |||
| 495 | + | assert.equal(prevented, true); | |||
| 496 | + | assert.equal(reset, 1); | |||
| 497 | + | assert.equal(window.ermiteMR.hasDraft(), false); | |||
| 498 | + | } finally { Object.assign(globalThis, saved); c.restore(); } | |||
| 499 | + | }); | |||
M internal/adapters/web/static/js/ermite.js +24 -0
| @@ -853,6 +853,17 @@ | |||
| 853 | 853 | // reloads the page. A stream the server refuses on reconnection (the | |
| 854 | 854 | // repository is gone, the session ended) makes the page reload. | |
| 855 | 855 | window.ermiteLive = (() => { | |
| 856 | + | let connection = null; | |
| 857 | + | let tearingDown = 0; | |
| 858 | + | // hx-sse has no public close API. Its cleanup aborts the fetch and leaves | |
| 859 | + | // reader.cancel() uncaught. Suppress only AbortError during our delete swap | |
| 860 | + | // (through the rejection-reporting task), not ordinary stream/app failures. | |
| 861 | + | window.addEventListener?.('unhandledrejection', (event) => { | |
| 862 | + | if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault(); | |
| 863 | + | }); | |
| 864 | + | window.addEventListener?.('htmx:sse:after:connection', (event) => { | |
| 865 | + | if (event.target.matches?.('.hint-live')) connection = event.detail.connection; | |
| 866 | + | }); | |
| 856 | 867 | let reloading = false; | |
| 857 | 868 | let stopped = false; | |
| 858 | 869 | // A stream that drops and comes back within LOST_AFTER (a proxy or the | |
| @@ -907,6 +918,19 @@ window.ermiteLive = (() => { | |||
| 907 | 918 | // fitIn lays out the one-line chip lists of a fragment now in the page. | |
| 908 | 919 | const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); }; | |
| 909 | 920 | return { | |
| 921 | + | async teardown(remove) { | |
| 922 | + | ++tearingDown; | |
| 923 | + | try { return await remove(); } | |
| 924 | + | finally { setTimeout(() => { --tearingDown; }, 0); } | |
| 925 | + | }, | |
| 926 | + | resumeStream(element) { | |
| 927 | + | // The public connection includes id-only messages as well as named ones. | |
| 928 | + | const id = connection?.lastEventId; | |
| 929 | + | if (!id) return; | |
| 930 | + | const url = new URL(element.getAttribute('hx-sse:connect'), location.href); | |
| 931 | + | url.searchParams.set('after', id); | |
| 932 | + | element.setAttribute('hx-sse:connect', url.pathname + url.search); | |
| 933 | + | }, | |
| 910 | 934 | // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows | |
| 911 | 935 | // only if the stream is still down LOST_AFTER later. | |
| 912 | 936 | lost() { | |
| @@ -853,6 +853,17 @@ | |||||
| 853 | // reloads the page. A stream the server refuses on reconnection (the | 853 | // reloads the page. A stream the server refuses on reconnection (the | ||
| 854 | // repository is gone, the session ended) makes the page reload. | 854 | // repository is gone, the session ended) makes the page reload. | ||
| 855 | window.ermiteLive = (() => { | 855 | window.ermiteLive = (() => { | ||
| 856 | + | let connection = null; | |||
| 857 | + | let tearingDown = 0; | |||
| 858 | + | // hx-sse has no public close API. Its cleanup aborts the fetch and leaves | |||
| 859 | + | // reader.cancel() uncaught. Suppress only AbortError during our delete swap | |||
| 860 | + | // (through the rejection-reporting task), not ordinary stream/app failures. | |||
| 861 | + | window.addEventListener?.('unhandledrejection', (event) => { | |||
| 862 | + | if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault(); | |||
| 863 | + | }); | |||
| 864 | + | window.addEventListener?.('htmx:sse:after:connection', (event) => { | |||
| 865 | + | if (event.target.matches?.('.hint-live')) connection = event.detail.connection; | |||
| 866 | + | }); | |||
| 856 | let reloading = false; | 867 | let reloading = false; | ||
| 857 | let stopped = false; | 868 | let stopped = false; | ||
| 858 | // A stream that drops and comes back within LOST_AFTER (a proxy or the | 869 | // A stream that drops and comes back within LOST_AFTER (a proxy or the | ||
| @@ -907,6 +918,19 @@ window.ermiteLive = (() => { | |||||
| 907 | // fitIn lays out the one-line chip lists of a fragment now in the page. | 918 | // fitIn lays out the one-line chip lists of a fragment now in the page. | ||
| 908 | const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); }; | 919 | const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); }; | ||
| 909 | return { | 920 | return { | ||
| 921 | + | async teardown(remove) { | |||
| 922 | + | ++tearingDown; | |||
| 923 | + | try { return await remove(); } | |||
| 924 | + | finally { setTimeout(() => { --tearingDown; }, 0); } | |||
| 925 | + | }, | |||
| 926 | + | resumeStream(element) { | |||
| 927 | + | // The public connection includes id-only messages as well as named ones. | |||
| 928 | + | const id = connection?.lastEventId; | |||
| 929 | + | if (!id) return; | |||
| 930 | + | const url = new URL(element.getAttribute('hx-sse:connect'), location.href); | |||
| 931 | + | url.searchParams.set('after', id); | |||
| 932 | + | element.setAttribute('hx-sse:connect', url.pathname + url.search); | |||
| 933 | + | }, | |||
| 910 | // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows | 934 | // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows | ||
| 911 | // only if the stream is still down LOST_AFTER later. | 935 | // only if the stream is still down LOST_AFTER later. | ||
| 912 | lost() { | 936 | lost() { | ||
M internal/adapters/web/static/js/mr.js +14 -3
| @@ -456,7 +456,14 @@ window.ermiteMR = (() => { | |||
| 456 | 456 | } | |
| 457 | 457 | ||
| 458 | 458 | function hasDraft() { | |
| 459 | - | return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) || | |
| 459 | + | // Only form controls, never diff rows. Inline edits live in .cmt forms. | |
| 460 | + | return $$(':is(form.cform, .cmt form) :is(textarea, input, select)').some((t) => { | |
| 461 | + | if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false; | |
| 462 | + | if (document.activeElement === t) return true; | |
| 463 | + | if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked; | |
| 464 | + | if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected); | |
| 465 | + | return t.value !== t.defaultValue; | |
| 466 | + | }) || | |
| 460 | 467 | !!document.activeElement?.closest?.('.cform'); | |
| 461 | 468 | } | |
| 462 | 469 | ||
| @@ -523,8 +530,12 @@ window.ermiteMR = (() => { | |||
| 523 | 530 | for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus'); | |
| 524 | 531 | const marker = document.createComment('MR region'); | |
| 525 | 532 | old.before(marker); | |
| 526 | - | await window.htmx.swap({ sourceElement: old, target: old, text: '', | |
| 533 | + | const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '', | |
| 527 | 534 | swap: 'delete settle:0 transition:false', push: false, replace: false }); | |
| 535 | + | if (old.matches('.hint-live')) { | |
| 536 | + | window.ermiteLive.resumeStream(fresh); | |
| 537 | + | await window.ermiteLive.teardown(remove); | |
| 538 | + | } else await remove(); | |
| 528 | 539 | const node = document.adoptNode(fresh); | |
| 529 | 540 | marker.replaceWith(node); | |
| 530 | 541 | window.htmx.process(node); | |
| @@ -639,7 +650,7 @@ window.ermiteMR = (() => { | |||
| 639 | 650 | if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |
| 640 | 651 | e.preventDefault(); | |
| 641 | 652 | const form = cancel.closest('.cform'); | |
| 642 | - | for (const t of $$('textarea', form)) t.value = t.defaultValue; | |
| 653 | + | form.reset(); | |
| 643 | 654 | if (form.contains(document.activeElement)) document.activeElement.blur(); | |
| 644 | 655 | headRefresh.resume(); | |
| 645 | 656 | }); | |
| @@ -456,7 +456,14 @@ window.ermiteMR = (() => { | |||||
| 456 | } | 456 | } | ||
| 457 | 457 | ||||
| 458 | function hasDraft() { | 458 | function hasDraft() { | ||
| 459 | - | return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) || | 459 | + | // Only form controls, never diff rows. Inline edits live in .cmt forms. |
| 460 | + | return $$(':is(form.cform, .cmt form) :is(textarea, input, select)').some((t) => { | |||
| 461 | + | if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false; | |||
| 462 | + | if (document.activeElement === t) return true; | |||
| 463 | + | if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked; | |||
| 464 | + | if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected); | |||
| 465 | + | return t.value !== t.defaultValue; | |||
| 466 | + | }) || | |||
| 460 | !!document.activeElement?.closest?.('.cform'); | 467 | !!document.activeElement?.closest?.('.cform'); | ||
| 461 | } | 468 | } | ||
| 462 | 469 | ||||
| @@ -523,8 +530,12 @@ window.ermiteMR = (() => { | |||||
| 523 | for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus'); | 530 | for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus'); | ||
| 524 | const marker = document.createComment('MR region'); | 531 | const marker = document.createComment('MR region'); | ||
| 525 | old.before(marker); | 532 | old.before(marker); | ||
| 526 | - | await window.htmx.swap({ sourceElement: old, target: old, text: '', | 533 | + | const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '', |
| 527 | swap: 'delete settle:0 transition:false', push: false, replace: false }); | 534 | swap: 'delete settle:0 transition:false', push: false, replace: false }); | ||
| 535 | + | if (old.matches('.hint-live')) { | |||
| 536 | + | window.ermiteLive.resumeStream(fresh); | |||
| 537 | + | await window.ermiteLive.teardown(remove); | |||
| 538 | + | } else await remove(); | |||
| 528 | const node = document.adoptNode(fresh); | 539 | const node = document.adoptNode(fresh); | ||
| 529 | marker.replaceWith(node); | 540 | marker.replaceWith(node); | ||
| 530 | window.htmx.process(node); | 541 | window.htmx.process(node); | ||
| @@ -639,7 +650,7 @@ window.ermiteMR = (() => { | |||||
| 639 | if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | 650 | if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | ||
| 640 | e.preventDefault(); | 651 | e.preventDefault(); | ||
| 641 | const form = cancel.closest('.cform'); | 652 | const form = cancel.closest('.cform'); | ||
| 642 | - | for (const t of $$('textarea', form)) t.value = t.defaultValue; | 653 | + | form.reset(); |
| 643 | if (form.contains(document.activeElement)) document.activeElement.blur(); | 654 | if (form.contains(document.activeElement)) document.activeElement.blur(); | ||
| 644 | headRefresh.resume(); | 655 | headRefresh.resume(); | ||
| 645 | }); | 656 | }); | ||
M internal/app/web_events_test.go +12 -5
| @@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) { | |||
| 245 | 245 | } | |
| 246 | 246 | s.cancel() | |
| 247 | 247 | ||
| 248 | - | r := h.sse("topic=home", ids[0]) | |
| 249 | - | for _, want := range ids[1:] { | |
| 250 | - | ev := r.next(t, watchWait) | |
| 251 | - | assert.Equal(t, want, ev.id) | |
| 252 | - | assert.Equal(t, "home-list", ev.name) | |
| 248 | + | // A replacement stream resumes via the URL; reconnect headers still win. | |
| 249 | + | for _, r := range []*sseStream{ | |
| 250 | + | h.sse("topic=home&after="+ids[0], ""), | |
| 251 | + | h.sse("topic=home&after=invalid", ids[0]), | |
| 252 | + | } { | |
| 253 | + | for _, want := range ids[1:] { | |
| 254 | + | ev := r.next(t, watchWait) | |
| 255 | + | assert.Equal(t, want, ev.id) | |
| 256 | + | assert.Equal(t, "home-list", ev.name) | |
| 257 | + | } | |
| 258 | + | r.cancel() | |
| 253 | 259 | } | |
| 260 | + | r := h.sse("topic=home", ids[2]) | |
| 254 | 261 | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 255 | 262 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 256 | 263 | live := r.next(t, watchWait) | |
| @@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) { | |||||
| 245 | } | 245 | } | ||
| 246 | s.cancel() | 246 | s.cancel() | ||
| 247 | 247 | ||||
| 248 | - | r := h.sse("topic=home", ids[0]) | 248 | + | // A replacement stream resumes via the URL; reconnect headers still win. |
| 249 | - | for _, want := range ids[1:] { | 249 | + | for _, r := range []*sseStream{ |
| 250 | - | ev := r.next(t, watchWait) | 250 | + | h.sse("topic=home&after="+ids[0], ""), |
| 251 | - | assert.Equal(t, want, ev.id) | 251 | + | h.sse("topic=home&after=invalid", ids[0]), |
| 252 | - | assert.Equal(t, "home-list", ev.name) | 252 | + | } { |
| 253 | + | for _, want := range ids[1:] { | |||
| 254 | + | ev := r.next(t, watchWait) | |||
| 255 | + | assert.Equal(t, want, ev.id) | |||
| 256 | + | assert.Equal(t, "home-list", ev.name) | |||
| 257 | + | } | |||
| 258 | + | r.cancel() | |||
| 253 | } | 259 | } | ||
| 260 | + | r := h.sse("topic=home", ids[2]) | |||
| 254 | h.writeFile(wt, "live.txt", []byte("x\n")) | 261 | h.writeFile(wt, "live.txt", []byte("x\n")) | ||
| 255 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | 262 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | ||
| 256 | live := r.next(t, watchWait) | 263 | live := r.next(t, watchWait) | ||